This commit is contained in:
2026-08-17 12:31:24 +08:00
parent 8566c6ea9e
commit ac6cf9c904
23 changed files with 396 additions and 58 deletions
@@ -0,0 +1,43 @@
package com.baiyue.firstleg.domain;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import java.math.BigDecimal;
import java.util.Locale;
import java.util.Map;
/** Platform-controlled container specifications shared by booking and simulation flows. */
public final class ContainerSpecificationCatalog {
private static final Map<String, Specification> VALUES = Map.of(
"20GP", new Specification("20GP", new BigDecimal("33"), new BigDecimal("28000")),
"40GP", new Specification("40GP", new BigDecimal("67"), new BigDecimal("28000")),
"40HC", new Specification("40HC", new BigDecimal("76"), new BigDecimal("28000")),
"40NR", new Specification("40NR", new BigDecimal("67"), new BigDecimal("28000")),
"45HC", new Specification("45HC", new BigDecimal("86"), new BigDecimal("28000"))
);
private ContainerSpecificationCatalog() {}
public static Specification require(String containerType) {
String normalized = containerType == null ? "" : containerType.trim().toUpperCase(Locale.ROOT);
Specification specification = VALUES.get(normalized);
if (specification == null) {
throw new BizException(ResultCode.VALIDATION_FAILED, "柜型必须使用平台固定规格");
}
return specification;
}
public static Specification requireMatch(String containerType, BigDecimal capacityCbm, BigDecimal maxWeightKg) {
Specification specification = require(containerType);
if (capacityCbm == null || maxWeightKg == null
|| specification.capacityCbm().compareTo(capacityCbm) != 0
|| specification.maxWeightKg().compareTo(maxWeightKg) != 0) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"柜型容量和限重必须使用平台固定规格");
}
return specification;
}
public record Specification(String containerType, BigDecimal capacityCbm, BigDecimal maxWeightKg) {}
}
@@ -6,9 +6,9 @@ import java.util.List;
@Mapper public interface ContainerOverviewMapper {
@Select("SELECT * FROM fl_container_document WHERE container_booking_id=#{id} AND document_type IN ('DELIVERY_ORDER','PICKUP_ORDER') ORDER BY document_type") List<ContainerDocumentEntity> documents(@Param("id") long id);
@Select("SELECT * FROM fl_container_transport_history WHERE container_booking_id=#{id} ORDER BY created_at,id") List<ContainerTransportHistoryEntity> transport(@Param("id") long id);
@Select("SELECT id,action,reason,operator_id,operator_name,occurred_at FROM fl_container_exception_history WHERE container_booking_id=#{id} ORDER BY occurred_at,id") List<ExceptionRow> exceptions(@Param("id") long id);
@Select("SELECT id,action,reason,operator_id operatorId,operator_name operatorName,occurred_at occurredAt,request_hash requestHash FROM fl_container_exception_history WHERE container_booking_id=#{id} ORDER BY occurred_at,id") List<ExceptionRow> exceptions(@Param("id") long id);
@Insert("INSERT INTO fl_container_exception_history(id,container_booking_id,action,reason,operator_id,operator_name,operation_id,request_hash,occurred_at) VALUES(#{id},#{bookingId},#{action},#{reason},#{operatorId},#{operatorName},#{operationId},#{requestHash},CURRENT_TIMESTAMP(3))") int insertException(@Param("id") long id,@Param("bookingId") long bookingId,@Param("action") String action,@Param("reason") String reason,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName,@Param("operationId") String operationId,@Param("requestHash") String requestHash);
@Select("SELECT action,reason,operator_id operatorId,operator_name operatorName,occurred_at occurredAt FROM fl_container_exception_history WHERE container_booking_id=#{id} ORDER BY id DESC LIMIT 1") ExceptionRow latestException(@Param("id") long id);
@Select("SELECT id,action,reason,operator_id operatorId,operator_name operatorName,occurred_at occurredAt,request_hash requestHash FROM fl_container_exception_history WHERE container_booking_id=#{id} ORDER BY id DESC LIMIT 1") ExceptionRow latestException(@Param("id") long id);
@Select("SELECT id,action,reason,operator_id operatorId,operator_name operatorName,occurred_at occurredAt,request_hash requestHash FROM fl_container_exception_history WHERE container_booking_id=#{bookingId} AND operator_id=#{operatorId} AND operation_id=#{operationId} LIMIT 1") ExceptionRow exceptionOperation(@Param("bookingId") long bookingId,@Param("operatorId") long operatorId,@Param("operationId") String operationId);
@Select("SELECT id,loading_plan_id AS loadingPlanId,container_booking_id AS containerBookingId,"
+ "task_status AS taskStatus,version FROM fl_loading_pick_task "
@@ -5,6 +5,7 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.oceanbooking.domain.OceanBookingStatus;
import com.baiyue.firstleg.domain.ContainerSpecificationCatalog;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*;
import com.baiyue.firstleg.oceanbooking.entity.*;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
@@ -267,7 +268,7 @@ public class OceanBookingService {
}
private static List<OceanBookingPartyEntity> parties(long id, List<PartyRequest> requests) { return requests.stream().map(r -> { OceanBookingPartyEntity v = new OceanBookingPartyEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setPartyType(r.partyType().trim().toUpperCase()); v.setPartyName(r.partyName().trim()); v.setAddress(trim(r.address())); v.setContactName(trim(r.contactName())); v.setContactPhone(trim(r.contactPhone())); return v; }).toList(); }
private static List<OceanBookingContainerRequirementEntity> requirements(long id, List<ContainerRequirementRequest> requests) { int[] sort = {0}; return requests.stream().map(r -> { OceanBookingContainerRequirementEntity v = new OceanBookingContainerRequirementEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setContainerType(r.containerType().trim().toUpperCase()); v.setContainerKind(r.containerKind().trim().toUpperCase()); v.setCapacityCbm(r.capacityCbm()); v.setMaxWeightKg(r.maxWeightKg()); v.setQuantity(r.quantity()); v.setSortNo(sort[0]++); return v; }).toList(); }
private static List<OceanBookingContainerRequirementEntity> requirements(long id, List<ContainerRequirementRequest> requests) { int[] sort = {0}; return requests.stream().map(r -> { var specification = ContainerSpecificationCatalog.requireMatch(r.containerType(), r.capacityCbm(), r.maxWeightKg()); OceanBookingContainerRequirementEntity v = new OceanBookingContainerRequirementEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setContainerType(specification.containerType()); v.setContainerKind(r.containerKind().trim().toUpperCase()); v.setCapacityCbm(specification.capacityCbm()); v.setMaxWeightKg(specification.maxWeightKg()); v.setQuantity(r.quantity()); v.setSortNo(sort[0]++); return v; }).toList(); }
private String hash(String action, Long id, Object request) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((action + '|' + id + '|' + objectMapper.writeValueAsString(request)).getBytes(StandardCharsets.UTF_8))); } catch (Exception ex) { throw new IllegalStateException("无法计算订舱请求摘要", ex); } }
private String json(Object value) { if (value == null) return null; try { return objectMapper.writeValueAsString(value); } catch (JsonProcessingException ex) { throw new IllegalStateException("无法保存订舱历史快照", ex); } }
private static OceanBookingStatus parseStatus(String status) { try { return OceanBookingStatus.valueOf(status); } catch (RuntimeException ex) { throw badRequest("订舱状态无效"); } }
@@ -7,6 +7,7 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
import com.baiyue.firstleg.domain.SmartLoadingStatus;
import com.baiyue.firstleg.domain.ContainerSpecificationCatalog;
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.Query;
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
@@ -56,6 +57,7 @@ public class ContainerService {
@Transactional
public ContainerBookingEntity createSimulation(long operatorId, String operatorName, SimulationCreateRequest request) {
var specification = ContainerSpecificationCatalog.requireMatch(request.containerType(), request.capacityCbm(), request.maxWeightKg());
WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.warehouseId());
Long versionId = priceBooks.activeVersionId();
if (versionId == null) throw conflict("当前没有 ACTIVE 价格版本");
@@ -66,8 +68,8 @@ public class ContainerService {
value.setBookingNo("CB" + value.getId()); value.setCabinetNo("CAB-" + value.getId());
value.setChannelCode(channel.getChannelCode()); value.setChannelName(channel.getChannelName());
value.setWarehouseId(warehouse.id()); value.setWarehouseName(warehouse.warehouseName());
value.setContainerType(request.containerType().trim()); value.setCapacityCbm(request.capacityCbm());
value.setMaxWeightKg(request.maxWeightKg()); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setContainerType(specification.containerType()); value.setCapacityCbm(specification.capacityCbm());
value.setMaxWeightKg(specification.maxWeightKg()); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); repository.insert(value);
value.setPlanStatus(SmartLoadingStatus.WAIT_LOADING.name()); return value;
}
@@ -130,6 +132,7 @@ public class ContainerService {
throw conflict("货柜已进入预装流程,只能修改集装箱号和航次");
}
validateSchedule(request.data());
ContainerSpecificationCatalog.requireMatch(request.data().containerType(), request.data().capacityCbm(), request.data().maxWeightKg());
WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.data().warehouseId());
var trucking = suppliers.trucking(request.data().terminalTruckingCompanyId(), request.data().terminalTruckingCompany());
ensureUnique(trim(request.data().containerNo()), request.data().cabinetNo().trim(), id);
@@ -214,16 +217,17 @@ public class ContainerService {
catch (RuntimeException ex) { throw conflict("装柜计划状态无效,不能修改货柜"); }
}
private static void copy(ContainerBookingEntity v, SaveRequest r) {
var specification = ContainerSpecificationCatalog.requireMatch(r.containerType(), r.capacityCbm(), r.maxWeightKg());
v.setContainerNo(trim(r.containerNo())); v.setCabinetNo(r.cabinetNo().trim());
v.setWarehouseId(r.warehouseId()); v.setWarehouseName(trim(r.warehouseName()));
v.setBillOfLadingNo(trim(r.billOfLadingNo())); v.setVesselName(trim(r.vesselName()));
v.setVoyageNo(trim(r.voyageNo())); v.setContainerType(r.containerType().trim());
v.setVoyageNo(trim(r.voyageNo())); v.setContainerType(specification.containerType());
v.setShippingCompanyId(r.shippingCompanyId()); v.setShippingCompanyName(trim(r.shippingCompanyName()));
v.setDeparturePortCode(trim(r.departurePortCode())); v.setDeparturePortName(trim(r.departurePortName()));
v.setDestinationPortCode(trim(r.destinationPortCode())); v.setDestinationPortName(trim(r.destinationPortName()));
v.setDestinationWarehouseCode(trim(r.destinationWarehouseCode()));
v.setDestinationWarehouseName(trim(r.destinationWarehouseName()));
v.setCapacityCbm(r.capacityCbm()); v.setMaxWeightKg(r.maxWeightKg());
v.setCapacityCbm(specification.capacityCbm()); v.setMaxWeightKg(specification.maxWeightKg());
v.setTerminalTruckingCompany(trim(r.terminalTruckingCompany())); v.setLoadingAt(r.loadingAt());
v.setEstimatedSailingAt(r.estimatedSailingAt()); v.setActualSailingAt(r.actualSailingAt());
v.setEstimatedPortArrivalAt(r.estimatedPortArrivalAt()); v.setActualPortArrivalAt(r.actualPortArrivalAt());
@@ -101,13 +101,25 @@ public class MeasurementLocationOccupationService {
private java.util.List<WarehousePalletLocationClient.PalletReference> confirmedPalletReferences(
long tenantId, long inboundId) {
java.util.List<WarehousePalletLocationClient.PalletReference> references = measurements
.listWeights(tenantId, inboundId).stream()
.filter(weight -> "CONFIRMED".equals(weight.getRecognitionStatus()))
.filter(weight -> weight.getConfirmedAt() != null && weight.getNetWeightKg() != null)
.filter(weight -> weight.getPalletId() != null && weight.getPalletId() > 0)
.map(weight -> new WarehousePalletLocationClient.PalletReference(weight.getId(), weight.getPalletId()))
.toList();
java.util.List<WarehousePalletLocationClient.PalletReference> references = new java.util.ArrayList<>();
for (InboundWeightResultEntity weight : measurements.listWeights(tenantId, inboundId)) {
if (!"CONFIRMED".equals(weight.getRecognitionStatus())) {
continue;
}
if (weight.getConfirmedAt() == null) {
throw new BizException(ResultCode.CONFLICT, "已确认称重图片缺少确认时间,无法分配库位");
}
if (weight.getNetWeightKg() == null || weight.getNetWeightKg().signum() <= 0) {
throw new BizException(ResultCode.CONFLICT, "已确认称重图片缺少有效净重,无法分配库位");
}
if (weight.getPalletId() == null || weight.getPalletId() <= 0) {
throw new BizException(ResultCode.CONFLICT, "已确认称重图片缺少有效托盘,无法分配库位");
}
if (weight.getId() == null || weight.getId() <= 0) {
throw new BizException(ResultCode.CONFLICT, "已确认称重图片缺少结果标识,无法分配库位");
}
references.add(new WarehousePalletLocationClient.PalletReference(weight.getId(), weight.getPalletId()));
}
if (references.isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "至少需要一张已确认的称重图片才能提交测量");
}
@@ -78,13 +78,12 @@ public class MeasurementReviewService {
throw new BizException(ResultCode.VALIDATION_FAILED,
"人工确认箱数合计与入库单总箱数不一致,不能审核通过");
}
BigDecimal weight = items.stream().map(com.baiyue.firstleg.entity.InboundMeasurementItemEntity::getTotalWeightKg)
.filter(java.util.Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal weight = order.getActualGrossWeightKg();
BigDecimal volume = items.stream().map(com.baiyue.firstleg.entity.InboundMeasurementItemEntity::getTotalVolumeCbm)
.filter(java.util.Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add);
if (weight.signum() <= 0 || volume.signum() <= 0) {
if (weight == null || weight.signum() <= 0 || volume.signum() <= 0) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"审核通过的测量快照必须包含大于零的实重和体积");
"审核通过的测量快照必须包含大于零的货物总重量和体积");
}
}
@@ -42,7 +42,7 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号',
vessel_name VARCHAR(128) NULL COMMENT '船名快照',
voyage_no VARCHAR(64) NULL COMMENT '航次;确认前允许修订',
container_type VARCHAR(32) NOT NULL COMMENT '箱型,例如20GP、40GP、40HQ',
container_type VARCHAR(32) NOT NULL COMMENT '平台固定柜型:20GP、40GP、40HC、40NR、45HC',
shipping_company_id BIGINT UNSIGNED NULL COMMENT '船司ID快照',
shipping_company_name VARCHAR(128) NULL COMMENT '船司名称快照',
departure_port_code VARCHAR(32) NULL COMMENT '起运港编码快照',
@@ -1212,7 +1212,7 @@ components:
pages: {type: string, pattern: '^[0-9]+$'}
ContainerBooking:
type: object
required: [id, bookingNo, cabinetNo, warehouseId, warehouseName, containerType, planStatus, version]
required: [id, bookingNo, cabinetNo, warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg, planStatus, version]
properties:
id: {type: string, pattern: '^[1-9][0-9]*$'}
planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空}
@@ -1228,7 +1228,7 @@ components:
billOfLadingNo: {type: string, maxLength: 128, nullable: true}
vesselName: {type: string, maxLength: 128, nullable: true}
voyageNo: {type: string, maxLength: 64, nullable: true}
containerType: {type: string, maxLength: 32}
containerType: {type: string, enum: [20GP, 40GP, 40HC, 40NR, 45HC], description: 平台固定柜型}
shippingCompanyId: {type: string, nullable: true}
shippingCompanyName: {type: string, maxLength: 128, nullable: true}
departurePortCode: {type: string, maxLength: 32, nullable: true}
@@ -1237,8 +1237,8 @@ components:
destinationPortName: {type: string, maxLength: 128, nullable: true}
destinationWarehouseCode: {type: string, maxLength: 64, nullable: true}
destinationWarehouseName: {type: string, maxLength: 128, nullable: true}
capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true}
maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true}
capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: 必须等于柜型固定容量}
maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, description: 必须等于柜型固定限重}
terminalTruckingCompanyId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: ACTIVE Warehouse Supplier ID validated as TRUCKING}
terminalTruckingCompany: {type: string, maxLength: 128, nullable: true, description: Server-frozen Warehouse supplier name snapshot}
loadingAt: {type: string, format: date-time, nullable: true}
@@ -1252,7 +1252,7 @@ components:
version: {type: integer, minimum: 0}
ContainerSaveRequest:
type: object
required: [cabinetNo, warehouseId, warehouseName, containerType]
required: [cabinetNo, warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg]
properties:
containerNo: {type: string, maxLength: 64, nullable: true}
cabinetNo: {type: string, maxLength: 64}
@@ -1261,7 +1261,7 @@ components:
billOfLadingNo: {type: string, maxLength: 128, nullable: true}
vesselName: {type: string, maxLength: 128, nullable: true}
voyageNo: {type: string, maxLength: 64, nullable: true}
containerType: {type: string, maxLength: 32}
containerType: {type: string, enum: [20GP, 40GP, 40HC, 40NR, 45HC], description: 平台固定柜型}
shippingCompanyId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
shippingCompanyName: {type: string, maxLength: 128, nullable: true}
departurePortCode: {type: string, maxLength: 32, nullable: true}
@@ -1270,8 +1270,8 @@ components:
destinationPortName: {type: string, maxLength: 128, nullable: true}
destinationWarehouseCode: {type: string, maxLength: 64, nullable: true}
destinationWarehouseName: {type: string, maxLength: 128, nullable: true}
capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true}
maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, nullable: true}
capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: 必须等于柜型固定容量}
maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, description: 必须等于柜型固定限重}
terminalTruckingCompanyId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: ACTIVE Warehouse Supplier ID validated as TRUCKING}
terminalTruckingCompany: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint; persisted name always comes from Warehouse and cannot replace the ID}
loadingAt: {type: string, format: date-time, nullable: true}
@@ -1451,9 +1451,9 @@ components:
properties:
channelCode: {type: string, maxLength: 64, description: 当前 ACTIVE 价格簿中的真实物流渠道}
warehouseId: {type: integer, format: int64, minimum: 1}
containerType: {type: string, maxLength: 32}
capacityCbm: {type: number, minimum: 0, exclusiveMinimum: true}
maxWeightKg: {type: number, minimum: 0, exclusiveMinimum: true}
containerType: {type: string, enum: [20GP, 40GP, 40HC, 40NR, 45HC], description: 与订舱共用的平台固定柜型}
capacityCbm: {type: number, minimum: 0, exclusiveMinimum: true, description: 必须等于柜型固定容量}
maxWeightKg: {type: number, minimum: 0, exclusiveMinimum: true, description: 必须等于柜型固定限重}
ContainerOceanBookingLinkRequest:
type: object
required: [oceanBookingId, requirementId, expectedVersion]
@@ -67,10 +67,10 @@ components:
type: object
required: [containerType, containerKind, capacityCbm, maxWeightKg, quantity]
properties:
containerType: {type: string, maxLength: 32}
containerType: {type: string, enum: [20GP, 40GP, 40HC, 40NR, 45HC], description: Platform-controlled container type}
containerKind: {type: string, maxLength: 32}
capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0}
maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0}
capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: Must match the fixed platform specification for containerType}
maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, description: Must match the fixed platform specification for containerType}
quantity: {type: integer, minimum: 1}
ChannelOption:
type: object
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.domain;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ContainerSpecificationCatalogTest {
@Test
void exposesCanonicalSpecificationsAndRejectsAliases() {
var specification = ContainerSpecificationCatalog.requireMatch(
"40HC", new BigDecimal("76.000"), new BigDecimal("28000.000"));
assertThat(specification.containerType()).isEqualTo("40HC");
assertThatThrownBy(() -> ContainerSpecificationCatalog.require("40HQ"))
.isInstanceOf(BizException.class).hasMessageContaining("固定规格");
assertThatThrownBy(() -> ContainerSpecificationCatalog.requireMatch(
"40HC", new BigDecimal("68"), new BigDecimal("28000")))
.isInstanceOf(BizException.class).hasMessageContaining("容量和限重");
}
}
@@ -309,7 +309,7 @@ class OceanBookingServiceTest {
new PartyRequest("CONSIGNEE", "收货人", null, null, null),
new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null));
List<ContainerRequirementRequest> requirements = List.of(
new ContainerRequirementRequest("40HQ", "DRY", BigDecimal.ONE, BigDecimal.TEN, 1));
new ContainerRequirementRequest("40HC", "DRY", new BigDecimal("76"), new BigDecimal("28000"), 1));
return new DraftData(customerId, customerName, "SEA-US", 701L, "深圳仓", "BL-001",
81L, "船公司", null, null, "VESSEL", "V001", "CNSZX", "深圳", "USLAX", "洛杉矶",
null, serviceTerm, freightTerm, LocalDateTime.of(2026, 9, 1, 0, 0),
@@ -20,6 +20,16 @@ class ContainerOverviewSqlContractTest {
.doesNotContain("SELECT *");
}
@Test void exceptionQueriesSelectEveryExceptionRowConstructorField() throws Exception {
for (String method : java.util.List.of("exceptions", "latestException")) {
String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod(method, long.class)
.getAnnotation(Select.class).value());
assertThat(sql).contains("SELECT id,action,reason", "operator_id operatorId",
"operator_name operatorName", "occurred_at occurredAt", "request_hash requestHash")
.doesNotContain("SELECT *");
}
}
@Test void developmentAndUnifiedSchemasContainImmutableIdempotentExceptionHistory() throws Exception {
String development = new ClassPathResource("db/phase3-smart-loading-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
@@ -83,10 +83,22 @@ class ContainerServiceTest {
assertThat(saved.getChannelName()).isEqualTo("美西海运");
assertThat(saved.getWarehouseId()).isEqualTo(101L);
assertThat(saved.getWarehouseName()).isEqualTo("上海中转仓");
assertThat(saved.getCapacityCbm()).isEqualByComparingTo("68.0000");
assertThat(saved.getCapacityCbm()).isEqualByComparingTo("76.0000");
assertThat(saved.getPlanStatus()).isEqualTo("WAIT_LOADING");
}
@Test
@DisplayName("新增模拟柜拒绝柜型与固定规格不匹配")
void rejectsSimulationWithNonCanonicalSpecification() {
SimulationCreateRequest invalid = new SimulationCreateRequest(
"SEA-US", 101L, "40HC", new BigDecimal("100"), new BigDecimal("28000"));
assertThatThrownBy(() -> service.createSimulation(9L, "admin", invalid))
.isInstanceOf(BizException.class)
.hasMessageContaining("容量和限重");
verify(repository, never()).insert(any());
}
@Test
@DisplayName("智能装柜草稿阶段允许完整编辑")
void updatesAllFieldsWhileEditing() {
@@ -271,12 +283,12 @@ class ContainerServiceTest {
@DisplayName("首次关联允许已有预排货的编辑计划且保持内部柜和计划身份")
void linksConfirmedBookingAfterPreloading() {
ContainerBookingEntity container = booking("EDITING", 2);
container.setWarehouseId(101L); container.setChannelCode("SEA-US"); container.setContainerType("40HQ");
container.setCapacityCbm(new BigDecimal("68.0000")); container.setMaxWeightKg(new BigDecimal("26000.000"));
container.setWarehouseId(101L); container.setChannelCode("SEA-US"); container.setContainerType("40HC");
container.setCapacityCbm(new BigDecimal("76.0000")); container.setMaxWeightKg(new BigDecimal("28000.000"));
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(21L); booking.setStatus("CONFIRMED"); booking.setWarehouseId(101L); booking.setChannelCode("SEA-US");
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
requirement.setId(31L); requirement.setQuantity(1); requirement.setContainerType("40HQ"); requirement.setCapacityCbm(new BigDecimal("68.0000")); requirement.setMaxWeightKg(new BigDecimal("26000.000"));
requirement.setId(31L); requirement.setQuantity(1); requirement.setContainerType("40HC"); requirement.setCapacityCbm(new BigDecimal("76.0000")); requirement.setMaxWeightKg(new BigDecimal("28000.000"));
when(repository.find(11L)).thenReturn(container);
when(oceanBookings.find(21L)).thenReturn(booking);
when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
@@ -323,15 +335,15 @@ class ContainerServiceTest {
private static SaveRequest request(String cabinetNo) {
return new SaveRequest("MSCU1234567", cabinetNo, 101L, "上海仓", "BOL-001", "柏悦轮",
"V001", "40HQ", 201L, "船司A", "CNSHA", "上海港", "USLAX", "洛杉矶港",
"ONT8", "ONT8", new BigDecimal("68.0000"), new BigDecimal("26000.000"), 202L, "拖车公司A",
"V001", "40HC", 201L, "船司A", "CNSHA", "上海港", "USLAX", "洛杉矶港",
"ONT8", "ONT8", new BigDecimal("76.0000"), new BigDecimal("28000.000"), 202L, "拖车公司A",
LocalDateTime.of(2026, 8, 10, 8, 0), LocalDateTime.of(2026, 8, 12, 8, 0), null,
LocalDateTime.of(2026, 8, 25, 8, 0), null,
LocalDateTime.of(2026, 8, 27, 8, 0), null);
}
private static SimulationCreateRequest simulation() {
return new SimulationCreateRequest("SEA-US", 101L, "40HQ", new BigDecimal("68.0000"), new BigDecimal("26000.000"));
return new SimulationCreateRequest("SEA-US", 101L, "40HC", new BigDecimal("76.0000"), new BigDecimal("28000.000"));
}
private static ChannelRow channel() {
@@ -1,6 +1,7 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehousePalletLocationClient;
import com.baiyue.firstleg.config.InboundProperties;
import com.baiyue.firstleg.entity.InboundOrderEntity;
@@ -23,6 +24,7 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -115,6 +117,48 @@ class MeasurementLocationOccupationServiceTest {
org.mockito.ArgumentMatchers.any());
}
@Test
void rejectsConfirmedWeightWithoutConfirmationTimeInsteadOfDroppingIt() {
InboundWeightResultEntity weight = confirmedWeights().get(0);
weight.setConfirmedAt(null);
when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound());
when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(List.of(weight));
assertThatThrownBy(() -> service.occupy(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3))
.isInstanceOf(BizException.class)
.hasMessageContaining("缺少确认时间");
verify(warehouse, never()).reserve(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any());
}
@Test
void rejectsConfirmedWeightWithoutNetWeightInsteadOfDroppingIt() {
InboundWeightResultEntity weight = confirmedWeights().get(0);
weight.setNetWeightKg(null);
when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound());
when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(List.of(weight));
assertThatThrownBy(() -> service.occupy(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3))
.isInstanceOf(BizException.class)
.hasMessageContaining("缺少有效净重");
verify(warehouse, never()).reserve(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any());
}
@Test
void rejectsConfirmedWeightWithoutPalletInsteadOfDroppingIt() {
InboundWeightResultEntity weight = confirmedWeights().get(0);
weight.setPalletId(null);
when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound());
when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(List.of(weight));
assertThatThrownBy(() -> service.occupy(BUSINESS_TENANT, INBOUND, OPERATOR, MEASUREMENT, 3))
.isInstanceOf(BizException.class)
.hasMessageContaining("缺少有效托盘");
verify(warehouse, never()).reserve(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any());
}
@Test
void confirmFailureReleasesReservationInResourceTenantAndKeepsOriginalFailure() {
properties.setWarehouseResourceTenantId(90001L);
@@ -81,6 +81,23 @@ class MeasurementReviewServiceTest {
assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_SPLIT");
}
@Test
@DisplayName("审核通过使用订单权威货物总重量,不依赖箱规重量")
void shouldApproveWithActualGrossWeightWhenCartonWeightIsMissing() {
Fixture fixture = fixture(false);
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setPackageCount(5);
item.setTotalVolumeCbm(new BigDecimal("0.7480"));
when(fixture.repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(item));
when(fixture.repository.transitionReview(10001L, 10L, 9L, 3, true, 20L)).thenReturn(1);
when(fixture.repository.transitionMeasurement(10001L, 10L, 20L, "APPROVED")).thenReturn(1);
fixture.service.approve(10001L, 9L, "审核员", 10L, "approve-authoritative-weight",
new MeasurementReviewRequest(20L, 3, null));
verify(fixture.repository).transitionReview(10001L, 10L, 9L, 3, true, 20L);
}
@Test
@DisplayName("确认箱数合计不等于订单总箱数时不能审核通过")
void shouldRejectApprovalWhenConfirmedPackageCountDoesNotMatchOrder() {
@@ -195,6 +212,7 @@ class MeasurementReviewServiceTest {
order.setProcessStatus("MEASURE_PENDING");
order.setMeasureReviewStatus("PENDING");
order.setTotalPackageCount(5);
order.setActualGrossWeightKg(new BigDecimal("11.500"));
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(20L);
measurement.setStatus("SUBMITTED");