This commit is contained in:
2026-08-11 09:25:43 +08:00
6 changed files with 133 additions and 70 deletions
@@ -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&lt;&gt;'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
@@ -39,8 +39,10 @@ class ContainerBookingMapperContractTest {
.reduce("", (left, right) -> left + "\n" + right);
assertThat(allSql)
.contains("capacity_cbm")
.contains("p.plan_status&lt;&gt;'EDITING'")
.contains("p.plan_status!='EDITING'")
.contains("p.plan_status='COMPLETED'")
.contains("version=#{expectedVersion}");
.contains("version=#{expectedVersion}")
.doesNotContain("&lt;")
.doesNotContain("&gt;");
}
}
@@ -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));