From ac6cf9c9044b7bb6947c8cbc4e836ec318bf514b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 17 Aug 2026 12:31:24 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 32 ++++++++++- .../domain/ContainerSpecificationCatalog.java | 43 +++++++++++++++ .../mapper/ContainerOverviewMapper.java | 4 +- .../service/OceanBookingService.java | 3 +- .../firstleg/service/ContainerService.java | 12 +++-- .../MeasurementLocationOccupationService.java | 26 ++++++--- .../service/MeasurementReviewService.java | 7 ++- .../db/phase3-smart-loading-schema.sql | 2 +- .../main/resources/openapi/firstleg-v1.yaml | 22 ++++---- .../resources/openapi/ocean-bookings-v1.yaml | 6 +-- .../ContainerSpecificationCatalogTest.java | 24 +++++++++ .../service/OceanBookingServiceTest.java | 2 +- .../ContainerOverviewSqlContractTest.java | 10 ++++ .../service/ContainerServiceTest.java | 26 ++++++--- ...surementLocationOccupationServiceTest.java | 44 +++++++++++++++ .../service/MeasurementReviewServiceTest.java | 18 +++++++ .../InternalPalletLocationController.java | 3 +- .../service/LocationResourceService.java | 34 ++++++++++-- .../PalletLocationAllocationService.java | 22 +++++--- .../main/resources/db/phase2-pallet-seed.sql | 31 ++++++++++- ...lWarehouseResourceSeedSqlContractTest.java | 9 +++- .../service/LocationResourceServiceTest.java | 53 +++++++++++++++++++ .../PalletLocationAllocationServiceTest.java | 21 ++++++++ 23 files changed, 396 insertions(+), 58 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/domain/ContainerSpecificationCatalog.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/domain/ContainerSpecificationCatalogTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 373c05300..c672d6abd 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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 基线。 -- 不初始化预占、占用或任何业务单据。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/domain/ContainerSpecificationCatalog.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/ContainerSpecificationCatalog.java new file mode 100644 index 000000000..c5d101c7d --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/domain/ContainerSpecificationCatalog.java @@ -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 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) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerOverviewMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerOverviewMapper.java index 723f140e5..2614509e7 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerOverviewMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerOverviewMapper.java @@ -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 documents(@Param("id") long id); @Select("SELECT * FROM fl_container_transport_history WHERE container_booking_id=#{id} ORDER BY created_at,id") List 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 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 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 " diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java index 3e8b1c887..4013f8ecf 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java @@ -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 parties(long id, List 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 requirements(long id, List 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 requirements(long id, List 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("订舱状态无效"); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java index 7beb5d364..d99f79d09 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java @@ -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()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java index 2df133c29..a15743ea3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java @@ -101,13 +101,25 @@ public class MeasurementLocationOccupationService { private java.util.List confirmedPalletReferences( long tenantId, long inboundId) { - java.util.List 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 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, "至少需要一张已确认的称重图片才能提交测量"); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java index b5a66c5b3..a4c271877 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementReviewService.java @@ -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, - "审核通过的测量快照必须包含大于零的实重和体积"); + "审核通过的测量快照必须包含大于零的货物总重量和体积"); } } diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 866e9cb4d..f7972f2e6 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -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 '起运港编码快照', diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 2ae737ed2..4f874a9c8 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -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] diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index 915db7ace..4b8a59e91 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -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 diff --git a/firstleg/src/test/java/com/baiyue/firstleg/domain/ContainerSpecificationCatalogTest.java b/firstleg/src/test/java/com/baiyue/firstleg/domain/ContainerSpecificationCatalogTest.java new file mode 100644 index 000000000..a4848da93 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/domain/ContainerSpecificationCatalogTest.java @@ -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("容量和限重"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java index 5948814f4..d9655f66e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java @@ -309,7 +309,7 @@ class OceanBookingServiceTest { new PartyRequest("CONSIGNEE", "收货人", null, null, null), new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null)); List 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), diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java index a1c384ab4..afecb8b9e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/ContainerOverviewSqlContractTest.java @@ -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); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java index 00c67b6d6..e84b98a71 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -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() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java index 6f8adca8b..7e8d334d8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java @@ -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); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java index 3a35d68b1..8089bf324 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementReviewServiceTest.java @@ -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"); diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java index d280dac40..327a8813e 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/InternalPalletLocationController.java @@ -41,7 +41,8 @@ public class InternalPalletLocationController { } @PostMapping("/reservations") - @Operation(summary = "按托盘主数据拆分并预占库位") + @Operation(summary = "按已确认称重图片逐条预占库位", + description = "一张图片对应一条库位预占;托盘仅作为可重复引用的主数据,不按容量拆分或限制") public ApiResponse reserve(@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @Valid @RequestBody PalletAllocationDtos.ReserveRequest request) { verify(token); diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java index db4164965..3ef390192 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/LocationResourceService.java @@ -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 references) { + Map expected = references.stream().collect(Collectors.toMap( + item -> requestIdempotencyKey + ":" + item.weightResultId(), + PalletAllocationDtos.PalletReference::palletId, + (first, duplicate) -> { + throw new BizException(ResultCode.BAD_REQUEST, "同一张称重图片不能重复预占库位"); + })); + List active = repository.findReservationsByBusiness( + tenantId, sourceService, businessId).stream() + .filter(item -> "RESERVED".equals(item.getReservationStatus()) + || "CONFIRMED".equals(item.getReservationStatus())) + .toList(); + Map 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(); diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java index fc41096d5..f6f41045b 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PalletLocationAllocationService.java @@ -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 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 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 targets = reservationTargets(request, pallets); List 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 reservationTargets( + PalletAllocationDtos.ReserveRequest request, List pallets) { + List 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 occupiedResources(long tenantId, String sourceService, long businessId) { return locationResourceService.findOccupiedResources(tenantId, sourceService, businessId); diff --git a/warehouse/src/main/resources/db/phase2-pallet-seed.sql b/warehouse/src/main/resources/db/phase2-pallet-seed.sql index 39e25779b..82580ebb0 100644 --- a/warehouse/src/main/resources/db/phase2-pallet-seed.sql +++ b/warehouse/src/main/resources/db/phase2-pallet-seed.sql @@ -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 基线。 -- 这里只创建公共资源,不初始化预占、占用或业务单据。 diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/InitialWarehouseResourceSeedSqlContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/InitialWarehouseResourceSeedSqlContractTest.java index 741e233da..41f33aed0 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/InitialWarehouseResourceSeedSqlContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/InitialWarehouseResourceSeedSqlContractTest.java @@ -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") diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/LocationResourceServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/LocationResourceServiceTest.java index 0f14d6ce9..ecfe65978 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/LocationResourceServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/LocationResourceServiceTest.java @@ -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); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/PalletLocationAllocationServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/PalletLocationAllocationServiceTest.java index 490898b73..5b176e773 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/service/PalletLocationAllocationServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/PalletLocationAllocationServiceTest.java @@ -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> 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(