From e915086133e258bb6975b4e4432902a88b88a93b Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 10 Aug 2026 20:52:46 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E8=A3=85=E6=9F=9C=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sql/2026-08-05-可靠审计Outbox-MySQL8.sql | 61 ------------ .../mapper/ContainerBookingMapper.java | 2 +- .../firstleg/service/SmartLoadingService.java | 37 ++++++-- .../src/main/resources/application-dev.yml | 5 + .../ContainerBookingMapperContractTest.java | 6 +- .../service/SmartLoadingServiceTest.java | 92 +++++++++++++++++++ 6 files changed, 133 insertions(+), 70 deletions(-) delete mode 100644 deploy/sql/2026-08-05-可靠审计Outbox-MySQL8.sql diff --git a/deploy/sql/2026-08-05-可靠审计Outbox-MySQL8.sql b/deploy/sql/2026-08-05-可靠审计Outbox-MySQL8.sql deleted file mode 100644 index d382d4506..000000000 --- a/deploy/sql/2026-08-05-可靠审计Outbox-MySQL8.sql +++ /dev/null @@ -1,61 +0,0 @@ --- 可靠审计 Outbox、消费幂等和死信补偿增量(MySQL 8) -SET NAMES utf8mb4; -USE baiyue_audit; - -DROP PROCEDURE IF EXISTS create_reliable_audit_outbox; -DELIMITER $$ -CREATE PROCEDURE create_reliable_audit_outbox(IN schema_name VARCHAR(64)) -BEGIN - SET @ddl = CONCAT( - 'CREATE TABLE IF NOT EXISTS \`', schema_name, '\`.sys_audit_outbox (', - 'event_id VARCHAR(64) NOT NULL,event_type VARCHAR(64) NOT NULL,resource_type VARCHAR(64) NOT NULL,', - 'resource_id VARCHAR(128) NULL,service_name VARCHAR(64) NULL,payload_json JSON NOT NULL,', - 'status VARCHAR(16) NOT NULL DEFAULT ''PENDING'',attempt_count INT NOT NULL DEFAULT 0,', - 'compensation_count INT NOT NULL DEFAULT 0,next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),', - 'lease_owner VARCHAR(128) NULL,lease_until DATETIME(3) NULL,last_error VARCHAR(1000) NULL,', - 'dead_at DATETIME(3) NULL,sent_at DATETIME(3) NULL,created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),', - 'updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),', - 'PRIMARY KEY(event_id),KEY idx_audit_outbox_publish(status,next_retry_at,lease_until,created_at),', - 'KEY idx_audit_outbox_retention(status,sent_at)) ENGINE=InnoDB COMMENT=''可靠审计事件本地发送箱''' - ); - PREPARE stmt FROM @ddl; - EXECUTE stmt; - DEALLOCATE PREPARE stmt; -END$$ -DELIMITER ; -CALL create_reliable_audit_outbox('baiyue_auth'); -CALL create_reliable_audit_outbox('baiyue_iam'); -CALL create_reliable_audit_outbox('baiyue_order'); -CALL create_reliable_audit_outbox('baiyue_firstleg'); -CALL create_reliable_audit_outbox('baiyue_warehouse'); -DROP PROCEDURE IF EXISTS create_reliable_audit_outbox; - -USE baiyue_audit; -DROP PROCEDURE IF EXISTS add_audit_event_id; -DELIMITER $$ -CREATE PROCEDURE add_audit_event_id() -BEGIN - IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA='baiyue_audit' AND TABLE_NAME='sys_audit_log' AND COLUMN_NAME='event_id') THEN - ALTER TABLE sys_audit_log ADD COLUMN event_id VARCHAR(64) NULL COMMENT '审计事件全局唯一ID' AFTER id; - END IF; - IF NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS - WHERE TABLE_SCHEMA='baiyue_audit' AND TABLE_NAME='sys_audit_log' AND INDEX_NAME='uk_audit_event_id') THEN - ALTER TABLE sys_audit_log ADD UNIQUE KEY uk_audit_event_id(event_id); - END IF; -END$$ -DELIMITER ; -CALL add_audit_event_id(); -DROP PROCEDURE IF EXISTS add_audit_event_id; - -CREATE TABLE IF NOT EXISTS sys_audit_dead_letter ( - id BIGINT NOT NULL AUTO_INCREMENT,event_id VARCHAR(64) NOT NULL,payload_text LONGTEXT NOT NULL, - status VARCHAR(16) NOT NULL DEFAULT 'PENDING',retry_count INT NOT NULL DEFAULT 0, - next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),lease_owner VARCHAR(128) NULL, - lease_until DATETIME(3) NULL,last_error VARCHAR(1000) NULL, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),processed_at DATETIME(3) NULL, - PRIMARY KEY(id),UNIQUE KEY uk_audit_dead_event(event_id), - KEY idx_audit_dead_retry(status,next_retry_at,lease_until) -) ENGINE=InnoDB COMMENT='审计消息死信补偿表'; - -SELECT 'PASS' AS migration_status,'可靠审计Outbox、幂等和死信补偿表已就绪' AS message; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java index ad18775f4..ba8be9441 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java @@ -63,7 +63,7 @@ public interface ContainerBookingMapper extends BaseMapper items = repository.items(planId); + if (items.isEmpty()) throw validation("装柜明细不能为空"); + checkPlanCapacity(booking, items); + if (StringUtils.hasText(booking.getContainerNo())) { + loadingPickService.begin(planId, booking.getContainerNo(), operatorId, operatorName, items); + } try { if (repository.push(planId, key, request.expectedVersion(), operatorId, operatorName) != 1) throw conflict("装柜计划已被并发修改"); } catch (DuplicateKeyException e) { throw conflict("幂等键已用于其他推送"); } @@ -287,13 +291,32 @@ public class SmartLoadingService { BigDecimal v=lines.stream().map(x->unit(x.getVolumeCbm())).reduce(BigDecimal.ZERO,BigDecimal::add).add(unit(c.unitVolumeCbm()).multiply(BigDecimal.valueOf(count))); BigDecimal w=lines.stream().map(x->unit(x.getGrossWeightKg())).reduce(BigDecimal.ZERO,BigDecimal::add).add(unit(c.unitWeightKg()).multiply(BigDecimal.valueOf(count))); if (b.getCapacityCbm()==null || b.getMaxWeightKg()==null) throw validation("货柜容量和最大承重未配置"); - if (v.compareTo(b.getCapacityCbm())>0 || w.compareTo(b.getMaxWeightKg())>0) throw validation("超过货柜容积或重量上限"); + if (v.compareTo(b.getCapacityCbm().multiply(CAPACITY_LIMIT_RATIO))>0 + || w.compareTo(b.getMaxWeightKg().multiply(CAPACITY_LIMIT_RATIO))>0) { + throw validation("超过货柜容积或重量的110%上限"); + } + } + private void checkPlanCapacity(ContainerBookingEntity booking, List items) { + if (booking.getCapacityCbm() == null || booking.getMaxWeightKg() == null) { + throw validation("货柜容量和最大承重未配置"); + } + BigDecimal weight = items.stream().map(x -> unit(x.getGrossWeightKg())) + .reduce(BigDecimal.ZERO, BigDecimal::add); + if (weight.compareTo(booking.getMaxWeightKg().multiply(CAPACITY_LIMIT_RATIO)) > 0) { + throw validation("超过货柜重量的110%上限"); + } + BigDecimal volume = items.stream().map(x -> unit(x.getVolumeCbm())) + .reduce(BigDecimal.ZERO, BigDecimal::add); + if (volume.compareTo(booking.getCapacityCbm().multiply(CAPACITY_LIMIT_RATIO)) > 0) { + throw validation("超过货柜容积的110%上限"); + } } private int fittingBoxes(ContainerBookingEntity b,List lines,Candidate c){ if(b.getCapacityCbm()==null||b.getMaxWeightKg()==null)return 0; BigDecimal usedV=lines.stream().map(x->unit(x.getVolumeCbm())).reduce(BigDecimal.ZERO,BigDecimal::add); BigDecimal usedW=lines.stream().map(x->unit(x.getGrossWeightKg())).reduce(BigDecimal.ZERO,BigDecimal::add); - BigDecimal leftV=b.getCapacityCbm().subtract(usedV),leftW=b.getMaxWeightKg().subtract(usedW); + BigDecimal leftV=b.getCapacityCbm().multiply(CAPACITY_LIMIT_RATIO).subtract(usedV); + BigDecimal leftW=b.getMaxWeightKg().multiply(CAPACITY_LIMIT_RATIO).subtract(usedW); int byV=unit(c.unitVolumeCbm()).signum()==0?c.availablePackageCount():leftV.max(BigDecimal.ZERO).divideToIntegralValue(c.unitVolumeCbm()).intValue(); int byW=unit(c.unitWeightKg()).signum()==0?c.availablePackageCount():leftW.max(BigDecimal.ZERO).divideToIntegralValue(c.unitWeightKg()).intValue(); return Math.max(0,Math.min(c.availablePackageCount(),Math.min(byV,byW))); @@ -301,7 +324,9 @@ public class SmartLoadingService { private Plan view(LoadingPlanEntity p,ContainerBookingEntity b){return new Plan(p.getId(),p.getPlanNo(),p.getPlanStatus(),p.getContainerBookingId(),b.getContainerNo(),b.getVoyageNo(),p.getVersion(),repository.items(p.getId()));} private Plan draftView(LoadingPlanEntity plan, ContainerBookingEntity booking) { if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "该货柜尚未创建智能装柜草稿"); - if (!"EDITING".equals(plan.getPlanStatus())) throw conflict("装柜计划已推送预装,请前往预装清单查看"); + if (!Set.of("EDITING", "LOADING").contains(plan.getPlanStatus())) { + throw conflict("装柜已完成,请前往装柜完成记录查看"); + } return view(plan, booking); } private LoadingPlanEntity editing(long id,int version){LoadingPlanEntity p=repository.plan(id);if(p==null)throw new BizException(ResultCode.NOT_FOUND,"装柜计划不存在");if(!Set.of("EDITING", "LOADING").contains(p.getPlanStatus())||!Objects.equals(p.getVersion(),version))throw conflict("计划已完成或版本冲突");return p;} diff --git a/firstleg/src/main/resources/application-dev.yml b/firstleg/src/main/resources/application-dev.yml index 5e849b5f8..d0360bf48 100644 --- a/firstleg/src/main/resources/application-dev.yml +++ b/firstleg/src/main/resources/application-dev.yml @@ -4,3 +4,8 @@ spring: 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/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java index edf2bbbf9..78f7d5c57 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerBookingMapperContractTest.java @@ -39,8 +39,10 @@ class ContainerBookingMapperContractTest { .reduce("", (left, right) -> left + "\n" + right); assertThat(allSql) .contains("capacity_cbm") - .contains("p.plan_status<>'EDITING'") + .contains("p.plan_status!='EDITING'") .contains("p.plan_status='COMPLETED'") - .contains("version=#{expectedVersion}"); + .contains("version=#{expectedVersion}") + .doesNotContain("<") + .doesNotContain(">"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index 604885ee4..9867f5232 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -36,6 +36,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.catchThrowableOfType; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; @@ -77,6 +78,28 @@ class SmartLoadingServiceTest { assertThat(service.detail(1L).bookingId()).isEqualTo(99L); } + @Test + void restoresLoadingPlanForContinuedAssignment() { + LoadingPlanEntity plan = plan("LOADING", 3); + when(repository.booking(99L)).thenReturn(booking()); + when(repository.planByBooking(99L)).thenReturn(plan); + when(repository.items(1L)).thenReturn(List.of()); + + assertThat(service.draftByBooking(99L).status()).isEqualTo("LOADING"); + } + + @Test + void doesNotRestoreCompletedPlanInTheLoadingWorkbench() { + when(repository.booking(99L)).thenReturn(booking()); + when(repository.planByBooking(99L)).thenReturn(plan("COMPLETED", 4)); + + BizException error = catchThrowableOfType( + () -> service.draftByBooking(99L), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT); + verify(repository, never()).items(anyLong()); + } + @Test void returnsExistingDraftOnCreate() { LoadingPlanEntity plan = plan("EDITING", 2); @@ -105,6 +128,35 @@ class SmartLoadingServiceTest { verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any()); } + @Test + void allowsVolumeAndWeightWithinOneHundredTenPercent() { + stubAssignment(); + ContainerBookingEntity tolerant = booking(); + tolerant.setCapacityCbm(new BigDecimal("0.91")); + tolerant.setMaxWeightKg(new BigDecimal("3.64")); + when(repository.booking(99L)).thenReturn(tolerant); + + service.assign(1, new AssignRequest(10L, 20L, 2, 0, "assign-110-ok"), 7, "u"); + + verify(repository).touch(1, 0, 7, "u"); + } + + @Test + void rejectsVolumeOrWeightAboveOneHundredTenPercent() { + stubAssignment(); + ContainerBookingEntity exceeded = booking(); + exceeded.setCapacityCbm(new BigDecimal("0.90")); + exceeded.setMaxWeightKg(new BigDecimal("3.63")); + when(repository.booking(99L)).thenReturn(exceeded); + + BizException error = catchThrowableOfType(() -> service.assign(1, + new AssignRequest(10L, 20L, 2, 0, "assign-110-exceeded"), 7, "u"), BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED); + assertThat(error.getMessage()).contains("110%"); + verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any()); + } + @Test void snapshotsCargoFields() { stubAssignment(); @@ -296,6 +348,46 @@ class SmartLoadingServiceTest { verify(repository).push(1, "key", 3, 7, "u"); } + @Test + void rejectsOverweightPlanBeforePushEvenWithoutContainerNumber() { + LoadingPlanEntity editing = plan("EDITING", 1); + ContainerBookingEntity booking = booking(); + booking.setContainerNo(null); + booking.setMaxWeightKg(new BigDecimal("1")); + LoadingPlanItemEntity overweight = item(8, 9, 20, 1); + overweight.setGrossWeightKg(new BigDecimal("2")); + when(repository.plan(1)).thenReturn(editing); + when(repository.booking(99)).thenReturn(booking); + when(repository.items(1)).thenReturn(List.of(overweight)); + + BizException error = catchThrowableOfType( + () -> service.pushPreload(1, "weight-key", new PushRequest(1), 7, "u"), + BizException.class); + + assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED); + assertThat(error.getMessage()).contains("重量").contains("110%"); + verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString()); + verify(loadingPickService, never()).begin(anyLong(), any(), anyLong(), anyString(), anyList()); + } + + @Test + void pushesWithoutContainerNumberWhenCapacityIsWithinLimit() { + LoadingPlanEntity editing = plan("EDITING", 1); + LoadingPlanEntity pushed = plan("LOADING", 2); + ContainerBookingEntity booking = booking(); + booking.setContainerNo(null); + when(repository.plan(1)).thenReturn(editing, pushed); + when(repository.booking(99)).thenReturn(booking); + when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1))); + when(repository.push(1, "no-container-key", 1, 7, "u")).thenReturn(1); + + Plan result = service.pushPreload(1, "no-container-key", new PushRequest(1), 7, "u"); + + assertThat(result.status()).isEqualTo("LOADING"); + verify(repository).push(1, "no-container-key", 1, 7, "u"); + verify(loadingPickService, never()).begin(anyLong(), any(), anyLong(), anyString(), anyList()); + } + private void stubAssignment() { LoadingPlanEntity plan = plan("EDITING", 0); when(repository.plan(1)).thenReturn(plan, plan("EDITING", 1)); From fe60a20d9564728032cce5a7d69c5a38dba53242 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 11 Aug 2026 09:17:02 +0800 Subject: [PATCH 2/2] feat(firstleg): enforce single remainder split schema --- deploy/sql/头程数据库统一增量-MySQL8.sql | 130 +++++++++++++----- .../db/phase3-inbound-parent-child-schema.sql | 130 +++++++++++++----- .../InboundParentChildSqlContractTest.java | 19 ++- .../Phase3FinalUnifiedSqlContractTest.java | 16 +++ .../service/SplitPreloadSqlContractTest.java | 5 +- 5 files changed, 232 insertions(+), 68 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index c5487a1c7..96d7c4b82 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -7399,6 +7399,8 @@ USE baiyue_firstleg; /* * 三期主子入仓单结构迁移。 * 仅建立结构和数据库保护,不自动拆分或回填存量业务数据。 + * 新写入采用“来源单承接实装、仅创建余量子单”;LOADED 子单和 loaded_child_inbound_id + * 仅为历史双子单读取保留,不删除、不清洗存量记录。 * tenant_id 继续表示客户隔离键;数量统一使用 package_count。 */ DROP PROCEDURE IF EXISTS phase3_inbound_add_column; @@ -7455,6 +7457,10 @@ UPDATE fl_inbound_order WHERE inbound_role = 'CHILD' AND split_purpose = 'MASTER'; +CALL phase3_inbound_add_column( + 'fl_inbound_order', 'active_remainder_root_id', + "BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN inbound_role = 'CHILD' AND split_purpose = 'REMAINDER' AND split_status = 'NOT_SPLIT' AND deleted = 0 THEN root_inbound_id ELSE NULL END) STORED COMMENT '有效余量唯一约束键;历史已完成分片为空'"); + DROP PROCEDURE IF EXISTS phase3_inbound_add_column; DROP PROCEDURE IF EXISTS phase3_inbound_add_index; @@ -7486,6 +7492,31 @@ CALL phase3_inbound_add_index( 'fl_inbound_order', 'idx_inbound_root_role', 'KEY `idx_inbound_root_role` (`tenant_id`, `root_inbound_id`, `inbound_role`, `created_at`, `id`)'); +DROP PROCEDURE IF EXISTS phase3_assert_active_remainder_unique_ready; +DELIMITER $$ +CREATE PROCEDURE phase3_assert_active_remainder_unique_ready() +BEGIN + IF EXISTS ( + SELECT 1 + FROM fl_inbound_order + WHERE inbound_role = 'CHILD' + AND split_purpose = 'REMAINDER' + AND split_status = 'NOT_SPLIT' + AND deleted = 0 + AND root_inbound_id IS NOT NULL + GROUP BY tenant_id, root_inbound_id + HAVING COUNT(*) > 1 + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '同一根入仓单存在多个有效余量,请先人工核对历史数据'; + END IF; +END$$ +DELIMITER ; +CALL phase3_assert_active_remainder_unique_ready(); +DROP PROCEDURE IF EXISTS phase3_assert_active_remainder_unique_ready; +CALL phase3_inbound_add_index( + 'fl_inbound_order', 'uk_inbound_one_active_remainder', + 'UNIQUE KEY `uk_inbound_one_active_remainder` (`tenant_id`, `active_remainder_root_id`)'); + DROP PROCEDURE IF EXISTS phase3_inbound_drop_legacy_index; DELIMITER $$ CREATE PROCEDURE phase3_inbound_drop_legacy_index() @@ -7812,8 +7843,8 @@ BEGIN OR NEW.split_sequence IS NULL OR NEW.package_count IS NULL THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单缺少主单、顺序号或箱数'; END IF; - IF NEW.split_purpose = 'MASTER' THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单必须标记LOADED或REMAINDER'; + IF NEW.split_purpose <> 'REMAINDER' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '新建子入仓单只能作为余量分片'; END IF; IF NOT EXISTS ( SELECT 1 FROM fl_inbound_order parent_order @@ -7853,11 +7884,6 @@ BEGIN 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') - OR (NEW.split_status = 'FULLY_SPLIT' AND COALESCE(NEW.package_count, 0) <> 0)) THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主单箱数归零必须与完全拆分状态同时提交'; - END IF; END$$ CREATE TRIGGER trg_inbound_split_item_before_insert @@ -7876,11 +7902,14 @@ BEGIN AND batch.tenant_id = NEW.tenant_id AND batch.parent_inbound_id = NEW.parent_inbound_id AND batch.status = 'PREPARED' + AND batch.loaded_child_inbound_id IS NULL + AND batch.remainder_child_inbound_id IS NOT NULL + AND NEW.child_inbound_id = batch.remainder_child_inbound_id AND parent_order.tenant_id = NEW.tenant_id AND parent_order.inbound_role = 'MASTER' AND child_order.tenant_id = NEW.tenant_id AND child_order.inbound_role = 'CHILD' - AND child_order.split_purpose IN ('LOADED', 'REMAINDER') + AND child_order.split_purpose = 'REMAINDER' AND child_order.parent_inbound_id = NEW.parent_inbound_id AND child_order.package_count >= NEW.package_count AND child_order.deleted = 0 @@ -7905,6 +7934,13 @@ BEGIN IF NEW.status <> 'PREPARED' OR NEW.applied_at IS NOT NULL THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次必须从PREPARED创建'; END IF; + IF NEW.loaded_child_inbound_id IS NOT NULL + OR NEW.loaded_package_count = 0 + OR NEW.loaded_package_count + NEW.remaining_package_count <> NEW.package_count + OR (NEW.remaining_package_count = 0 AND NEW.remainder_child_inbound_id IS NOT NULL) + OR (NEW.remaining_package_count > 0 AND NEW.remainder_child_inbound_id IS NULL) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '新拆分批次必须由来源单承接实装且最多创建一个余量子单'; + END IF; IF NOT EXISTS ( SELECT 1 FROM fl_inbound_order parent_order WHERE parent_order.id = NEW.parent_inbound_id @@ -7919,16 +7955,21 @@ BEGIN WHERE source_order.id = NEW.source_inbound_id AND source_order.tenant_id = NEW.tenant_id AND source_order.deleted = 0 - AND COALESCE(source_order.package_count, source_order.total_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 + AND ((source_order.split_status = 'NOT_SPLIT' + AND COALESCE(source_order.package_count, source_order.total_package_count) = NEW.package_count) + OR (source_order.split_status = 'FULLY_SPLIT' + AND source_order.package_count = NEW.loaded_package_count))) 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_status = 'NOT_SPLIT')) + AND ((source_order.split_status = 'NOT_SPLIT' + AND source_order.package_count = NEW.package_count) + OR (source_order.split_status = 'FULLY_SPLIT' + AND source_order.package_count = NEW.loaded_package_count)))) ) 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 ( @@ -7944,6 +7985,20 @@ BEGIN ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '实装拆分批次必须绑定匹配的装柜计划和柜次'; END IF; + IF NEW.remainder_child_inbound_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM fl_inbound_order remainder_order + WHERE remainder_order.id = NEW.remainder_child_inbound_id + AND remainder_order.tenant_id = NEW.tenant_id + AND remainder_order.parent_inbound_id = NEW.parent_inbound_id + AND remainder_order.root_inbound_id = NEW.parent_inbound_id + AND remainder_order.inbound_role = 'CHILD' + AND remainder_order.split_purpose = 'REMAINDER' + AND remainder_order.split_status = 'NOT_SPLIT' + AND remainder_order.package_count = NEW.remaining_package_count + AND remainder_order.deleted = 0 + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '余量子入仓单与拆分批次数量或根归属不一致'; + END IF; END$$ CREATE TRIGGER trg_inbound_split_batch_before_update @@ -7987,34 +8042,28 @@ BEGIN WHERE source_order.id = NEW.source_inbound_id AND source_order.tenant_id = NEW.tenant_id AND source_order.split_status = 'FULLY_SPLIT' + AND source_order.package_count = NEW.loaded_package_count ) OR (SELECT COALESCE(SUM(item.package_count), 0) FROM fl_inbound_split_item item WHERE item.tenant_id = NEW.tenant_id - AND item.split_batch_id = NEW.id) <> NEW.package_count OR + AND item.split_batch_id = NEW.id) <> NEW.remaining_package_count OR (SELECT COUNT(DISTINCT item.child_inbound_id) FROM fl_inbound_split_item item WHERE item.tenant_id = NEW.tenant_id - AND item.split_batch_id = NEW.id) NOT BETWEEN 1 AND 2 OR - (SELECT COALESCE(SUM(item.package_count), 0) - FROM fl_inbound_split_item item - WHERE item.tenant_id = NEW.tenant_id - AND item.split_batch_id = NEW.id - AND item.child_inbound_id = NEW.loaded_child_inbound_id) <> NEW.loaded_package_count OR - (SELECT COALESCE(SUM(item.package_count), 0) - FROM fl_inbound_split_item item - WHERE item.tenant_id = NEW.tenant_id - AND item.split_batch_id = NEW.id - AND item.child_inbound_id = NEW.remainder_child_inbound_id) <> NEW.remaining_package_count OR + AND item.split_batch_id = NEW.id) <> CASE WHEN NEW.remaining_package_count > 0 THEN 1 ELSE 0 END OR + (NEW.loaded_child_inbound_id IS NOT NULL) OR NEW.loaded_package_count + NEW.remaining_package_count <> NEW.package_count OR - (NEW.loaded_package_count > 0 AND NEW.loaded_child_inbound_id IS NULL) OR (NEW.remaining_package_count > 0 AND NEW.remainder_child_inbound_id IS NULL) OR - (NEW.loaded_child_inbound_id IS NOT NULL AND NOT EXISTS ( - SELECT 1 FROM fl_inbound_order c WHERE c.id = NEW.loaded_child_inbound_id - AND c.parent_inbound_id = NEW.parent_inbound_id AND c.split_purpose = 'LOADED')) OR (NEW.remainder_child_inbound_id IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM fl_inbound_order c WHERE c.id = NEW.remainder_child_inbound_id - AND c.parent_inbound_id = NEW.parent_inbound_id AND c.split_purpose = 'REMAINDER')) OR + AND c.parent_inbound_id = NEW.parent_inbound_id AND c.root_inbound_id = NEW.parent_inbound_id + AND c.split_purpose = 'REMAINDER' AND c.split_status = 'NOT_SPLIT' + AND c.package_count = NEW.remaining_package_count AND c.deleted = 0)) OR + EXISTS (SELECT 1 FROM fl_inbound_split_item item + WHERE item.tenant_id = NEW.tenant_id + AND item.split_batch_id = NEW.id + AND item.child_inbound_id <> NEW.remainder_child_inbound_id) OR EXISTS (SELECT 1 FROM fl_inbound_split_item item JOIN fl_inbound_order child_order ON child_order.id = item.child_inbound_id @@ -8036,6 +8085,24 @@ BEGIN ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子单承接箱数超过来源测量箱规箱数'; END IF; + IF NOT EXISTS ( + SELECT 1 + FROM fl_inbound_order root_order + WHERE root_order.id = NEW.parent_inbound_id + AND root_order.tenant_id = NEW.tenant_id + AND root_order.inbound_role = 'MASTER' + AND root_order.deleted = 0 + AND root_order.total_package_count = + COALESCE(root_order.package_count, 0) + + COALESCE((SELECT SUM(child.package_count) + FROM fl_inbound_order child + WHERE child.tenant_id = NEW.tenant_id + AND child.root_inbound_id = root_order.id + AND child.inbound_role = 'CHILD' + AND child.deleted = 0), 0) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '根入仓单原始箱数与已完成分片及当前余量不守恒'; + END IF; ELSEIF NEW.status = 'CANCELLED' THEN IF EXISTS ( SELECT 1 FROM fl_inbound_split_item item @@ -8093,12 +8160,13 @@ BEGIN AND table_name = 'fl_inbound_order' AND column_name IN ('inbound_role', 'parent_inbound_id', 'root_inbound_id', 'split_sequence', 'split_status', 'package_count', - 'master_order_id', 'split_purpose')) <> 8 THEN + 'master_order_id', 'split_purpose', 'active_remainder_root_id')) <> 9 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单关系字段未完整创建'; END IF; IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' - AND index_name IN ('uk_inbound_master_order', 'uk_inbound_parent_split_sequence')) <> 2 + AND index_name IN ('uk_inbound_master_order', 'uk_inbound_parent_split_sequence', + 'uk_inbound_one_active_remainder')) <> 3 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', 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 6e46e6917..37bfa1803 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 @@ -4,6 +4,8 @@ USE baiyue_firstleg; /* * 三期主子入仓单结构迁移。 * 仅建立结构和数据库保护,不自动拆分或回填存量业务数据。 + * 新写入采用“来源单承接实装、仅创建余量子单”;LOADED 子单和 loaded_child_inbound_id + * 仅为历史双子单读取保留,不删除、不清洗存量记录。 * tenant_id 继续表示客户隔离键;数量统一使用 package_count。 */ DROP PROCEDURE IF EXISTS phase3_inbound_add_column; @@ -60,6 +62,10 @@ UPDATE fl_inbound_order WHERE inbound_role = 'CHILD' AND split_purpose = 'MASTER'; +CALL phase3_inbound_add_column( + 'fl_inbound_order', 'active_remainder_root_id', + "BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN inbound_role = 'CHILD' AND split_purpose = 'REMAINDER' AND split_status = 'NOT_SPLIT' AND deleted = 0 THEN root_inbound_id ELSE NULL END) STORED COMMENT '有效余量唯一约束键;历史已完成分片为空'"); + DROP PROCEDURE IF EXISTS phase3_inbound_add_column; DROP PROCEDURE IF EXISTS phase3_inbound_add_index; @@ -91,6 +97,31 @@ CALL phase3_inbound_add_index( 'fl_inbound_order', 'idx_inbound_root_role', 'KEY `idx_inbound_root_role` (`tenant_id`, `root_inbound_id`, `inbound_role`, `created_at`, `id`)'); +DROP PROCEDURE IF EXISTS phase3_assert_active_remainder_unique_ready; +DELIMITER $$ +CREATE PROCEDURE phase3_assert_active_remainder_unique_ready() +BEGIN + IF EXISTS ( + SELECT 1 + FROM fl_inbound_order + WHERE inbound_role = 'CHILD' + AND split_purpose = 'REMAINDER' + AND split_status = 'NOT_SPLIT' + AND deleted = 0 + AND root_inbound_id IS NOT NULL + GROUP BY tenant_id, root_inbound_id + HAVING COUNT(*) > 1 + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '同一根入仓单存在多个有效余量,请先人工核对历史数据'; + END IF; +END$$ +DELIMITER ; +CALL phase3_assert_active_remainder_unique_ready(); +DROP PROCEDURE IF EXISTS phase3_assert_active_remainder_unique_ready; +CALL phase3_inbound_add_index( + 'fl_inbound_order', 'uk_inbound_one_active_remainder', + 'UNIQUE KEY `uk_inbound_one_active_remainder` (`tenant_id`, `active_remainder_root_id`)'); + DROP PROCEDURE IF EXISTS phase3_inbound_drop_legacy_index; DELIMITER $$ CREATE PROCEDURE phase3_inbound_drop_legacy_index() @@ -417,8 +448,8 @@ BEGIN OR NEW.split_sequence IS NULL OR NEW.package_count IS NULL THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单缺少主单、顺序号或箱数'; END IF; - IF NEW.split_purpose = 'MASTER' THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子入仓单必须标记LOADED或REMAINDER'; + IF NEW.split_purpose <> 'REMAINDER' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '新建子入仓单只能作为余量分片'; END IF; IF NOT EXISTS ( SELECT 1 FROM fl_inbound_order parent_order @@ -458,11 +489,6 @@ BEGIN 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') - OR (NEW.split_status = 'FULLY_SPLIT' AND COALESCE(NEW.package_count, 0) <> 0)) THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主单箱数归零必须与完全拆分状态同时提交'; - END IF; END$$ CREATE TRIGGER trg_inbound_split_item_before_insert @@ -481,11 +507,14 @@ BEGIN AND batch.tenant_id = NEW.tenant_id AND batch.parent_inbound_id = NEW.parent_inbound_id AND batch.status = 'PREPARED' + AND batch.loaded_child_inbound_id IS NULL + AND batch.remainder_child_inbound_id IS NOT NULL + AND NEW.child_inbound_id = batch.remainder_child_inbound_id AND parent_order.tenant_id = NEW.tenant_id AND parent_order.inbound_role = 'MASTER' AND child_order.tenant_id = NEW.tenant_id AND child_order.inbound_role = 'CHILD' - AND child_order.split_purpose IN ('LOADED', 'REMAINDER') + AND child_order.split_purpose = 'REMAINDER' AND child_order.parent_inbound_id = NEW.parent_inbound_id AND child_order.package_count >= NEW.package_count AND child_order.deleted = 0 @@ -510,6 +539,13 @@ BEGIN IF NEW.status <> 'PREPARED' OR NEW.applied_at IS NOT NULL THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '拆分批次必须从PREPARED创建'; END IF; + IF NEW.loaded_child_inbound_id IS NOT NULL + OR NEW.loaded_package_count = 0 + OR NEW.loaded_package_count + NEW.remaining_package_count <> NEW.package_count + OR (NEW.remaining_package_count = 0 AND NEW.remainder_child_inbound_id IS NOT NULL) + OR (NEW.remaining_package_count > 0 AND NEW.remainder_child_inbound_id IS NULL) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '新拆分批次必须由来源单承接实装且最多创建一个余量子单'; + END IF; IF NOT EXISTS ( SELECT 1 FROM fl_inbound_order parent_order WHERE parent_order.id = NEW.parent_inbound_id @@ -524,16 +560,21 @@ BEGIN WHERE source_order.id = NEW.source_inbound_id AND source_order.tenant_id = NEW.tenant_id AND source_order.deleted = 0 - AND COALESCE(source_order.package_count, source_order.total_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 + AND ((source_order.split_status = 'NOT_SPLIT' + AND COALESCE(source_order.package_count, source_order.total_package_count) = NEW.package_count) + OR (source_order.split_status = 'FULLY_SPLIT' + AND source_order.package_count = NEW.loaded_package_count))) 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_status = 'NOT_SPLIT')) + AND ((source_order.split_status = 'NOT_SPLIT' + AND source_order.package_count = NEW.package_count) + OR (source_order.split_status = 'FULLY_SPLIT' + AND source_order.package_count = NEW.loaded_package_count)))) ) 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 ( @@ -549,6 +590,20 @@ BEGIN ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '实装拆分批次必须绑定匹配的装柜计划和柜次'; END IF; + IF NEW.remainder_child_inbound_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM fl_inbound_order remainder_order + WHERE remainder_order.id = NEW.remainder_child_inbound_id + AND remainder_order.tenant_id = NEW.tenant_id + AND remainder_order.parent_inbound_id = NEW.parent_inbound_id + AND remainder_order.root_inbound_id = NEW.parent_inbound_id + AND remainder_order.inbound_role = 'CHILD' + AND remainder_order.split_purpose = 'REMAINDER' + AND remainder_order.split_status = 'NOT_SPLIT' + AND remainder_order.package_count = NEW.remaining_package_count + AND remainder_order.deleted = 0 + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '余量子入仓单与拆分批次数量或根归属不一致'; + END IF; END$$ CREATE TRIGGER trg_inbound_split_batch_before_update @@ -592,34 +647,28 @@ BEGIN WHERE source_order.id = NEW.source_inbound_id AND source_order.tenant_id = NEW.tenant_id AND source_order.split_status = 'FULLY_SPLIT' + AND source_order.package_count = NEW.loaded_package_count ) OR (SELECT COALESCE(SUM(item.package_count), 0) FROM fl_inbound_split_item item WHERE item.tenant_id = NEW.tenant_id - AND item.split_batch_id = NEW.id) <> NEW.package_count OR + AND item.split_batch_id = NEW.id) <> NEW.remaining_package_count OR (SELECT COUNT(DISTINCT item.child_inbound_id) FROM fl_inbound_split_item item WHERE item.tenant_id = NEW.tenant_id - AND item.split_batch_id = NEW.id) NOT BETWEEN 1 AND 2 OR - (SELECT COALESCE(SUM(item.package_count), 0) - FROM fl_inbound_split_item item - WHERE item.tenant_id = NEW.tenant_id - AND item.split_batch_id = NEW.id - AND item.child_inbound_id = NEW.loaded_child_inbound_id) <> NEW.loaded_package_count OR - (SELECT COALESCE(SUM(item.package_count), 0) - FROM fl_inbound_split_item item - WHERE item.tenant_id = NEW.tenant_id - AND item.split_batch_id = NEW.id - AND item.child_inbound_id = NEW.remainder_child_inbound_id) <> NEW.remaining_package_count OR + AND item.split_batch_id = NEW.id) <> CASE WHEN NEW.remaining_package_count > 0 THEN 1 ELSE 0 END OR + (NEW.loaded_child_inbound_id IS NOT NULL) OR NEW.loaded_package_count + NEW.remaining_package_count <> NEW.package_count OR - (NEW.loaded_package_count > 0 AND NEW.loaded_child_inbound_id IS NULL) OR (NEW.remaining_package_count > 0 AND NEW.remainder_child_inbound_id IS NULL) OR - (NEW.loaded_child_inbound_id IS NOT NULL AND NOT EXISTS ( - SELECT 1 FROM fl_inbound_order c WHERE c.id = NEW.loaded_child_inbound_id - AND c.parent_inbound_id = NEW.parent_inbound_id AND c.split_purpose = 'LOADED')) OR (NEW.remainder_child_inbound_id IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM fl_inbound_order c WHERE c.id = NEW.remainder_child_inbound_id - AND c.parent_inbound_id = NEW.parent_inbound_id AND c.split_purpose = 'REMAINDER')) OR + AND c.parent_inbound_id = NEW.parent_inbound_id AND c.root_inbound_id = NEW.parent_inbound_id + AND c.split_purpose = 'REMAINDER' AND c.split_status = 'NOT_SPLIT' + AND c.package_count = NEW.remaining_package_count AND c.deleted = 0)) OR + EXISTS (SELECT 1 FROM fl_inbound_split_item item + WHERE item.tenant_id = NEW.tenant_id + AND item.split_batch_id = NEW.id + AND item.child_inbound_id <> NEW.remainder_child_inbound_id) OR EXISTS (SELECT 1 FROM fl_inbound_split_item item JOIN fl_inbound_order child_order ON child_order.id = item.child_inbound_id @@ -641,6 +690,24 @@ BEGIN ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '子单承接箱数超过来源测量箱规箱数'; END IF; + IF NOT EXISTS ( + SELECT 1 + FROM fl_inbound_order root_order + WHERE root_order.id = NEW.parent_inbound_id + AND root_order.tenant_id = NEW.tenant_id + AND root_order.inbound_role = 'MASTER' + AND root_order.deleted = 0 + AND root_order.total_package_count = + COALESCE(root_order.package_count, 0) + + COALESCE((SELECT SUM(child.package_count) + FROM fl_inbound_order child + WHERE child.tenant_id = NEW.tenant_id + AND child.root_inbound_id = root_order.id + AND child.inbound_role = 'CHILD' + AND child.deleted = 0), 0) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '根入仓单原始箱数与已完成分片及当前余量不守恒'; + END IF; ELSEIF NEW.status = 'CANCELLED' THEN IF EXISTS ( SELECT 1 FROM fl_inbound_split_item item @@ -698,12 +765,13 @@ BEGIN AND table_name = 'fl_inbound_order' AND column_name IN ('inbound_role', 'parent_inbound_id', 'root_inbound_id', 'split_sequence', 'split_status', 'package_count', - 'master_order_id', 'split_purpose')) <> 8 THEN + 'master_order_id', 'split_purpose', 'active_remainder_root_id')) <> 9 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单关系字段未完整创建'; END IF; IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order' - AND index_name IN ('uk_inbound_master_order', 'uk_inbound_parent_split_sequence')) <> 2 + AND index_name IN ('uk_inbound_master_order', 'uk_inbound_parent_split_sequence', + 'uk_inbound_one_active_remainder')) <> 3 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', 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 a57c73212..ceb938c9c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundParentChildSqlContractTest.java @@ -22,10 +22,13 @@ class InboundParentChildSqlContractTest { .contains("'fl_inbound_order', 'package_count'") .contains("'fl_inbound_order', 'split_purpose'") .contains("SET split_purpose = 'REMAINDER'") + .contains("active_remainder_root_id") .contains("GENERATED ALWAYS AS (CASE WHEN inbound_role = 'MASTER'") + .contains("GENERATED ALWAYS AS (CASE WHEN inbound_role = 'CHILD'") .contains("DROP INDEX uk_inbound_tenant_order") .contains("uk_inbound_master_order") .contains("uk_inbound_parent_split_sequence") + .contains("uk_inbound_one_active_remainder") .contains("DROP INDEX uk_inbound_parent_sequence"); } @@ -54,17 +57,23 @@ class InboundParentChildSqlContractTest { } @Test - void protectsOwnershipAndTotals() { + void protectsSingleRemainderWritesAndConservation() { assertThat(sql) .contains("子入仓单必须绑定同一客户的有效主单") .contains("parent_order.version > NEW.expected_parent_version") .contains("NEW.loaded_package_count") .contains("NEW.remaining_package_count") .contains("NEW.loaded_package_count + NEW.remaining_package_count <> NEW.package_count") + .contains("NEW.loaded_child_inbound_id IS NOT NULL") + .contains("NEW.child_inbound_id = batch.remainder_child_inbound_id") + .contains("source_order.package_count = NEW.loaded_package_count") + .contains("item.child_inbound_id <> NEW.remainder_child_inbound_id") + .contains("<> NEW.remaining_package_count") + .contains("根入仓单原始箱数与已完成分片及当前余量不守恒") .contains("COALESCE(source_order.package_count, source_order.total_package_count) = NEW.package_count") .contains("AND plan_item.deleted = 0))))\n OR NOT EXISTS (") .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") @@ -75,13 +84,14 @@ class InboundParentChildSqlContractTest { } @Test - void validatesMultiChildAndMultiSkuTotals() { + void validatesSingleRemainderAndMultiSkuTotals() { assertThat(sql) .contains("uk_inbound_parent_split_sequence") + .contains("uk_inbound_one_active_remainder") .contains("uk_inbound_split_batch_line") .contains("uk_inbound_split_batch_source_child") .contains("COUNT(DISTINCT item.child_inbound_id)") - .contains("NOT BETWEEN 1 AND 2") + .contains("<> CASE WHEN NEW.remaining_package_count > 0 THEN 1 ELSE 0 END") .contains("child_order.package_count <> SUM(item.package_count)"); } @@ -108,6 +118,7 @@ class InboundParentChildSqlContractTest { assertThat(sql) .contains("table_name = 'fl_inbound_split_batch'") .contains("uk_inbound_split_source_once") + .contains("uk_inbound_one_active_remainder") .contains("table_name = 'fl_inbound_split_item'") .contains("uk_inbound_split_batch_source_child") .contains("is_nullable = 'NO'") 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 f08c26ecd..39547bfbf 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -107,6 +107,22 @@ class Phase3FinalUnifiedSqlContractTest { .doesNotContain("UNIQUE KEY `uk_inbound_split_batch_source_item`"); } + @Test + void unifiedEntryEnforcesSingleRemainderWritesAndKeepsLegacyColumns() throws Exception { + String sql = normalise(Files.readString(resolveUnified())); + + assertThat(sql) + .contains("'fl_inbound_order', 'active_remainder_root_id'") + .contains("uk_inbound_one_active_remainder") + .contains("uk_inbound_split_source_once") + .contains("NEW.loaded_child_inbound_id IS NOT NULL") + .contains("NEW.child_inbound_id = batch.remainder_child_inbound_id") + .contains("source_order.package_count = NEW.loaded_package_count") + .contains("根入仓单原始箱数与已完成分片及当前余量不守恒") + .contains("'fl_inbound_split_batch', 'loaded_child_inbound_id'") + .contains("fk_inbound_split_loaded_child"); + } + private static void assertBlock(String unified, String marker, String nextMarker, String modulePath) throws Exception { int start = unified.indexOf(marker); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadSqlContractTest.java index d4ec93b8c..7c5354aa4 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadSqlContractTest.java @@ -16,7 +16,7 @@ class SplitPreloadSqlContractTest { } @Test - void limitsSecondaryInboundDepth() throws Exception { + void limitsNewChildrenToRootBasedRemainders() throws Exception { String sql = moduleMigration(); assertThat(sql) .contains("parent_inbound_id") @@ -25,7 +25,8 @@ class SplitPreloadSqlContractTest { .contains("split_status") .contains("uk_inbound_parent_split_sequence") .contains("fk_inbound_parent_order") - .contains("子入仓单必须标记LOADED或REMAINDER") + .contains("新建子入仓单只能作为余量分片") + .contains("uk_inbound_one_active_remainder") .contains("入仓单主子归属创建后不可修改"); }