diff --git a/AGENTS.md b/AGENTS.md index 3976a39a5..30bce5762 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 和完整财务。 -- 三期:当前 NO-GO;待 `../docs/三期/三期需求确认表.md` 的 P3 阻断项解除并锁定契约后,才扩展国内出仓、分柜、装柜、报关和 PDA 协作。 +- 三期:已确认范围可按 `../docs/三期/三期开工门禁.md` 进入契约和模块实现。`firstleg` 负责履约编排、柜次、排柜、订舱、拖车、PDA 与 `LOADED` 门禁;`order`、`warehouse`、`file`、`iam`、`gateway` 和 `integration-service` 仅在各自契约需要时协作。未冻结的字段、权限、迁移或设备事项只阻断对应模块。 ## 契约、数据库与验证入口 @@ -44,4 +44,4 @@ - 一期:`../docs/一期/README.md`、`../docs/一期/一期开发进度表.md`、`../docs/一期/一期头程订单闭环设计.md` - 二期:`../docs/二期/README.md`、`../docs/二期/二期需求优化版-v2-测量计费入库主流程.md`、需求确认清单及联调报告 -- 三期:`../docs/三期/README.md`、`../docs/三期/三期开发规划.md`、`../docs/三期/三期需求确认表.md`、页面与原型清单 +- 三期:`../docs/三期/README.md`、`../docs/三期/三期开工门禁.md`、`../docs/三期/三期开发规划.md`、`../docs/三期/三期需求确认表.md`、页面与原型清单 diff --git a/auth/AGENTS.md b/auth/AGENTS.md index 61efb99f9..abc8d9d4e 100644 --- a/auth/AGENTS.md +++ b/auth/AGENTS.md @@ -29,4 +29,4 @@ - 一期:登录、注册、账号状态和 Token;先读 `../../docs/一期/README.md`。 - 二期:无独立登录流程,仅保障入库/计费沿用 JWT、应用域和客户隔离;先读 `../../docs/二期/README.md`。 -- 三期:NO-GO,不新增出仓账号或角色;确认表解除阻断后再改权限。 +- 三期:没有独立账号或角色模型;认证与会话仅在 IAM 三期权限契约需要时保持兼容。先读 `../../docs/三期/三期开工门禁.md`,不得借三期新增租户层级或绕过服务端授权。 diff --git a/file/AGENTS.md b/file/AGENTS.md index f4ff06671..e1e5c358f 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -27,4 +27,4 @@ - 一期:资质上传与读取;先读 `../../docs/一期/README.md`。 - 二期:测量图片和 OCR 重试;先读 `../../docs/二期/README.md` 与第三方联调说明。 -- 三期:NO-GO,不新增装柜/报关附件模型;确认后再定义证据生命周期。 +- 三期:按稳定契约实现装柜五类证据和报关回执的存储、归属、鉴权与审计;先读 `../../docs/三期/三期开工门禁.md`。照片允许追加、错误记录仅作废不硬删除,客户不得读取照片或锁码。 diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 21e68af56..0f51e510f 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -39,4 +39,4 @@ - 一期:履约任务与分配;先读 `../../docs/一期/README.md`。 - 二期:入库、测量、审核、计费闭环;先读 `../../docs/二期/README.md`、主流程和联调报告。 -- 三期:NO-GO;待 P3 阻断解除后重新定义出仓状态机。 +- 三期:负责订单履约编排、柜次、智能排柜、订舱、拖车、PDA 作业与 `LOADED` 门禁;先读 `../../docs/三期/三期开工门禁.md`。已确认范围按 Controller、DTO、OpenAPI、迁移和调用方共同实现;设备与真机事项只阻断相应验收,不得阻断已确认契约。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingInboundMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingInboundMapper.java index 604b555e3..31d7ebd29 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingInboundMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/mapper/LoadingInboundMapper.java @@ -11,10 +11,67 @@ public interface LoadingInboundMapper { + "FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0") Snapshot find(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId); + @Select(""" + SELECT batch.source_inbound_id AS sourceInboundId, + batch.loaded_child_inbound_id AS loadedChildInboundId, + batch.parent_inbound_id AS rootInboundId, + source.process_status AS sourceStatus, source.version AS sourceVersion, + child.process_status AS loadedChildStatus, child.version AS loadedChildVersion + FROM fl_inbound_split_batch batch + JOIN fl_inbound_order source ON source.id=batch.source_inbound_id + AND source.tenant_id=batch.tenant_id AND source.deleted=0 + JOIN fl_inbound_order child ON child.id=batch.loaded_child_inbound_id + AND child.tenant_id=batch.tenant_id AND child.deleted=0 + WHERE batch.tenant_id=#{tenantId} AND batch.status='APPLIED' + AND (batch.source_inbound_id=#{inboundId} OR batch.loaded_child_inbound_id=#{inboundId}) + ORDER BY batch.id DESC LIMIT 1 + """) + CompletionTarget completionTarget(@Param("tenantId") long tenantId, + @Param("inboundId") long inboundId); + @Update("UPDATE fl_inbound_order SET process_status='LOADED', updated_by=#{operatorId}, version=version+1 " + "WHERE tenant_id=#{tenantId} AND id=#{inboundId} AND deleted=0 AND process_status='LOADING' AND version=#{expectedVersion}") int completeLoading(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, @Param("operatorId") long operatorId, @Param("expectedVersion") int expectedVersion); + @Update(""" + UPDATE fl_inbound_order child + JOIN fl_inbound_split_batch batch ON batch.loaded_child_inbound_id=child.id + AND batch.tenant_id=child.tenant_id AND batch.status='APPLIED' + JOIN fl_inbound_order source ON source.id=batch.source_inbound_id + AND source.tenant_id=batch.tenant_id AND source.deleted=0 + SET child.process_status='LOADED', child.updated_by=#{operatorId}, child.version=child.version+1 + WHERE child.tenant_id=#{tenantId} AND batch.source_inbound_id=#{sourceInboundId} + AND child.deleted=0 AND child.process_status='LOADING' + AND source.version=#{expectedSourceVersion} + """) + int completeLoadedChild(@Param("tenantId") long tenantId, + @Param("sourceInboundId") long sourceInboundId, + @Param("operatorId") long operatorId, + @Param("expectedSourceVersion") int expectedSourceVersion); + + @Update(""" + UPDATE fl_inbound_order root + SET root.process_status='LOADED', root.updated_by=#{operatorId}, root.version=root.version+1 + WHERE root.tenant_id=#{tenantId} AND root.id=#{rootInboundId} AND root.deleted=0 + AND root.inbound_role='MASTER' AND root.process_status IN ('INBOUNDED','LOADING') + AND NOT EXISTS ( + SELECT 1 FROM fl_inbound_order remainder + WHERE remainder.tenant_id=root.tenant_id AND remainder.parent_inbound_id=root.id + AND remainder.inbound_role='CHILD' AND remainder.split_purpose='REMAINDER' + AND remainder.split_status='NOT_SPLIT' AND remainder.deleted=0) + AND NOT EXISTS ( + SELECT 1 FROM fl_inbound_order loaded + WHERE loaded.tenant_id=root.tenant_id AND loaded.parent_inbound_id=root.id + AND loaded.inbound_role='CHILD' AND loaded.split_purpose='LOADED' + AND loaded.process_status<>'LOADED' AND loaded.deleted=0) + """) + int aggregateRootLoaded(@Param("tenantId") long tenantId, + @Param("rootInboundId") long rootInboundId, + @Param("operatorId") long operatorId); + record Snapshot(Long id, Long tenantId, String processStatus, Integer version) {} + record CompletionTarget(Long sourceInboundId, Long loadedChildInboundId, Long rootInboundId, + String sourceStatus, Integer sourceVersion, + String loadedChildStatus, Integer loadedChildVersion) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java index 3c7a8b7de..cae31a46b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/repository/LoadingRepository.java @@ -29,6 +29,19 @@ public class LoadingRepository { return inbounds.completeLoading(tenantId, inboundId, operatorId, expectedVersion); } + public LoadingInboundMapper.CompletionTarget completionTarget(long tenantId, long inboundId) { + return inbounds.completionTarget(tenantId, inboundId); + } + + public int completeLoadedChild(long tenantId, long sourceInboundId, long operatorId, + int expectedSourceVersion) { + return inbounds.completeLoadedChild(tenantId, sourceInboundId, operatorId, expectedSourceVersion); + } + + public int aggregateRootLoaded(long tenantId, long rootInboundId, long operatorId) { + return inbounds.aggregateRootLoaded(tenantId, rootInboundId, operatorId); + } + public List listEvidence(long tenantId, long inboundId) { return evidences.selectList(new LambdaQueryWrapper() .eq(LoadingEvidenceEntity::getTenantId, tenantId) 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 7c07918fe..332abcd6b 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 @@ -14,6 +14,7 @@ import com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity; import com.baiyue.firstleg.loading.domain.PickingStatus; import com.baiyue.firstleg.loading.dto.LoadingResponses.PickingResponse; 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.loading.repository.LoadingRepository; import com.baiyue.firstleg.repository.LoadingPickRepository; @@ -98,6 +99,10 @@ public class LoadingDeliveryService { LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, COMPLETE_LOADING, key, hash); if (replay != null) return replay; Snapshot inbound = requireInbound(tenantId, inboundId); + CompletionTarget target = repository.completionTarget(tenantId, inboundId); + if (target != null) { + return completeFulfillmentSlice(tenantId, inboundId, operatorId, operatorName, key, request, target); + } if ("LOADED".equals(inbound.processStatus()) || "DELIVERED".equals(inbound.processStatus())) { loadingModelBridge.requireCompleted(tenantId, inboundId); return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key, @@ -114,6 +119,30 @@ public class LoadingDeliveryService { new LoadingActionResponse("LOADED", evidenceResponse(tenantId, inboundId))); } + private LoadingActionResponse completeFulfillmentSlice(long tenantId, long evidenceInboundId, + long operatorId, String operatorName, String key, + CompleteLoadingRequest request, + CompletionTarget target) { + if ("LOADED".equals(target.loadedChildStatus())) { + return complete(tenantId, evidenceInboundId, operatorId, COMPLETE_LOADING, key, + new LoadingActionResponse("LOADED", evidenceResponse(tenantId, evidenceInboundId))); + } + if (!"LOADING".equals(target.loadedChildStatus())) { + throw new BizException(ResultCode.CONFLICT, "实装履约分片不在装柜中"); + } + loadingModelBridge.requireCompleted(tenantId, target.sourceInboundId()); + completionGate.requireBookingTransportAndCustoms(tenantId, target.sourceInboundId()); + requireCompletedPicking(tenantId, target.sourceInboundId()); + requireCompletionEvidence(tenantId, target.sourceInboundId()); + if (repository.completeLoadedChild(tenantId, target.sourceInboundId(), operatorId, + request.expectedVersion()) != 1) throw concurrent(); + repository.aggregateRootLoaded(tenantId, target.rootInboundId(), operatorId); + addHistory(tenantId, target.loadedChildInboundId(), "LOADING", "LOADED", COMPLETE_LOADING, + request.remark(), operatorId, operatorName); + return complete(tenantId, evidenceInboundId, operatorId, COMPLETE_LOADING, key, + new LoadingActionResponse("LOADED", evidenceResponse(tenantId, evidenceInboundId))); + } + public EvidenceListResponse evidence(long tenantId, long inboundId) { requireLoadingStage(tenantId, inboundId); return evidenceResponse(tenantId, inboundId); @@ -274,6 +303,8 @@ public class LoadingDeliveryService { private Snapshot requireLoadingStage(long tenantId, long inboundId) { Snapshot value = requireInbound(tenantId, inboundId); + CompletionTarget target = repository.completionTarget(tenantId, inboundId); + if (target != null && Set.of("LOADING", "LOADED").contains(target.loadedChildStatus())) return value; if (!("LOADING".equals(value.processStatus()) || "LOADED".equals(value.processStatus()) || "DELIVERED".equals(value.processStatus()))) { throw new BizException(ResultCode.CONFLICT, "订单尚未进入装柜阶段"); @@ -285,6 +316,13 @@ public class LoadingDeliveryService { * corrections are handled by the offline/manual process defined for phase three. */ private Snapshot requireMutableLoadingStage(long tenantId, long inboundId) { Snapshot value = requireInbound(tenantId, inboundId); + CompletionTarget target = repository.completionTarget(tenantId, inboundId); + if (target != null) { + if (!"LOADING".equals(target.loadedChildStatus())) { + throw new BizException(ResultCode.CONFLICT, "装柜完成后不允许在线修改证据"); + } + return value; + } if (!"LOADING".equals(value.processStatus())) { throw new BizException(ResultCode.CONFLICT, "装柜完成后不允许在线修改证据"); } 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 2b78be244..bfa1f9119 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java @@ -44,7 +44,8 @@ public interface InboundSplitSettlementMapper { actual_net_weight_kg,measure_completed_at,inbounded_at,express_company_code,express_company_name, express_tracking_no,public_remark,created_by,created_by_name,created_at,updated_by,updated_at,deleted,version, inbound_role,parent_inbound_id,root_inbound_id,split_sequence,split_status,split_purpose) - SELECT #{childId},tenant_id,task_id,order_id,order_no,#{childInboundCode},'INBOUNDED',order_status_snapshot, + SELECT #{childId},tenant_id,task_id,order_id,order_no,#{childInboundCode}, + CASE WHEN #{splitPurpose}='LOADED' THEN 'LOADING' ELSE 'INBOUNDED' END,order_status_snapshot, 'APPROVED',fee_status,fee_review_status,notification_status,expected_arrival_at,customer_id,customer_name, company_id,company_name,warehouse_id,warehouse_name,delivery_warehouse_id,delivery_warehouse_name,business_type, channel_code,channel_name,carrier_id,carrier_name,tax_mode,pricing_tax_mode,container_mode,customs_inspection, 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 34e9b662b..9fd9ea4ac 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java @@ -153,7 +153,7 @@ public class UnifiedInboundQueryService { : "CONFIRMED".equals(preloadStatus) ? null : preloadStatus == null ? "SMART_SPLIT" : "CONFIRM_PRELOAD"; case "LOADING" -> "COMPLETE_LOADING"; - case "LOADED" -> "COMPLETE_DELIVERY"; + case "LOADED" -> null; case "DELIVERED" -> null; default -> null; }; 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 cad7b8542..e0a06a23b 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 @@ -160,13 +160,13 @@ CREATE TABLE IF NOT EXISTS fl_inbound_split_item ( child_inbound_id BIGINT UNSIGNED NOT NULL COMMENT '子入仓单ID', line_no INT UNSIGNED NOT NULL COMMENT '同一拆分批次内的行号', source_inbound_detail_id BIGINT UNSIGNED NULL COMMENT '订单货物明细逻辑ID', - source_measurement_id BIGINT UNSIGNED NULL COMMENT '来源测量批次ID', - source_measurement_item_id BIGINT UNSIGNED NULL COMMENT '来源测量箱规明细ID', + source_measurement_id BIGINT UNSIGNED NOT NULL COMMENT '来源测量批次ID', + source_measurement_item_id BIGINT UNSIGNED NOT NULL COMMENT '来源测量箱规明细ID', package_count INT UNSIGNED NOT NULL COMMENT '分配给子单的箱数', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), PRIMARY KEY (id), UNIQUE KEY uk_inbound_split_batch_line (tenant_id, split_batch_id, line_no), - UNIQUE KEY uk_inbound_split_batch_source_item (tenant_id, split_batch_id, source_measurement_item_id), + UNIQUE KEY uk_inbound_split_batch_source_child (tenant_id, split_batch_id, source_measurement_item_id, child_inbound_id), KEY idx_inbound_split_detail (tenant_id, source_inbound_detail_id, created_at), CONSTRAINT chk_inbound_split_item_package CHECK (package_count > 0), CONSTRAINT fk_inbound_split_item_batch FOREIGN KEY (split_batch_id) REFERENCES fl_inbound_split_batch (id), @@ -232,6 +232,9 @@ CALL phase3_inbound_add_column( CALL phase3_inbound_add_column( 'fl_inbound_split_item', 'line_no', "INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '同一拆分批次内的行号' AFTER child_inbound_id"); +ALTER TABLE fl_inbound_split_item + MODIFY source_measurement_id BIGINT UNSIGNED NOT NULL COMMENT '来源测量批次ID', + MODIFY source_measurement_item_id BIGINT UNSIGNED NOT NULL COMMENT '来源测量箱规明细ID'; CALL phase3_inbound_add_column( 'fl_inbound_split_batch', 'loaded_child_inbound_id', "BIGINT UNSIGNED NULL COMMENT '本批次实装子单ID' AFTER package_count"); @@ -339,6 +342,9 @@ CALL phase3_inbound_add_index( CALL phase3_inbound_add_index( 'fl_inbound_split_batch', 'idx_inbound_split_loading_plan', 'KEY `idx_inbound_split_loading_plan` (`tenant_id`, `loading_plan_id`, `container_booking_id`, `status`, `id`)'); +CALL phase3_inbound_add_index( + 'fl_inbound_split_batch', 'uk_inbound_split_source_once', + 'UNIQUE KEY `uk_inbound_split_source_once` (`tenant_id`, `source_inbound_id`)'); DROP PROCEDURE IF EXISTS phase3_inbound_drop_split_legacy_index; DELIMITER $$ @@ -395,6 +401,9 @@ BEGIN IF NEW.inbound_role = 'MASTER' AND NEW.parent_inbound_id IS NOT NULL THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主入仓单不能绑定父单'; END IF; + IF NEW.inbound_role = 'MASTER' AND NEW.split_purpose <> 'MASTER' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主入仓单用途必须为MASTER'; + END IF; IF NEW.inbound_role = 'CHILD' THEN IF NEW.parent_inbound_id IS NULL OR NEW.root_inbound_id IS NULL OR NEW.split_sequence IS NULL OR NEW.package_count IS NULL THEN @@ -419,7 +428,8 @@ CREATE TRIGGER trg_inbound_order_parent_before_update BEFORE UPDATE ON fl_inbound_order FOR EACH ROW BEGIN - IF NOT (OLD.inbound_role <=> NEW.inbound_role) + IF NOT (OLD.tenant_id <=> NEW.tenant_id) + OR NOT (OLD.inbound_role <=> NEW.inbound_role) OR NOT (OLD.parent_inbound_id <=> NEW.parent_inbound_id) OR NOT (OLD.root_inbound_id <=> NEW.root_inbound_id) OR NOT (OLD.split_sequence <=> NEW.split_sequence) @@ -436,8 +446,9 @@ BEGIN IF NEW.split_status NOT IN ('NOT_SPLIT', 'PARTIALLY_SPLIT', 'FULLY_SPLIT') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单拆分状态非法'; END IF; - IF NEW.inbound_role = 'CHILD' AND NEW.split_purpose = 'MASTER' THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单用途非法'; + IF (NEW.inbound_role = 'CHILD' AND NEW.split_purpose = 'MASTER') + OR (NEW.inbound_role = 'MASTER' AND NEW.split_purpose <> 'MASTER') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '入仓单层级与用途不一致'; END IF; IF NEW.inbound_role = 'MASTER' AND ((NEW.package_count = 0 AND NEW.split_status <> 'FULLY_SPLIT') @@ -450,6 +461,9 @@ CREATE TRIGGER trg_inbound_split_item_before_insert BEFORE INSERT ON fl_inbound_split_item FOR EACH ROW BEGIN + IF NEW.source_measurement_id IS NULL OR NEW.source_measurement_item_id IS NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分明细必须绑定真实测量来源'; + END IF; IF NOT EXISTS ( SELECT 1 FROM fl_inbound_split_batch batch @@ -467,7 +481,7 @@ BEGIN AND child_order.parent_inbound_id = NEW.parent_inbound_id AND child_order.package_count >= NEW.package_count AND child_order.deleted = 0 - AND (NEW.source_measurement_item_id IS NULL OR EXISTS ( + AND EXISTS ( SELECT 1 FROM fl_inbound_measurement_item source_item JOIN fl_inbound_measurement source_batch ON source_batch.id = source_item.measurement_id @@ -475,7 +489,7 @@ BEGIN AND source_item.tenant_id = NEW.tenant_id AND source_item.measurement_id = NEW.source_measurement_id AND source_batch.inbound_id = COALESCE(batch.source_inbound_id, batch.parent_inbound_id) - )) + ) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分明细与批次、主单或子单不一致'; END IF; @@ -497,23 +511,32 @@ BEGIN ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次主单不存在或客户归属不一致'; END IF; - IF NEW.source_inbound_id IS NOT NULL AND NOT EXISTS ( + IF NEW.source_inbound_id IS NULL OR NOT EXISTS ( SELECT 1 FROM fl_inbound_order source_order WHERE source_order.id = NEW.source_inbound_id AND source_order.tenant_id = NEW.tenant_id AND source_order.deleted = 0 - AND (source_order.id = NEW.parent_inbound_id OR + AND source_order.package_count = NEW.package_count + AND ((source_order.id = NEW.parent_inbound_id + AND source_order.inbound_role = 'MASTER' + AND source_order.split_status = 'NOT_SPLIT') OR (source_order.inbound_role = 'CHILD' AND source_order.parent_inbound_id = NEW.parent_inbound_id - AND source_order.split_purpose = 'REMAINDER')) + AND source_order.split_purpose = 'REMAINDER' + AND source_order.split_status = 'NOT_SPLIT')) ) THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次来源必须是根主单或其余量子单'; + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次来源必须是箱数匹配且未消费的根主单或余量子单'; END IF; IF NEW.loaded_package_count > 0 AND ( NEW.loading_plan_id IS NULL OR NEW.container_booking_id IS NULL OR NOT EXISTS ( SELECT 1 FROM fl_loading_plan plan WHERE plan.id = NEW.loading_plan_id AND plan.container_booking_id = NEW.container_booking_id + AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item + WHERE plan_item.loading_plan_id = plan.id + AND plan_item.account_id = NEW.tenant_id + AND plan_item.inbound_id = NEW.source_inbound_id + AND plan_item.deleted = 0) ) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '实装拆分批次必须绑定匹配的装柜计划和柜次'; @@ -544,13 +567,23 @@ BEGIN NEW.loading_plan_id IS NULL OR NEW.container_booking_id IS NULL OR NOT EXISTS ( SELECT 1 FROM fl_loading_plan plan WHERE plan.id = NEW.loading_plan_id - AND plan.container_booking_id = NEW.container_booking_id))) + AND plan.container_booking_id = NEW.container_booking_id + AND EXISTS (SELECT 1 FROM fl_loading_plan_item plan_item + WHERE plan_item.loading_plan_id = plan.id + AND plan_item.account_id = NEW.tenant_id + AND plan_item.inbound_id = NEW.source_inbound_id + AND plan_item.deleted = 0))) OR NOT EXISTS ( SELECT 1 FROM fl_inbound_order parent_order WHERE parent_order.id = NEW.parent_inbound_id AND parent_order.tenant_id = NEW.tenant_id AND parent_order.inbound_role = 'MASTER' AND parent_order.version > NEW.expected_parent_version + ) OR NOT EXISTS ( + SELECT 1 FROM fl_inbound_order source_order + WHERE source_order.id = NEW.source_inbound_id + AND source_order.tenant_id = NEW.tenant_id + AND source_order.split_status = 'FULLY_SPLIT' ) OR (SELECT COALESCE(SUM(item.package_count), 0) FROM fl_inbound_split_item item diff --git a/firstleg/src/main/resources/db/phase3-split-preload-schema.sql b/firstleg/src/main/resources/db/phase3-split-preload-schema.sql index 057c67447..f0a099c77 100644 --- a/firstleg/src/main/resources/db/phase3-split-preload-schema.sql +++ b/firstleg/src/main/resources/db/phase3-split-preload-schema.sql @@ -126,7 +126,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_split_detail ( source_package_count > 1 AND locked_package_count > 0 AND locked_package_count < source_package_count ), - CONSTRAINT chk_inbound_split_status CHECK (status IN ('LOCKED', 'COMPLETED')), + CONSTRAINT chk_legacy_inbound_split_status CHECK (status IN ('LOCKED', 'COMPLETED')), CONSTRAINT chk_inbound_split_completion CHECK ( (status = 'LOCKED' AND child_inbound_id IS NULL AND completed_at IS NULL) OR (status = 'COMPLETED' AND child_inbound_id IS NOT NULL AND completed_at IS NOT NULL) @@ -143,39 +143,14 @@ CREATE TRIGGER trg_inbound_split_detail_before_insert BEFORE INSERT ON fl_inbound_split_detail FOR EACH ROW BEGIN - DECLARE v_parent_tenant BIGINT UNSIGNED; - DECLARE v_parent_parent BIGINT UNSIGNED; - SELECT tenant_id, parent_inbound_id INTO v_parent_tenant, v_parent_parent - FROM fl_inbound_order WHERE id = NEW.parent_inbound_id AND deleted = 0; - IF v_parent_tenant <> NEW.tenant_id OR v_parent_parent IS NOT NULL THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分锁定只能发生在客户所属主入库单'; - END IF; - IF NEW.status <> 'LOCKED' OR NEW.child_inbound_id IS NOT NULL THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分锁定创建时不能提前绑定二级子入库单'; - END IF; + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '旧部分锁定台账已退役,请使用主子分片批次'; END$$ CREATE TRIGGER trg_inbound_split_detail_before_update BEFORE UPDATE ON fl_inbound_split_detail FOR EACH ROW BEGIN - DECLARE v_child_tenant BIGINT UNSIGNED; - DECLARE v_child_parent BIGINT UNSIGNED; - IF NEW.tenant_id <> OLD.tenant_id OR NEW.parent_inbound_id <> OLD.parent_inbound_id - OR NEW.inbound_detail_id <> OLD.inbound_detail_id - OR NEW.source_package_count <> OLD.source_package_count - OR NEW.locked_package_count <> OLD.locked_package_count - OR NEW.operation_id <> OLD.operation_id THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分锁定的归属和箱数创建后不可修改'; - END IF; - IF OLD.status <> 'LOCKED' OR NEW.status <> 'COMPLETED' OR NEW.child_inbound_id IS NULL THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '部分锁定只允许完成并绑定二级子入库单'; - END IF; - SELECT tenant_id, parent_inbound_id INTO v_child_tenant, v_child_parent - FROM fl_inbound_order WHERE id = NEW.child_inbound_id AND deleted = 0; - IF v_child_tenant <> NEW.tenant_id OR v_child_parent <> NEW.parent_inbound_id THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '剩余箱数必须绑定主单唯一二级子入库单'; - END IF; + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '旧部分锁定台账只读,不允许更新'; END$$ CREATE TRIGGER trg_inbound_split_detail_before_delete 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 32f13b509..d65ba444c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java @@ -52,8 +52,7 @@ class UnifiedInboundQueryServiceTest { assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDED", "CONFIRMED")).isNull(); assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "WAIT_SPLIT", null)) .isEqualTo("SMART_SPLIT"); - assertThat(UnifiedInboundQueryService.nextAction("LOADED", "LOADED", null)) - .isEqualTo("COMPLETE_DELIVERY"); + assertThat(UnifiedInboundQueryService.nextAction("LOADED", "LOADED", null)).isNull(); assertThat(UnifiedInboundQueryService.nextAction("DELIVERED", "DELIVERED", null)).isNull(); } diff --git a/gateway/AGENTS.md b/gateway/AGENTS.md index 5b562f907..3a5ffb8d0 100644 --- a/gateway/AGENTS.md +++ b/gateway/AGENTS.md @@ -16,5 +16,5 @@ ## 修改约束与分期 - 新增路由同步检查本地/Nacos、App Scope、CORS、公开路径和 Swagger;不得暴露内部令牌接口。 -- 一期承载登录后 Admin/Client 入口;二期接入到仓/测量/计费公开路由但保持内部测量接口隔离;三期当前 NO-GO,不开放分柜、订舱、PDA、装柜、报关或港务路由。 -- 三期先读 `../../docs/三期/三期需求确认表.md`,解除阻断并冻结契约后再改网关。 +- 一期承载登录后 Admin/Client 入口;二期接入到仓/测量/计费公开路由但保持内部测量接口隔离;三期仅在 Controller、权限和公开访问域契约稳定后新增必要路由,内部 PDA、仓储和服务令牌接口继续不得经 Gateway 暴露。 +- 三期先读 `../../docs/三期/三期开工门禁.md`;网关变更只服务已确认模块,不以路由提前暴露未完成能力。 diff --git a/iam/AGENTS.md b/iam/AGENTS.md index b6f52d8bf..b768c95df 100644 --- a/iam/AGENTS.md +++ b/iam/AGENTS.md @@ -30,4 +30,4 @@ - 一期:客户建档审核、平台用户与 RBAC、企业资料;先读 `../../docs/一期/README.md`。 - 二期:仅提供客户资格/授权快照,不承载入库或计费状态;先读 `../../docs/二期/README.md`。 -- 三期:NO-GO,不提前增加出仓角色/PDA 权限;先读 `../../docs/三期/三期需求确认表.md`。 +- 三期:按最小权限为操作员、拣货员、理货员落地已确认的角色与权限;先读 `../../docs/三期/三期开工门禁.md`。具体权限码与授权快照须先随服务端契约冻结,不自行扩展租户层级或客户可见范围。 diff --git a/integration-service/AGENTS.md b/integration-service/AGENTS.md index 10b16785e..fb89e12d8 100644 --- a/integration-service/AGENTS.md +++ b/integration-service/AGENTS.md @@ -18,4 +18,4 @@ - 密钥、原始响应和客户敏感数据不得写入日志;真实调用默认关闭,适配器负责超时、重试、限流、错误归一化。 - OCR 只能访问受控输入/输出目录,接口仅接受内部服务令牌;契约变更同步检查 File 的 `WeightOcrClient` 和测试。 -- 一期仅保留通用适配基础;二期服务 File 重量 OCR,凭证缺失时走人工修正降级;三期当前 NO-GO,不提前接入报关、船期或港务接口。阶段文档:`../../docs/二期/README.md`、`../../docs/三期/三期需求确认表.md`。 +- 一期仅保留通用适配基础;二期服务 File 重量 OCR,凭证缺失时走人工修正降级;三期仅在船期来源、频率、限流、超时、最近有效结果和过期标识的契约稳定后接入外部查询,不建设报关自动对接。阶段文档:`../../docs/二期/README.md`、`../../docs/三期/三期开工门禁.md`、`../../docs/三期/三期需求确认表.md`。 diff --git a/order/AGENTS.md b/order/AGENTS.md index 8a90790cb..927ae3edb 100644 --- a/order/AGENTS.md +++ b/order/AGENTS.md @@ -27,4 +27,4 @@ - 一期:订单下单与交易状态闭环;先读 `../../docs/一期/README.md` 与闭环设计。 - 二期:仅提供货物内部查询,不承载入库状态;先读 `../../docs/二期/README.md`。 -- 三期:NO-GO;待确认后再设计分柜/出仓状态扩展。 +- 三期:在 `firstleg` 编排契约稳定后,负责父子订单、数量守恒、订单主状态、幂等和时间线所需的主数据扩展;先读 `../../docs/三期/三期开工门禁.md`。不得自行创建独立出仓单或绕过 `LOADED` 服务端门禁。 diff --git a/order/src/main/java/com/baiyue/order/controller/InternalOrderFulfillmentSplitController.java b/order/src/main/java/com/baiyue/order/controller/InternalOrderFulfillmentSplitController.java new file mode 100644 index 000000000..856802604 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/controller/InternalOrderFulfillmentSplitController.java @@ -0,0 +1,40 @@ +package com.baiyue.order.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.order.dto.OrderSplitDtos; +import com.baiyue.order.service.OrderFulfillmentSplitService; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +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; + +/** Firstleg 装柜结算调用的订单域内部契约。 */ +@RestController +@RequestMapping("/internal/order/v1/fulfillment-splits") +public class InternalOrderFulfillmentSplitController { + @Resource private OrderFulfillmentSplitService service; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + private byte[] expectedToken; + @PostConstruct void initializeToken(){expectedToken=internalToken.getBytes(StandardCharsets.UTF_8);} + + @PostMapping + public ApiResponse create( + @RequestHeader(value="X-Internal-Service-Token",required=false) String token, + @RequestHeader(value="X-Operator-Id",defaultValue="0") long operatorId, + @Valid @RequestBody OrderSplitDtos.CreateRequest request) { + requireToken(token); return ApiResponse.success(service.split(request,operatorId)); + } + + @GetMapping("/{tenantId}/{parentOrderId}") + public ApiResponse tree( + @RequestHeader(value="X-Internal-Service-Token",required=false) String token, + @PathVariable long tenantId,@PathVariable long parentOrderId){ + requireToken(token); return ApiResponse.success(service.tree(tenantId,parentOrderId)); + } + private void requireToken(String token){if(token==null||!MessageDigest.isEqual(expectedToken,token.getBytes(StandardCharsets.UTF_8)))throw new BizException(ResultCode.FORBIDDEN,"内部服务凭证无效");} +} 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 6ec107ab9..7ab64935e 100644 --- a/order/src/main/java/com/baiyue/order/controller/MerchantUnifiedOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/MerchantUnifiedOrderController.java @@ -7,6 +7,8 @@ import com.baiyue.order.dto.UnifiedOrderQuery; import com.baiyue.order.dto.UnifiedOrderResponse; import com.baiyue.order.service.OrderScopeGuard; import com.baiyue.order.service.UnifiedOrderQueryService; +import com.baiyue.order.service.OrderFulfillmentSplitService; +import com.baiyue.order.dto.OrderSplitDtos; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; @@ -24,6 +26,7 @@ import org.springframework.web.bind.annotation.RestController; @RequestMapping("/api/merchant/v1/orders") public class MerchantUnifiedOrderController { @Resource private UnifiedOrderQueryService service; + @Resource private OrderFulfillmentSplitService splitService; @GetMapping @PreAuthorize("hasAuthority('merchant:order:list')") @@ -42,4 +45,10 @@ public class MerchantUnifiedOrderController { @PathVariable long orderId) { return ApiResponse.success(service.detail(OrderScopeGuard.merchant(user), orderId, false)); } + + @GetMapping("/{orderId}/fulfillment-tree") + @PreAuthorize("hasAuthority('merchant:order:view')") + @Operation(summary = "我的父子订单与柜次数量摘要", description = "不返回装柜照片、锁码或内部审计字段。") + public ApiResponse fulfillmentTree(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long orderId){return ApiResponse.success(splitService.tree(OrderScopeGuard.merchant(user),orderId));} } diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java index f060d74cc..4f2c28850 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java @@ -10,6 +10,8 @@ import com.baiyue.order.dto.OrderRequests; import com.baiyue.order.service.OrderApplicationService; import com.baiyue.order.service.OrderScopeGuard; import com.baiyue.order.service.UnifiedOrderQueryService; +import com.baiyue.order.service.OrderFulfillmentSplitService; +import com.baiyue.order.dto.OrderSplitDtos; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; @@ -31,6 +33,7 @@ import org.springframework.web.bind.annotation.RestController; public class PlatformUnifiedOrderController { @Resource private UnifiedOrderQueryService service; @Resource private OrderApplicationService orderService; + @Resource private OrderFulfillmentSplitService splitService; @GetMapping @PreAuthorize("hasAuthority('platform:order:list')") @@ -54,6 +57,14 @@ public class PlatformUnifiedOrderController { return ApiResponse.success(service.detail(OrderScopeGuard.platform(user, tenantId), orderId, true)); } + @GetMapping("/{orderId}/fulfillment-tree") + @PreAuthorize("hasAuthority('platform:order:list')") + @Operation(summary = "父子订单与柜次数量摘要") + public ApiResponse fulfillmentTree(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam long tenantId,@PathVariable long orderId){ + return ApiResponse.success(splitService.tree(OrderScopeGuard.platform(user,tenantId),orderId)); + } + @PostMapping("/{orderId}/internal-notes") @BusinessAudit(event = "ORDER_INTERNAL_NOTE", resourceType = "FIRST_LEG_ORDER", resourceId = "#orderId", description = "Add internal note / 添加内部备注") @PreAuthorize("hasAuthority('platform:order:note')") diff --git a/order/src/main/java/com/baiyue/order/mapper/OrderFulfillmentSplitMapper.java b/order/src/main/java/com/baiyue/order/mapper/OrderFulfillmentSplitMapper.java index 4a0100317..1545a0407 100644 --- a/order/src/main/java/com/baiyue/order/mapper/OrderFulfillmentSplitMapper.java +++ b/order/src/main/java/com/baiyue/order/mapper/OrderFulfillmentSplitMapper.java @@ -8,12 +8,16 @@ import org.apache.ibatis.annotations.Select; import com.baiyue.order.entity.OrderEntity; import java.util.List; +import org.apache.ibatis.annotations.Update; @Mapper public interface OrderFulfillmentSplitMapper extends BaseMapper { @Select("SELECT * FROM ord_order WHERE tenant_id=#{tenantId} AND id=#{orderId} AND deleted=0 FOR UPDATE") OrderEntity lockOrder(@Param("tenantId") long tenantId, @Param("orderId") long orderId); + @Select("SELECT * FROM ord_order WHERE tenant_id=#{tenantId} AND id=#{orderId} AND deleted=0") + OrderEntity findOrder(@Param("tenantId") long tenantId, @Param("orderId") long orderId); + @Select("SELECT * FROM ord_order_fulfillment_split WHERE tenant_id=#{tenantId} AND parent_order_id=#{parentOrderId} AND idempotency_key=#{key} ORDER BY id") List findByIdempotency(@Param("tenantId") long tenantId, @Param("parentOrderId") long parentOrderId, @@ -22,4 +26,7 @@ public interface OrderFulfillmentSplitMapper extends BaseMapper findByParent(@Param("tenantId") long tenantId, @Param("parentOrderId") long parentOrderId); + + @Select("SELECT COALESCE(MAX(split_sequence),0) FROM ord_order WHERE tenant_id=#{tenantId} AND (id=#{parentOrderId} OR parent_order_id=#{parentOrderId}) AND deleted=0") + Integer maxSplitSequence(@Param("tenantId") long tenantId, @Param("parentOrderId") long parentOrderId); } diff --git a/order/src/main/java/com/baiyue/order/service/OrderFulfillmentSplitService.java b/order/src/main/java/com/baiyue/order/service/OrderFulfillmentSplitService.java new file mode 100644 index 000000000..ffad92d3d --- /dev/null +++ b/order/src/main/java/com/baiyue/order/service/OrderFulfillmentSplitService.java @@ -0,0 +1,83 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.dto.OrderSplitDtos; +import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.mapper.OrderFulfillmentSplitMapper; +import com.baiyue.order.mapper.OrderMapper; +import com.baiyue.order.entity.OrderFulfillmentSplitEntity; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.util.*; + +/** 订单域父子单/柜次数量契约。仅生成一层 CHILD,不复制原订单身份。 */ +@Service +public class OrderFulfillmentSplitService { + @Resource private OrderFulfillmentSplitMapper splits; + @Resource private OrderMapper orders; + @Resource private ObjectMapper json; + + @Transactional + public OrderSplitDtos.Response split(OrderSplitDtos.CreateRequest req, long operatorId) { + validate(req); + String hash = hash(req); + List replay = splits.findByIdempotency(req.tenantId(), req.parentOrderId(), req.idempotencyKey()); + if (!replay.isEmpty()) { + if (replay.stream().anyMatch(x -> x.getRequestHash()!=null && !hash.equals(x.getRequestHash()))) + throw new BizException(ResultCode.CONFLICT, "幂等键对应的请求内容不一致"); + return response(req, splits.lockOrder(req.tenantId(), req.parentOrderId()), replay); + } + OrderEntity parent = splits.lockOrder(req.tenantId(), req.parentOrderId()); + if (parent == null) throw new BizException(ResultCode.NOT_FOUND, "父订单不存在或不属于该客户"); + if (!req.parentOrderId().equals(req.sourceOrderId()) && !isDirectChild(req.sourceOrderId(), parent)) + throw new BizException(ResultCode.CONFLICT, "来源订单必须是父订单或其直接子订单"); + if (!"MASTER".equals(parent.getOrderRole())) throw new BizException(ResultCode.CONFLICT, "子订单不能继续拆分"); + if (parent.getVersion()!=null && !req.expectedParentVersion().equals(parent.getVersion())) + throw new BizException(ResultCode.CONFLICT, "父订单版本已变化,请刷新后重试"); + int next = Optional.ofNullable(splits.maxSplitSequence(req.tenantId(), req.parentOrderId())).orElse(0) + 1; + List rows = new ArrayList<>(); + OrderSplitDtos.Child loaded = createChild(req, parent, next++, "LOADED", req.loadedPackageCount(), hash, operatorId, rows); + OrderSplitDtos.Child remainder = req.remainingPackageCount() > 0 + ? createChild(req, parent, next, "REMAINDER", req.remainingPackageCount(), hash, operatorId, rows) : null; + for (OrderFulfillmentSplitEntity row : rows) splits.insert(row); + parent.setVersion((parent.getVersion()==null?0:parent.getVersion()) + 1); + LambdaUpdateWrapper guard = new LambdaUpdateWrapper() + .eq(OrderEntity::getId, parent.getId()) + .eq(OrderEntity::getTenantId, parent.getTenantId()) + .eq(OrderEntity::getVersion, req.expectedParentVersion()); + int updated = orders.update(parent, guard); + if (updated != 1) throw new BizException(ResultCode.CONFLICT, "父订单版本已变化,请刷新后重试"); + return new OrderSplitDtos.Response(parent.getId(), parent.getOrderNo(), parent.getVersion(), loaded, remainder, + req.sourcePackageCount(), req.loadedPackageCount(), req.remainingPackageCount(), req.loadingPlanId(), req.containerBookingId(), req.idempotencyKey(), LocalDateTime.now()); + } + + public OrderSplitDtos.Tree tree(long tenantId, long parentId) { + OrderEntity parent = splits.findOrder(tenantId, parentId); + if (parent == null) throw new BizException(ResultCode.NOT_FOUND, "订单不存在"); + if (!"MASTER".equals(parent.getOrderRole())) parentId = Optional.ofNullable(parent.getParentOrderId()).orElse(parentId); + parent = splits.findOrder(tenantId, parentId); + 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(); + 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)); + } + + private boolean isDirectChild(long source, OrderEntity parent) { OrderEntity c=orders.selectOne(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper().eq(OrderEntity::getId,source).eq(OrderEntity::getTenantId,parent.getTenantId())); return c!=null && "CHILD".equals(c.getOrderRole()) && parent.getId().equals(c.getParentOrderId()); } + private OrderSplitDtos.Child createChild(OrderSplitDtos.CreateRequest r, OrderEntity p, int seq, String role, int count, String hash, long op, List rows) { + OrderEntity c = new OrderEntity(); c.setId(IdWorker.getId()); c.setTenantId(p.getTenantId()); c.setOrderNo(p.getOrderNo()+"-"+seq); c.setOrderRole("CHILD"); c.setParentOrderId(p.getId()); c.setRootOrderId(p.getId()); c.setSplitSequence(seq); c.setStatus(p.getStatus()); c.setCustomerId(p.getCustomerId()); c.setCustomerName(p.getCustomerName()); c.setTransportMode(p.getTransportMode()); c.setTradeTerm(p.getTradeTerm()); c.setDestinationCountryCode(p.getDestinationCountryCode()); c.setDestinationWarehouseId(p.getDestinationWarehouseId()); c.setDestinationWarehouseName(p.getDestinationWarehouseName()); c.setReceivingWarehouseId(p.getReceivingWarehouseId()); c.setReceivingWarehouseName(p.getReceivingWarehouseName()); c.setChannelCode(p.getChannelCode()); c.setChannelName(p.getChannelName()); c.setDestinationType(p.getDestinationType()); c.setCreatedBy(op); c.setUpdatedBy(op); orders.insert(c); + OrderFulfillmentSplitEntity row=new OrderFulfillmentSplitEntity(); row.setId(IdWorker.getId()); row.setTenantId(r.tenantId()); row.setParentOrderId(p.getId()); row.setChildOrderId(c.getId()); row.setLoadingPlanId(r.loadingPlanId()); row.setContainerBookingId(r.containerBookingId()); row.setSourcePackageCount(r.sourcePackageCount()); row.setLoadedPackageCount(r.loadedPackageCount()); row.setRemainingPackageCount(r.remainingPackageCount()); row.setOperationId(Optional.ofNullable(r.operationId()).orElse("split-"+r.idempotencyKey())); row.setIdempotencyKey(r.idempotencyKey()); row.setRequestHash(hash); row.setSplitRole(role); row.setExpectedParentVersion(r.expectedParentVersion()); row.setCreatedBy(op); row.setCreatedByName(r.operatorName()); row.setCreatedAt(LocalDateTime.now()); rows.add(row); return new OrderSplitDtos.Child(c.getId(), c.getOrderNo(), seq, role, count); + } + private void validate(OrderSplitDtos.CreateRequest r) { if(r==null||r.tenantId()==null||r.parentOrderId()==null||r.sourceOrderId()==null||r.loadingPlanId()==null||r.containerBookingId()==null||r.sourcePackageCount()==null||r.loadedPackageCount()==null||r.remainingPackageCount()==null||r.expectedParentVersion()==null||r.idempotencyKey()==null||r.idempotencyKey().isBlank()) throw new BizException(ResultCode.BAD_REQUEST,"拆分请求字段不完整"); if(r.loadedPackageCount()<1||r.remainingPackageCount()<0||r.loadedPackageCount()+r.remainingPackageCount()!=r.sourcePackageCount()) throw new BizException(ResultCode.VALIDATION_FAILED,"装柜数量与剩余数量必须守恒"); } + private String hash(Object o) { try { byte[] b=json.writeValueAsString(o).getBytes(StandardCharsets.UTF_8); byte[] d=MessageDigest.getInstance("SHA-256").digest(b); StringBuilder s=new StringBuilder(); for(byte x:d)s.append(String.format("%02x",x)); return s.toString(); } catch(Exception e){throw new BizException("无法生成请求指纹");} } + private OrderSplitDtos.Response response(OrderSplitDtos.CreateRequest r, OrderEntity p, List rows) { OrderSplitDtos.Child l=null,m=null; for(var x:rows){OrderEntity c=orders.selectById(x.getChildOrderId()); var z=new OrderSplitDtos.Child(x.getChildOrderId(),c==null?null:c.getOrderNo(),c==null?null:c.getSplitSequence(),x.getSplitRole(),"LOADED".equals(x.getSplitRole())?x.getLoadedPackageCount():x.getRemainingPackageCount()); if("LOADED".equals(x.getSplitRole()))l=z;else m=z;} return new OrderSplitDtos.Response(p.getId(),p.getOrderNo(),p.getVersion(),l,m,r.sourcePackageCount(),r.loadedPackageCount(),r.remainingPackageCount(),r.loadingPlanId(),r.containerBookingId(),r.idempotencyKey(),rows.get(0).getCreatedAt()); } +} diff --git a/warehouse/AGENTS.md b/warehouse/AGENTS.md index 5cff538a3..fa7f66896 100644 --- a/warehouse/AGENTS.md +++ b/warehouse/AGENTS.md @@ -37,4 +37,4 @@ - 一期:公共仓与主数据;先读 `../../docs/一期/README.md`。 - 二期:托盘/库位预占占用和海外仓费用;先读 `../../docs/二期/README.md` 与主流程。 -- 三期:NO-GO;待确认后再扩展出仓资源。 +- 三期:仅为 PDA 拣货与资源结算提供实时库存、当前位置、可用量和原子结算能力;先读 `../../docs/三期/三期开工门禁.md`。不扩展完整 WMS,且设备真机事项不改变服务端资源契约。