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));