From 986e97acb62cb6f019f6ea7045cdcaf4071c6b52 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 10 Aug 2026 12:46:12 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- firstleg/AGENTS.md | 2 +- .../service/LoadingDeliveryService.java | 1 + .../db/phase3-inbound-parent-child-schema.sql | 43 ++++++++++++++++--- ...lfillmentCompletionMapperContractTest.java | 36 ++++++++++++++++ .../service/LoadingDeliveryServiceTest.java | 39 +++++++++++++++++ .../InboundParentChildSqlContractTest.java | 17 +++++++- .../PdaSplitSettlementSqlContractTest.java | 6 +++ .../com/baiyue/order/dto/OrderSplitDtos.java | 7 ++- .../entity/OrderFulfillmentSplitEntity.java | 1 + .../service/OrderFulfillmentSplitService.java | 4 +- .../db/phase3-order-parent-child-schema.sql | 3 ++ .../src/main/resources/openapi/order-v1.yaml | 40 +++++++++++++++++ .../phase3-order-splitting.mjs | 10 ++++- 14 files changed, 198 insertions(+), 13 deletions(-) create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/loading/mapper/LoadingFulfillmentCompletionMapperContractTest.java diff --git a/AGENTS.md b/AGENTS.md index 30bce5762..14c5caa91 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ - 一期:`auth`、`iam`、`warehouse`、`order`、`firstleg`、`file` 完成登录、客户/RBAC、公共仓、订单闭环;先读 `../docs/一期/README.md` 及闭环设计。 - 二期:`firstleg` 主导入库/测量/审核/计费,`warehouse` 管托盘库位,`file` 管测量图片,`integration-service` 管 OCR,`order` 仅提供货物内部查询;终点为 `WAIT_SPLIT + APPROVED + CONFIRMED`,不实现分柜、装柜、PDA 和完整财务。 -- 三期:已确认范围可按 `../docs/三期/三期开工门禁.md` 进入契约和模块实现。`firstleg` 负责履约编排、柜次、排柜、订舱、拖车、PDA 与 `LOADED` 门禁;`order`、`warehouse`、`file`、`iam`、`gateway` 和 `integration-service` 仅在各自契约需要时协作。未冻结的字段、权限、迁移或设备事项只阻断对应模块。 +- 三期:已确认范围可按 `../docs/三期/三期开工门禁.md` 进入契约和模块实现。`firstleg` 负责履约编排、柜次、排柜、订舱、拖车、PDA 与 `LOADED` 门禁;`order`、`warehouse`、`file`、`iam`、`gateway` 和 `integration-service` 仅在各自契约需要时协作。未冻结的字段、权限、迁移或跨服务调用只阻断对应模块。 ## 契约、数据库与验证入口 diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 0f51e510f..8dfa42e96 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -39,4 +39,4 @@ - 一期:履约任务与分配;先读 `../../docs/一期/README.md`。 - 二期:入库、测量、审核、计费闭环;先读 `../../docs/二期/README.md`、主流程和联调报告。 -- 三期:负责订单履约编排、柜次、智能排柜、订舱、拖车、PDA 作业与 `LOADED` 门禁;先读 `../../docs/三期/三期开工门禁.md`。已确认范围按 Controller、DTO、OpenAPI、迁移和调用方共同实现;设备与真机事项只阻断相应验收,不得阻断已确认契约。 +- 三期:负责订单履约编排、柜次、智能排柜、订舱、拖车、PDA 作业与 `LOADED` 门禁;先读 `../../docs/三期/三期开工门禁.md`。已确认范围按 Controller、DTO、OpenAPI、迁移和调用方共同实现;PDA 按 uni-app 在线更新安排开发与验收,不另设设备型号门禁。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java index 332abcd6b..5ed34234e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java @@ -33,6 +33,7 @@ import java.security.MessageDigest; import java.util.HexFormat; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; diff --git a/firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql b/firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql index e0a06a23b..c6fbf775a 100644 --- a/firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql +++ b/firstleg/src/main/resources/db/phase3-inbound-parent-child-schema.sql @@ -694,12 +694,35 @@ BEGIN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单关系字段未完整创建'; END IF; IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics - WHERE table_schema = DATABASE() - AND index_name IN ('uk_inbound_master_order', 'uk_inbound_parent_split_sequence', - 'uk_inbound_split_operation', 'uk_inbound_split_idempotency', - 'uk_inbound_split_history_operation')) <> 5 THEN + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' + AND index_name IN ('uk_inbound_master_order', 'uk_inbound_parent_split_sequence')) <> 2 + OR (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_split_batch' + AND index_name IN ('uk_inbound_split_operation', 'uk_inbound_split_idempotency', + 'uk_inbound_split_source_once')) <> 3 + OR (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_split_item' + AND index_name IN ('uk_inbound_split_batch_line', + 'uk_inbound_split_batch_source_child')) <> 2 + OR (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_split_history' + AND index_name = 'uk_inbound_split_history_operation') <> 1 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单唯一约束未完整创建'; END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_split_batch' + AND column_name IN ('source_inbound_id', 'loaded_child_inbound_id', + 'remainder_child_inbound_id', 'loaded_package_count', + 'remaining_package_count', 'loading_plan_id', + 'container_booking_id')) <> 7 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次来源、子单、数量或柜次字段未完整创建'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_split_item' + AND column_name IN ('source_measurement_id', 'source_measurement_item_id') + AND is_nullable = 'NO') <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分数量台账必须绑定非空测量来源'; + END IF; IF (SELECT COUNT(*) FROM information_schema.table_constraints WHERE constraint_schema = DATABASE() AND constraint_name IN ('fk_inbound_parent_order', 'fk_inbound_root_order', @@ -725,10 +748,20 @@ BEGIN SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' - AND index_name IN ('uk_inbound_tenant_order', 'uk_inbound_one_child') + AND index_name IN ('uk_inbound_tenant_order', 'uk_inbound_one_child', + 'uk_inbound_one_secondary', 'uk_inbound_parent_sequence') ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '旧订单唯一索引仍阻止创建子入仓单'; END IF; + IF EXISTS ( + SELECT 1 FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'fl_inbound_split_item' + AND index_name IN ('uk_inbound_split_child', 'uk_inbound_split_batch_child', + 'uk_inbound_split_batch_source_item') + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '旧拆分明细唯一索引仍阻止数量守恒分配'; + END IF; END$$ DELIMITER ; CALL phase3_assert_inbound_parent_child_schema(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/mapper/LoadingFulfillmentCompletionMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/mapper/LoadingFulfillmentCompletionMapperContractTest.java new file mode 100644 index 000000000..0ca07cc1f --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/mapper/LoadingFulfillmentCompletionMapperContractTest.java @@ -0,0 +1,36 @@ +package com.baiyue.firstleg.loading.mapper; + +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class LoadingFulfillmentCompletionMapperContractTest { + @Test + void completesLoadedSliceAndAggregatesRootOnlyAfterAllSlicesAreTerminal() throws Exception { + String target = String.join(" ", LoadingInboundMapper.class + .getMethod("completionTarget", long.class, long.class) + .getAnnotation(Select.class).value()).replaceAll("\\s+", " "); + String completeChild = String.join(" ", LoadingInboundMapper.class + .getMethod("completeLoadedChild", long.class, long.class, long.class, int.class) + .getAnnotation(Update.class).value()).replaceAll("\\s+", " "); + String aggregateRoot = String.join(" ", LoadingInboundMapper.class + .getMethod("aggregateRootLoaded", long.class, long.class, long.class) + .getAnnotation(Update.class).value()).replaceAll("\\s+", " "); + + assertThat(target) + .contains("batch.source_inbound_id=#{inboundId}") + .contains("batch.loaded_child_inbound_id=#{inboundId}") + .contains("batch.status='APPLIED'"); + assertThat(completeChild) + .contains("batch.loaded_child_inbound_id=child.id") + .contains("child.process_status='LOADED'") + .contains("source.version=#{expectedSourceVersion}"); + assertThat(aggregateRoot) + .contains("remainder.split_purpose='REMAINDER'") + .contains("remainder.split_status='NOT_SPLIT'") + .contains("loaded.split_purpose='LOADED'") + .contains("loaded.process_status<>'LOADED'"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java index 9c9e512f0..b8a7b630b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java @@ -10,6 +10,7 @@ import com.baiyue.firstleg.loading.dto.LoadingRequests.VoidEvidenceRequest; import com.baiyue.firstleg.loading.dto.LoadingResponses.LoadingActionResponse; import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity; import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.Snapshot; +import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.CompletionTarget; import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService; import com.baiyue.firstleg.mapper.LoadingPickItemMapper; import com.baiyue.firstleg.repository.LoadingPickRepository; @@ -79,6 +80,44 @@ class LoadingDeliveryServiceTest { verify(histories).insert(any(InboundStatusHistoryEntity.class)); } + @Test + void completesLoadedFulfillmentSliceBeforeAggregatingRoot() { + when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); + when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "INBOUNDED", 3)); + when(repository.completionTarget(10, 20)).thenReturn(new CompletionTarget( + 20L, 21L, 20L, "INBOUNDED", 3, "LOADING", 1)); + LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class); + when(progress.getExpectedCount()).thenReturn(5); + when(progress.getPickedCount()).thenReturn(5); + when(progress.getTaskCount()).thenReturn(1); + when(progress.getCompletedTaskCount()).thenReturn(1); + when(progress.getUnresolvedCount()).thenReturn(0); + when(loadingPicks.inboundProgress(10, 20)).thenReturn(progress); + when(repository.listEvidence(10, 20)).thenReturn(List.of( + evidence("PHOTO_STEP_1", 1L, null), evidence("PHOTO_STEP_2", 2L, null), + evidence("PHOTO_STEP_3", 3L, null), evidence("PHOTO_STEP_4", 4L, null), + evidence("SEAL", 5L, "LOCK-001"))); + when(files.validate(eq("token"), eq(10L), eq(20L), anyLong())).thenAnswer(invocation -> { + long fileId = invocation.getArgument(3); + String type = fileId == 5L ? "SEAL" : "PHOTO_STEP_" + fileId; + return ApiResponse.success(new LoadingEvidenceFileClient.FileMetadata( + fileId, type.toLowerCase() + ".png", "LOADING_" + type, "image/png")); + }); + when(repository.completeLoadedChild(10, 20, 7, 3)).thenReturn(1); + + LoadingActionResponse result = service.completeLoading(10, 20, 7, "admin", "slice-1", + new CompleteLoadingRequest(3, null)); + + assertThat(result.mainStatus()).isEqualTo("LOADED"); + verify(loadingModelBridge).requireCompleted(10, 20); + verify(completionGate).requireBookingTransportAndCustoms(10, 20); + verify(repository).completeLoadedChild(10, 20, 7, 3); + verify(repository).aggregateRootLoaded(10, 20, 7); + verify(histories).insert(argThat((InboundStatusHistoryEntity history) -> + Long.valueOf(21L).equals(history.getInboundId()))); + verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt()); + } + @Test void rejectsCompletionWhenEvidenceIsIncomplete() { when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java index 1e1312924..cfc838e8b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java @@ -62,7 +62,7 @@ class InboundParentChildSqlContractTest { .contains("NEW.remaining_package_count") .contains("NEW.loaded_package_count + NEW.remaining_package_count <> NEW.package_count") .contains("source_batch.inbound_id = COALESCE(batch.source_inbound_id, batch.parent_inbound_id)") - .contains("拆分批次来源必须是根主单或其余量子单") + .contains("拆分批次来源必须是箱数匹配且未消费的根主单或余量子单") .contains("SUM(split_item.package_count) > source_item.package_count") .contains("子单承接箱数超过来源测量箱规箱数") .contains("child_order.package_count >= NEW.package_count") @@ -90,7 +90,8 @@ class InboundParentChildSqlContractTest { .contains("'fl_inbound_measurement', 'source_measurement_id'") .contains("'fl_inbound_measurement_item', 'split_item_id'") .contains("'fl_inbound_measurement_item', 'source_measurement_item_id'") - .contains("source_measurement_id BIGINT UNSIGNED NULL") + .contains("source_measurement_id BIGINT UNSIGNED NOT NULL") + .contains("source_measurement_item_id BIGINT UNSIGNED NOT NULL") .contains("fk_inbound_split_item_measurement") .contains("fk_measure_split_batch") .contains("fk_measure_source_batch") @@ -100,6 +101,18 @@ class InboundParentChildSqlContractTest { .contains("idx_measure_item_split_source"); } + @Test + void finalAssertionIsTableSpecificAndRejectsLegacyIndexes() { + assertThat(sql) + .contains("table_name = 'fl_inbound_split_batch'") + .contains("uk_inbound_split_source_once") + .contains("table_name = 'fl_inbound_split_item'") + .contains("uk_inbound_split_batch_source_child") + .contains("is_nullable = 'NO'") + .contains("'uk_inbound_one_secondary', 'uk_inbound_parent_sequence'") + .contains("'uk_inbound_split_batch_source_item'"); + } + private static String loadSql() { try { return new ClassPathResource("db/phase3-inbound-parent-child-schema.sql") 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 ffcb7e4d2..3af078a5f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/PdaSplitSettlementSqlContractTest.java @@ -66,5 +66,11 @@ class PdaSplitSettlementSqlContractTest { .contains("container_booking_id") .contains("#{loadingPlanId}") .contains("#{containerBookingId}"); + + String clone = InboundSplitSettlementMapper.class.getMethod("cloneChildWithPurpose", + long.class, long.class, long.class, long.class, String.class, int.class, + int.class, String.class, long.class, String.class) + .getAnnotation(Insert.class).value()[0]; + assertThat(clone).contains("CASE WHEN #{splitPurpose}='LOADED' THEN 'LOADING' ELSE 'INBOUNDED' END"); } } diff --git a/order/src/main/java/com/baiyue/order/dto/OrderSplitDtos.java b/order/src/main/java/com/baiyue/order/dto/OrderSplitDtos.java index e374e63b1..eb05c1445 100644 --- a/order/src/main/java/com/baiyue/order/dto/OrderSplitDtos.java +++ b/order/src/main/java/com/baiyue/order/dto/OrderSplitDtos.java @@ -39,7 +39,12 @@ public final class OrderSplitDtos { ) {} public record Child(Long orderId, String orderNo, Integer splitSequence, - String splitRole, Integer packageCount) {} + String splitRole, Integer packageCount, Long sourceOrderId, + Long loadingPlanId, Long containerBookingId) { + public Child(Long orderId, String orderNo, Integer splitSequence, String splitRole, Integer packageCount) { + this(orderId, orderNo, splitSequence, splitRole, packageCount, null, null, null); + } + } public record Tree(Long parentOrderId, String parentOrderNo, Integer parentVersion, List children, Integer allocatedPackageCount, diff --git a/order/src/main/java/com/baiyue/order/entity/OrderFulfillmentSplitEntity.java b/order/src/main/java/com/baiyue/order/entity/OrderFulfillmentSplitEntity.java index fd4e00c55..92529950c 100644 --- a/order/src/main/java/com/baiyue/order/entity/OrderFulfillmentSplitEntity.java +++ b/order/src/main/java/com/baiyue/order/entity/OrderFulfillmentSplitEntity.java @@ -15,6 +15,7 @@ public class OrderFulfillmentSplitEntity { @TableId(type = IdType.ASSIGN_ID) private Long id; private Long tenantId; private Long parentOrderId; + private Long sourceOrderId; private Long childOrderId; private Long loadingPlanId; private Long containerBookingId; 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 ffad92d3d..5c38691b2 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderFulfillmentSplitService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderFulfillmentSplitService.java @@ -36,6 +36,8 @@ public class OrderFulfillmentSplitService { return response(req, splits.lockOrder(req.tenantId(), req.parentOrderId()), replay); } OrderEntity parent = splits.lockOrder(req.tenantId(), req.parentOrderId()); + replay = splits.findByIdempotency(req.tenantId(), req.parentOrderId(), req.idempotencyKey()); + if (!replay.isEmpty()) return response(req, parent, replay); if (parent == null) throw new BizException(ResultCode.NOT_FOUND, "父订单不存在或不属于该客户"); if (!req.parentOrderId().equals(req.sourceOrderId()) && !isDirectChild(req.sourceOrderId(), parent)) throw new BizException(ResultCode.CONFLICT, "来源订单必须是父订单或其直接子订单"); @@ -67,7 +69,7 @@ public class OrderFulfillmentSplitService { List rows = splits.findByParent(tenantId, parentId); Map children = new HashMap<>(); for (OrderFulfillmentSplitEntity row: rows) { OrderEntity child=orders.selectById(row.getChildOrderId()); if(child!=null&&tenantId==child.getTenantId()) children.put(child.getId(),child); } - List result = rows.stream().map(r -> { OrderEntity c=children.get(r.getChildOrderId()); return new OrderSplitDtos.Child(r.getChildOrderId(), c==null?null:c.getOrderNo(), c==null?null:c.getSplitSequence(), r.getSplitRole(), "LOADED".equals(r.getSplitRole())?r.getLoadedPackageCount():r.getRemainingPackageCount()); }).toList(); + List result = rows.stream().map(r -> { OrderEntity c=children.get(r.getChildOrderId()); return new OrderSplitDtos.Child(r.getChildOrderId(), c==null?null:c.getOrderNo(), c==null?null:c.getSplitSequence(), r.getSplitRole(), "LOADED".equals(r.getSplitRole())?r.getLoadedPackageCount():r.getRemainingPackageCount(), r.getSourceOrderId(), r.getLoadingPlanId(), r.getContainerBookingId()); }).toList(); int allocated = rows.stream().filter(r -> "LOADED".equals(r.getSplitRole())).mapToInt(OrderFulfillmentSplitEntity::getLoadedPackageCount).sum(); return new OrderSplitDtos.Tree(parent.getId(), parent.getOrderNo(), parent.getVersion(), result, allocated, Math.max(0, rows.stream().mapToInt(OrderFulfillmentSplitEntity::getSourcePackageCount).findFirst().orElse(0)-allocated)); } diff --git a/order/src/main/resources/db/phase3-order-parent-child-schema.sql b/order/src/main/resources/db/phase3-order-parent-child-schema.sql index fdf201faf..14620c6b4 100644 --- a/order/src/main/resources/db/phase3-order-parent-child-schema.sql +++ b/order/src/main/resources/db/phase3-order-parent-child-schema.sql @@ -53,6 +53,7 @@ CREATE TABLE IF NOT EXISTS ord_order_fulfillment_split ( id BIGINT UNSIGNED NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL, parent_order_id BIGINT UNSIGNED NOT NULL, + source_order_id BIGINT UNSIGNED NOT NULL, child_order_id BIGINT UNSIGNED NOT NULL, split_role VARCHAR(12) NOT NULL COMMENT 'LOADED或REMAINDER', loading_plan_id BIGINT UNSIGNED NULL, @@ -74,6 +75,7 @@ CREATE TABLE IF NOT EXISTS ord_order_fulfillment_split ( UNIQUE KEY uk_order_split_idempotency (tenant_id,parent_order_id,idempotency_key,split_role), UNIQUE KEY uk_order_split_child (tenant_id,child_order_id), KEY idx_order_split_parent (tenant_id,parent_order_id,created_at,id), + KEY idx_order_split_source (tenant_id,source_order_id,split_role), KEY idx_order_split_container (tenant_id,container_booking_id,created_at,id), CONSTRAINT fk_order_split_parent FOREIGN KEY(parent_order_id) REFERENCES ord_order(id), CONSTRAINT fk_order_split_child FOREIGN KEY(child_order_id) REFERENCES ord_order(id), @@ -114,6 +116,7 @@ END$$ DELIMITER ; CALL phase3_order_add_column('ord_order_fulfillment_split','split_role',"VARCHAR(12) NOT NULL DEFAULT 'LOADED' COMMENT 'LOADED或REMAINDER'"); +CALL phase3_order_add_column('ord_order_fulfillment_split','source_order_id','BIGINT UNSIGNED NULL COMMENT ''实际被消费的来源订单ID'''); CALL phase3_order_add_column('ord_order_fulfillment_split','request_hash',"CHAR(64) NULL COMMENT '请求内容SHA-256;存量记录允许为空'"); DROP PROCEDURE IF EXISTS phase3_order_drop_split_index; diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index df00e1543..3dd8d80f5 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -228,6 +228,46 @@ paths: responses: '200': {description: 成功或业务失败,按响应体 code 判断} '403': {description: 需要 platform:order:edit 权限} + /internal/order/v1/fulfillment-splits: + post: + summary: 创建父订单装柜拆分(Firstleg 内部) + parameters: + - $ref: '#/components/parameters/InternalToken' + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/FulfillmentSplitCreateRequest' } + responses: + '200': { description: 成功, content: { application/json: { schema: { $ref: '#/components/schemas/FulfillmentSplitResponse' } } } } + '409': { description: 数量或版本/幂等冲突 } + + /internal/order/v1/fulfillment-splits/{tenantId}/{parentOrderId}: + get: + summary: 查询父子订单柜次树(Firstleg 内部) + parameters: + - $ref: '#/components/parameters/InternalToken' + - { name: tenantId, in: path, required: true, schema: { type: integer, format: int64 } } + - { name: parentOrderId, in: path, required: true, schema: { type: integer, format: int64 } } + responses: + '200': { description: 成功 } + '403': { description: 内部凭证无效 } + + /api/platform/v1/orders/{orderId}/fulfillment-tree: + get: + summary: 查询父子订单与柜次数量摘要 + parameters: + - { name: orderId, in: path, required: true, schema: { type: integer, format: int64 } } + - { name: tenantId, in: query, required: true, schema: { type: integer, format: int64 } } + responses: { '200': { description: 成功 }, '403': { description: 无权限 } } + + /api/merchant/v1/orders/{orderId}/fulfillment-tree: + get: + summary: 查询我的父子订单与柜次数量摘要 + parameters: + - { name: orderId, in: path, required: true, schema: { type: integer, format: int64 } } + responses: { '200': { description: 成功 }, '403': { description: 无权限 } } + components: securitySchemes: bearerAuth: diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index d228c8ec7..78a620060 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -15,9 +15,15 @@ export async function run(context) { state: 'pending-public-contract', assertions: [ '父单与自动剩余子单关系可由 HTTP 查询', + '一单多柜关系由 container aggregate 返回', + '一柜多单关系由 container aggregate 返回', '已装数量 + 自动子单剩余数量 = 拆分前数量', - '同一订单可分配多个柜次且同柜可包含多个订单', - '子单仅允许一层且继续拆分被拒绝', + '同一 Idempotency-Key 与相同参数重放返回同一拆分结果', + '同一 Idempotency-Key 与不同参数被冲突拒绝且无部分写入', + '旧 expectedVersion 被冲突拒绝且父子订单数量不变', + '平台与客户 fulfillment-tree 均隔离其他客户数据', + '子订单继续拆分被拒绝,不产生嵌套层级', + '已消费 REMAINDER 再次消费被拒绝且数量不变', ], }, }