Test #1
@@ -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 "
|
||||
|
||||
+2
-1
@@ -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());
|
||||
|
||||
+19
-7
@@ -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
|
||||
|
||||
+24
@@ -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("容量和限重");
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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),
|
||||
|
||||
+10
@@ -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() {
|
||||
|
||||
+44
@@ -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");
|
||||
|
||||
+2
-1
@@ -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();
|
||||
|
||||
+15
-7
@@ -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 基线。
|
||||
-- 这里只创建公共资源,不初始化预占、占用或业务单据。
|
||||
|
||||
+7
-2
@@ -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);
|
||||
|
||||
+21
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user