Merge branch 'test' of http://8.163.30.128:10082/java-project/baiyue-firstleg into test
This commit is contained in:
@@ -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;
|
||||
@@ -63,7 +63,7 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
|
||||
updated_by_name=#{operatorName}, updated_at=CURRENT_TIMESTAMP(3), version=version+1
|
||||
WHERE id=#{value.id} AND version=#{expectedVersion}
|
||||
AND NOT EXISTS (SELECT 1 FROM fl_loading_plan p
|
||||
WHERE p.container_booking_id=fl_container_booking.id AND p.plan_status<>'EDITING')
|
||||
WHERE p.container_booking_id=fl_container_booking.id AND p.plan_status!='EDITING')
|
||||
""")
|
||||
int updateDraft(@Param("value") ContainerBookingEntity value, @Param("expectedVersion") int expectedVersion,
|
||||
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||
|
||||
@@ -29,6 +29,7 @@ import java.util.*;
|
||||
/** 智能装柜的候选、推荐/人工分配、删除和不可逆推送编排。 */
|
||||
@Service
|
||||
public class SmartLoadingService {
|
||||
private static final BigDecimal CAPACITY_LIMIT_RATIO = new BigDecimal("1.10");
|
||||
@Resource private SmartLoadingRepository repository;
|
||||
@Resource private OrderCargoClient orderCargoClient;
|
||||
@Resource private LoadingPickService loadingPickService;
|
||||
@@ -218,9 +219,12 @@ public class SmartLoadingService {
|
||||
if ("LOADING".equals(plan.getPlanStatus()) && key.equals(plan.getPushIdempotencyKey())) return view(plan, requireBooking(plan.getContainerBookingId()));
|
||||
if (!"EDITING".equals(plan.getPlanStatus()) || !Objects.equals(plan.getVersion(), request.expectedVersion())) throw conflict("仅编辑中的计划可开始装柜且版本必须匹配");
|
||||
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
|
||||
if (!StringUtils.hasText(booking.getContainerNo())) throw validation("开始装柜前必须填写集装箱号");
|
||||
if (repository.items(planId).isEmpty()) throw validation("装柜明细不能为空");
|
||||
loadingPickService.begin(planId, booking.getContainerNo(), operatorId, operatorName, repository.items(planId));
|
||||
List<LoadingPlanItemEntity> 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<LoadingPlanItemEntity> 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<LoadingPlanItemEntity> 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;}
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-2
@@ -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(">");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user