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
@@ -6166,14 +6166,17 @@ ON DUPLICATE KEY UPDATE
deleted = 0,
updated_at = CURRENT_TIMESTAMP(3);
-- 平台托盘初始化主数据;托盘是可复用主数据,不维护动态占用状态。
-- 平台托盘初始化主数据;两个国内作业仓各初始化木托盘、塑料托盘各一条。
-- 托盘是可复用主数据,不维护动态占用状态。
INSERT INTO wh_pallet (
id, tenant_id, warehouse_id, pallet_code, pallet_type, capacity_cbm,
weight_kg, color, remark, created_by, updated_by
)
VALUES
(193000000000200101,0,193000000000200001,'TP-001','木托盘',1.200000,8.000,'黄色',NULL,0,0),
(193000000000200102,0,193000000000200002,'TP-002','胶托盘',2.400000,12.000,'黑色',NULL,0,0)
(193000000000200102,0,193000000000200002,'TP-002','塑料托盘',2.400000,12.000,'黑色',NULL,0,0),
(193000000000200103,0,193000000000200001,'YW-TP-002','塑料托盘',2.400000,12.000,'黑色',NULL,0,0),
(193000000000200104,0,193000000000200002,'DG-TP-002','木托盘',1.200000,8.000,'黄色',NULL,0,0)
ON DUPLICATE KEY UPDATE
tenant_id = 0,
warehouse_id = VALUES(warehouse_id),
@@ -6183,6 +6186,31 @@ ON DUPLICATE KEY UPDATE
color = VALUES(color),
remark = VALUES(remark);
DROP PROCEDURE IF EXISTS assert_domestic_pallet_seed;
DELIMITER //
CREATE PROCEDURE assert_domestic_pallet_seed()
BEGIN
IF EXISTS (
SELECT expected.warehouse_id
FROM (
SELECT 193000000000200001 AS warehouse_id
UNION ALL SELECT 193000000000200002
) expected
LEFT JOIN wh_pallet pallet
ON pallet.tenant_id = 0
AND pallet.warehouse_id = expected.warehouse_id
AND pallet.pallet_type IN ('木托盘','塑料托盘')
AND pallet.deleted = 0
GROUP BY expected.warehouse_id
HAVING COUNT(DISTINCT pallet.pallet_type) <> 2
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '国内作业仓木托盘、塑料托盘初始化不完整';
END IF;
END//
DELIMITER ;
CALL assert_domestic_pallet_seed();
DROP PROCEDURE IF EXISTS assert_domestic_pallet_seed;
-- 义乌仓初始化 A-F 六个库区,每区 A1-A15 共 90 个普通库位。
-- A0 仍是系统共享溢出位;东莞仓继续保留最小 A1 + A0 基线。
-- 不初始化预占、占用或任何业务单据。
@@ -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");
@@ -41,7 +41,8 @@ public class InternalPalletLocationController {
}
@PostMapping("/reservations")
@Operation(summary = "按托盘主数据拆分并预占库位")
@Operation(summary = "按已确认称重图片逐条预占库位",
description = "一张图片对应一条库位预占;托盘仅作为可重复引用的主数据,不按容量拆分或限制")
public ApiResponse<PalletAllocationDtos.BatchResult> reserve(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody PalletAllocationDtos.ReserveRequest request) {
verify(token);
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.warehouse.dto.LocationDtos;
import com.baiyue.warehouse.dto.PalletAllocationDtos;
import com.baiyue.warehouse.dto.LocationDtos.ActiveResource;
import com.baiyue.warehouse.dto.LocationDtos.ConfirmCommand;
import com.baiyue.warehouse.dto.LocationDtos.ExactSelectiveReleaseCommand;
@@ -284,7 +285,7 @@ public class LocationResourceService {
continue;
}
Integer allocatedPackageCount = occupancy.getAllocatedPackageCount();
if (allocatedPackageCount == null || allocatedPackageCount < 1) {
if (allocatedPackageCount == null || allocatedPackageCount < 0) {
throw new BizException(ResultCode.CONFLICT,
"Occupied location has no valid allocated package count and cannot be released exactly");
}
@@ -322,7 +323,7 @@ public class LocationResourceService {
if (!requested.contains(occupancy.getReservationId())) continue;
Integer allocated = occupancy.getAllocatedPackageCount();
LocationEntity location = repository.findOwned(command.tenantId(), occupancy.getWarehouseId(), occupancy.getLocationId());
if (allocated == null || allocated < 1 || location == null || repository.releaseOccupancy(occupancy, command.reason()) != 1
if (allocated == null || allocated < 0 || location == null || repository.releaseOccupancy(occupancy, command.reason()) != 1
|| (!Boolean.TRUE.equals(occupancy.getSharedOverflow()) && repository.changeStatus(command.tenantId(),
occupancy.getWarehouseId(), occupancy.getLocationId(), "OCCUPIED", "AVAILABLE", location.getVersion(),
command.operatorId()) != 1)) {
@@ -567,7 +568,7 @@ public class LocationResourceService {
private static int requireAllocatedPackageCount(LocationOccupancyEntity occupancy) {
Integer allocated = occupancy.getAllocatedPackageCount();
if (allocated == null || allocated < 1) {
if (allocated == null || allocated < 0) {
throw new BizException(ResultCode.CONFLICT, "已占用资源缺少有效分配箱数");
}
return allocated;
@@ -593,6 +594,33 @@ public class LocationResourceService {
return List.copyOf(result);
}
/** 活动分配重放必须与原始称重图片幂等键及托盘引用完全一致。 */
@Transactional(readOnly = true)
public void requireSameActiveReservations(long tenantId, long warehouseId, String sourceService,
long businessId, String requestIdempotencyKey,
List<PalletAllocationDtos.PalletReference> references) {
Map<String, Long> expected = references.stream().collect(Collectors.toMap(
item -> requestIdempotencyKey + ":" + item.weightResultId(),
PalletAllocationDtos.PalletReference::palletId,
(first, duplicate) -> {
throw new BizException(ResultCode.BAD_REQUEST, "同一张称重图片不能重复预占库位");
}));
List<LocationReservationEntity> active = repository.findReservationsByBusiness(
tenantId, sourceService, businessId).stream()
.filter(item -> "RESERVED".equals(item.getReservationStatus())
|| "CONFIRMED".equals(item.getReservationStatus()))
.toList();
Map<String, Long> actual = active.stream().collect(Collectors.toMap(
LocationReservationEntity::getIdempotencyKey, LocationReservationEntity::getPalletId,
(first, duplicate) -> {
throw new BizException(ResultCode.CONFLICT, "活动库位分配存在重复幂等键");
}));
boolean sameWarehouse = active.stream().allMatch(item -> item.getWarehouseId() == warehouseId);
if (!sameWarehouse || !actual.equals(expected)) {
throw new BizException(ResultCode.CONFLICT, "活动库位分配与本次称重图片引用不一致");
}
}
private LocationReservationEntity reservation(ReserveCommand command, ReservationTarget target,
LocationEntity location) {
long id = IdWorker.getId();
@@ -53,10 +53,12 @@ public class PalletLocationAllocationService {
@BusinessAudit(event = "PALLET_LOCATION_RESERVE", resourceType = "INBOUND", resourceId = "#request.businessId", description = "复用托盘主数据并预占库位", captureBefore = false)
public PalletAllocationDtos.BatchResult reserve(PalletAllocationDtos.ReserveRequest request) {
if (!Boolean.TRUE.equals(request.measurementCompleted())) {
throw new BizException(ResultCode.CONFLICT, "只能使用已完成测量的总体积拆分托盘并占用库位");
throw new BizException(ResultCode.CONFLICT, "只能为已确认的称重图片预占库位");
}
List<LocationDtos.ActiveResource> existing = active(request.tenantId(), request.sourceService(), request.businessId());
if (!existing.isEmpty()) {
locationResourceService.requireSameActiveReservations(request.tenantId(), request.warehouseId(),
request.sourceService(), request.businessId(), request.idempotencyKey(), request.pallets());
return fromActive(request.tenantId(), request.warehouseId(), existing, request.totalVolumeCbm());
}
@@ -69,12 +71,7 @@ public class PalletLocationAllocationService {
pallets.add(pallet);
}
List<LocationDtos.ReservationTarget> targets = new ArrayList<>(pallets.size());
for (int index = 0; index < pallets.size(); index++) {
PalletEntity pallet = pallets.get(index);
targets.add(new LocationDtos.ReservationTarget(pallet.getId(), pallet.getPalletCode(),
request.idempotencyKey() + ":" + request.pallets().get(index).weightResultId()));
}
List<LocationDtos.ReservationTarget> targets = reservationTargets(request, pallets);
List<LocationDtos.ReservationResult> reservations = locationResourceService.reserveAvailable(
new LocationDtos.ReserveCommand(request.tenantId(), request.warehouseId(), request.sourceService(),
@@ -523,6 +520,17 @@ public class PalletLocationAllocationService {
return new ArrayList<>(locationResourceService.findActiveResources(tenantId, sourceService, businessId));
}
private static List<LocationDtos.ReservationTarget> reservationTargets(
PalletAllocationDtos.ReserveRequest request, List<PalletEntity> pallets) {
List<LocationDtos.ReservationTarget> targets = new ArrayList<>(pallets.size());
for (int index = 0; index < pallets.size(); index++) {
PalletEntity pallet = pallets.get(index);
targets.add(new LocationDtos.ReservationTarget(pallet.getId(), pallet.getPalletCode(),
request.idempotencyKey() + ":" + request.pallets().get(index).weightResultId()));
}
return targets;
}
public List<LocationDtos.OccupiedResource> occupiedResources(long tenantId, String sourceService,
long businessId) {
return locationResourceService.findOccupiedResources(tenantId, sourceService, businessId);
@@ -2,11 +2,13 @@
SET NAMES utf8mb4;
USE baiyue_warehouse;
-- 编码由平台维护接口自动生成;两个国内作业仓各初始化一条可复用托盘主数据。
-- 两个国内作业仓各初始化木托盘、塑料托盘各一条,共四条可复用托盘主数据。
INSERT INTO wh_pallet(id,tenant_id,warehouse_id,pallet_code,pallet_type,capacity_cbm,weight_kg,color,remark,created_by,updated_by)
VALUES
(190000000000000101,0,190000000000000001,'TP-001','木托盘',1.200000,8.000,'黄色',NULL,0,0),
(190000000000000102,0,190000000000000002,'TP-002','胶托盘',2.400000,12.000,'黑色',NULL,0,0)
(190000000000000102,0,190000000000000002,'TP-002','塑料托盘',2.400000,12.000,'黑色',NULL,0,0),
(190000000000000103,0,190000000000000001,'YW-TP-002','塑料托盘',2.400000,12.000,'黑色',NULL,0,0),
(190000000000000104,0,190000000000000002,'DG-TP-002','木托盘',1.200000,8.000,'黄色',NULL,0,0)
ON DUPLICATE KEY UPDATE
tenant_id=0,
warehouse_id=VALUES(warehouse_id),
@@ -16,6 +18,31 @@ ON DUPLICATE KEY UPDATE
color=VALUES(color),
remark=VALUES(remark);
DROP PROCEDURE IF EXISTS assert_domestic_pallet_seed;
DELIMITER //
CREATE PROCEDURE assert_domestic_pallet_seed()
BEGIN
IF EXISTS (
SELECT expected.warehouse_id
FROM (
SELECT 190000000000000001 AS warehouse_id
UNION ALL SELECT 190000000000000002
) expected
LEFT JOIN wh_pallet pallet
ON pallet.tenant_id=0
AND pallet.warehouse_id=expected.warehouse_id
AND pallet.pallet_type IN ('木托盘','塑料托盘')
AND pallet.deleted=0
GROUP BY expected.warehouse_id
HAVING COUNT(DISTINCT pallet.pallet_type)<>2
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='国内作业仓木托盘、塑料托盘初始化不完整';
END IF;
END//
DELIMITER ;
CALL assert_domestic_pallet_seed();
DROP PROCEDURE IF EXISTS assert_domestic_pallet_seed;
-- 义乌仓初始化 A-F 六个库区,每区 A1-A15 共 90 个普通库位。
-- A0 仍是系统共享溢出位;东莞仓继续保留最小 A1 + A0 基线。
-- 这里只创建公共资源,不初始化预占、占用或业务单据。
@@ -28,8 +28,13 @@ class InitialWarehouseResourceSeedSqlContractTest {
private static void assertSeed(String sql, String yiwuId, String dongguanId, String idPrefix) {
assertThat(sql)
.contains("0," + yiwuId + ",'TP-001'")
.contains("0," + dongguanId + ",'TP-002'")
.contains("0," + yiwuId + ",'TP-001','木托盘'")
.contains("0," + dongguanId + ",'TP-002','塑料托盘'")
.contains("0," + yiwuId + ",'YW-TP-002','塑料托盘'")
.contains("0," + dongguanId + ",'DG-TP-002','木托盘'")
.contains("COUNT(DISTINCT pallet.pallet_type)")
.contains("国内作业仓木托盘、塑料托盘初始化不完整")
.doesNotContain("'胶托盘'")
.contains("WHEN zones.zone_index = 0 AND slots.slot_number = 1 THEN 201")
.contains("ELSE 203 + zones.zone_index * 15 + slots.slot_number")
.contains("SELECT zone_index + 1 FROM zones WHERE zone_index < 5")
@@ -72,6 +72,43 @@ class LocationResourceServiceTest {
});
}
@Test
void preservesZeroPackageOccupiedResourceForImageBasedAllocation() {
LocationOccupancyEntity active = occupancy(201L, 101L, 9L, "A1", 501L);
active.setAllocatedPackageCount(0);
when(repository.findOccupanciesByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(List.of(active));
assertThat(service.findOccupiedResources(10001L, "FIRSTLEG", 301L)).singleElement()
.extracting(item -> item.allocatedPackageCount()).isEqualTo(0);
}
@Test
void rejectsReplayWhenConfirmedImageReferencesChanged() {
LocationReservationEntity reservation = reservation("RESERVED");
reservation.setIdempotencyKey("measure-001:7001");
when(repository.findReservationsByBusiness(10001L, "FIRSTLEG", 301L))
.thenReturn(List.of(reservation));
assertThatThrownBy(() -> service.requireSameActiveReservations(10001L, 88L, "FIRSTLEG", 301L,
"measure-001", List.of(new com.baiyue.warehouse.dto.PalletAllocationDtos.PalletReference(7002L, 501L))))
.isInstanceOf(com.baiyue.common.core.exception.BizException.class);
}
@Test
void allowsReplayWhenMultipleImagesReuseSamePalletMaster() {
LocationReservationEntity first = reservation("RESERVED");
first.setIdempotencyKey("measure-001:7001");
LocationReservationEntity second = reservation("RESERVED");
second.setId(102L);
second.setIdempotencyKey("measure-001:7002");
when(repository.findReservationsByBusiness(10001L, "FIRSTLEG", 301L))
.thenReturn(List.of(first, second));
service.requireSameActiveReservations(10001L, 88L, "FIRSTLEG", 301L, "measure-001", List.of(
new com.baiyue.warehouse.dto.PalletAllocationDtos.PalletReference(7001L, 501L),
new com.baiyue.warehouse.dto.PalletAllocationDtos.PalletReference(7002L, 501L)));
}
@Test
void shouldHandleCase01() {
when(repository.findWarehouse(10001L, 88L)).thenReturn(warehouse());
@@ -257,6 +294,22 @@ class LocationResourceServiceTest {
verify(repository, never()).releaseOccupancy(first, "PDA full pick");
}
@Test
void shouldReleaseZeroPackageLocationCreatedForConfirmedImage() {
LocationOccupancyEntity occupancy = occupancy(201L, 101L, 9L, "A1", 501L);
occupancy.setAllocatedPackageCount(0);
when(repository.lockOccupiedByBusiness(10001L, "FIRSTLEG", 301L)).thenReturn(List.of(occupancy));
when(repository.findOwned(10001L, 88L, 9L)).thenReturn(occupiedLocation(9L, "A1"));
when(repository.releaseOccupancy(occupancy, "PDA full pick")).thenReturn(1);
when(repository.changeStatus(10001L, 88L, 9L, "OCCUPIED", "AVAILABLE", 0, 7L)).thenReturn(1);
var result = service.releaseSelectedExactly(new com.baiyue.warehouse.dto.LocationDtos.ExactSelectiveReleaseCommand(
10001L, "FIRSTLEG", 301L, 7L, "PDA full pick", List.of(9L)));
assertThat(result).singleElement()
.extracting(item -> item.allocatedPackageCount()).isEqualTo(0);
}
@Test
void shouldReleasePublicResourcesInAllocationOrderWhenLocationsAreNotSpecified() {
LocationOccupancyEntity allocationThree = occupancy(203L, 103L, 11L, "A3", 503L);
@@ -122,6 +122,7 @@ class PalletLocationAllocationServiceTest {
LocationDtos.ActiveResource resource = new LocationDtos.ActiveResource(
101, null, 20, 30, "TP-001", 40, "A-01", false, "RESERVED");
when(locations.findActiveResources(10001, "FIRSTLEG", 88)).thenReturn(List.of(resource));
when(pallets.findOwnedIncludingDeleted(10001, 20, 30)).thenReturn(pallet(30, "TP-001"));
var result = service.reserve(reserve(88));
@@ -130,6 +131,26 @@ class PalletLocationAllocationServiceTest {
verify(pallets, never()).findOwned(anyLong(), anyLong(), anyLong());
}
@Test
void validatesConfirmedImageReferencesBeforeReplayingAllocation() {
LocationDtos.ActiveResource resource = new LocationDtos.ActiveResource(
101L, null, 20L, 30L, "TP-001", 40L, "A-01", false, "RESERVED");
when(locations.findActiveResources(10001L, "FIRSTLEG", 88L)).thenReturn(List.of(resource));
when(pallets.findOwnedIncludingDeleted(10001L, 20L, 30L)).thenReturn(pallet(30L, "TP-001"));
service.reserve(reserve(88L));
org.mockito.ArgumentCaptor<List<PalletAllocationDtos.PalletReference>> targets =
org.mockito.ArgumentCaptor.forClass(List.class);
verify(locations).requireSameActiveReservations(eq(10001L), eq(20L), eq("FIRSTLEG"), eq(88L),
eq("measure-001"), targets.capture());
assertThat(targets.getValue()).singleElement().satisfies(item -> {
assertThat(item.weightResultId()).isEqualTo(7001L);
assertThat(item.palletId()).isEqualTo(30L);
});
verify(locations, never()).reserveAvailable(any());
}
@Test
void confirmPersistsPackageCountOnEveryOccupancyTarget() {
LocationDtos.ActiveResource resource = new LocationDtos.ActiveResource(