feat(firstleg): link simulated containers to real bookings
This commit is contained in:
@@ -4768,6 +4768,8 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
|
||||
channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照',
|
||||
ocean_booking_id BIGINT UNSIGNED NULL COMMENT '来源订舱ID',
|
||||
ocean_booking_requirement_id BIGINT UNSIGNED NULL COMMENT '来源订舱箱型需求ID',
|
||||
customer_id BIGINT UNSIGNED NULL COMMENT '已关联真实订舱指定的客户范围',
|
||||
customer_name VARCHAR(128) NULL COMMENT '已关联真实订舱客户名称快照',
|
||||
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '装柜仓库ID;平台公共资源',
|
||||
warehouse_name VARCHAR(128) NOT NULL COMMENT '装柜仓库名称快照',
|
||||
bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号',
|
||||
|
||||
+11
-3
@@ -6,8 +6,10 @@ import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.Query;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingUnlinkRequest;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.service.ContainerService;
|
||||
import com.baiyue.firstleg.service.FirstlegScopeGuard;
|
||||
@@ -88,9 +90,15 @@ public class PlatformContainerController {
|
||||
@PostMapping @PreAuthorize("hasAuthority('platform:firstleg:container:create')")
|
||||
@BusinessAudit(event="CONTAINER_CREATE", resourceType="CONTAINER", description="新增货柜基础资料", captureBefore=false)
|
||||
public ApiResponse<ContainerBookingEntity> create(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody SaveRequest request) {
|
||||
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.create(user.userId(), user.username(), request));
|
||||
@Valid @RequestBody SimulationCreateRequest request) {
|
||||
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.createSimulation(user.userId(), user.username(), request));
|
||||
}
|
||||
@PostMapping("/{id}/ocean-booking-link") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
|
||||
@BusinessAudit(event="CONTAINER_OCEAN_BOOKING_LINK", resourceType="CONTAINER", resourceId="#id", description="关联真实订舱柜")
|
||||
public ApiResponse<ContainerBookingEntity> linkOceanBooking(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @Valid @RequestBody OceanBookingLinkRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.linkOceanBooking(id, user.userId(), user.username(), request)); }
|
||||
@DeleteMapping("/{id}/ocean-booking-link") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
|
||||
@BusinessAudit(event="CONTAINER_OCEAN_BOOKING_UNLINK", resourceType="CONTAINER", resourceId="#id", description="解除真实订舱柜关联")
|
||||
public ApiResponse<ContainerBookingEntity> unlinkOceanBooking(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @Valid @RequestBody OceanBookingUnlinkRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.unlinkOceanBooking(id, user.userId(), user.username(), request)); }
|
||||
@PutMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:container:edit')")
|
||||
@BusinessAudit(event="CONTAINER_UPDATE", resourceType="CONTAINER", resourceId="#id", description="完整编辑货柜基础资料")
|
||||
@Operation(summary="完整编辑货柜", description="仅待装柜或预装柜中时允许")
|
||||
|
||||
@@ -92,4 +92,18 @@ public final class ContainerDtos {
|
||||
this(containerNo, voyageNo, version, "运输信息更新");
|
||||
}
|
||||
}
|
||||
|
||||
@Schema(name = "ContainerSimulationCreateRequest", description = "新增内部模拟柜;内部柜号由服务端生成")
|
||||
public record SimulationCreateRequest(@NotBlank @Size(max = 64) String channelCode,
|
||||
@NotNull @Positive Long warehouseId, @NotBlank @Size(max = 32) String containerType,
|
||||
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm,
|
||||
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg) {}
|
||||
|
||||
@Schema(name = "ContainerOceanBookingLinkRequest", description = "将内部模拟柜关联到真实订舱箱型分配")
|
||||
public record OceanBookingLinkRequest(@NotNull @Positive Long oceanBookingId,
|
||||
@NotNull @Positive Long requirementId, @NotNull @PositiveOrZero Integer expectedVersion) {}
|
||||
|
||||
@Schema(name = "ContainerOceanBookingUnlinkRequest", description = "解除空模拟柜的真实订舱关联")
|
||||
public record OceanBookingUnlinkRequest(@NotNull @PositiveOrZero Integer expectedVersion,
|
||||
@NotBlank @Size(max = 500) String reason) {}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ public class ContainerBookingEntity {
|
||||
private String channelName;
|
||||
private Long oceanBookingId;
|
||||
private Long oceanBookingRequirementId;
|
||||
private Long customerId;
|
||||
private String customerName;
|
||||
private Long warehouseId;
|
||||
private String warehouseName;
|
||||
private String billOfLadingNo;
|
||||
|
||||
@@ -24,7 +24,7 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
|
||||
WHERE (#{query.containerNo} IS NULL OR b.container_no = #{query.containerNo})
|
||||
AND (#{query.cabinetNo} IS NULL OR b.cabinet_no = #{query.cabinetNo})
|
||||
AND (#{query.billOfLadingNo} IS NULL OR b.bill_of_lading_no = #{query.billOfLadingNo})
|
||||
AND (#{query.customerId} IS NULL OR EXISTS (
|
||||
AND (#{query.customerId} IS NULL OR b.customer_id = #{query.customerId} OR EXISTS (
|
||||
SELECT 1 FROM fl_ocean_booking_container_allocation customer_allocation
|
||||
JOIN fl_ocean_booking customer_booking ON customer_booking.id = customer_allocation.ocean_booking_id
|
||||
WHERE customer_allocation.container_booking_id = b.id
|
||||
@@ -98,6 +98,15 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
|
||||
""")
|
||||
int updateDraft(@Param("value") ContainerBookingEntity value, @Param("expectedVersion") int expectedVersion,
|
||||
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||
@Update("UPDATE fl_container_booking SET ocean_booking_id=#{value.oceanBookingId},ocean_booking_requirement_id=#{value.oceanBookingRequirementId},customer_id=#{value.customerId},customer_name=#{value.customerName},updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE id=#{value.id} AND version=#{expectedVersion} AND ocean_booking_id IS NULL")
|
||||
int updateLink(@Param("value") ContainerBookingEntity value, @Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||
@Update("UPDATE fl_container_booking SET ocean_booking_id=NULL,ocean_booking_requirement_id=NULL,customer_id=NULL,customer_name=NULL,updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE id=#{id} AND version=#{expectedVersion}")
|
||||
int clearLink(@Param("id") long id, @Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||
@Select("SELECT COUNT(*) FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id WHERE p.container_booking_id=#{id} AND i.deleted=0") int hasActiveLoadingItems(@Param("id") long id);
|
||||
@Select("SELECT COUNT(*) FROM fl_loading_pick_task t JOIN fl_loading_plan p ON p.id=t.loading_plan_id WHERE p.container_booking_id=#{id}") int hasPickTask(@Param("id") long id);
|
||||
@Select("SELECT COUNT(*) FROM fl_loading_evidence e JOIN fl_loading_plan_item i ON i.inbound_id=e.inbound_id AND i.account_id=e.tenant_id JOIN fl_loading_plan p ON p.id=i.loading_plan_id WHERE p.container_booking_id=#{id} AND i.deleted=0 AND e.status='ACTIVE'") int hasEvidence(@Param("id") long id);
|
||||
@Select("SELECT COUNT(*) FROM fl_container_document WHERE container_booking_id=#{id}") int hasDocuments(@Param("id") long id);
|
||||
@Select("SELECT COUNT(*) FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id WHERE p.container_booking_id=#{id} AND i.deleted=0 AND i.account_id<>#{customerId}") int hasMismatchedCustomerItems(@Param("id") long id, @Param("customerId") long customerId);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_container_booking SET container_no=#{containerNo}, voyage_no=#{voyageNo},
|
||||
|
||||
-3
@@ -65,8 +65,5 @@ public class PlatformOceanBookingController {
|
||||
@PostMapping("/{oceanBookingId}/change-fail") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:change')")
|
||||
@BusinessAudit(event="OCEAN_BOOKING_CHANGE_FAIL", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="标记变更船期失败")
|
||||
public ApiResponse<Detail> failChange(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.failChange(oceanBookingId, user.userId(), user.username(), key, request)); }
|
||||
@PostMapping("/{oceanBookingId}/allocations") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:allocate')")
|
||||
@BusinessAudit(event="OCEAN_BOOKING_ALLOCATE_CONTAINER", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="分配订舱柜次")
|
||||
public ApiResponse<Detail> allocate(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody AllocationRequest request) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.allocate(oceanBookingId, user.userId(), user.username(), key, request)); }
|
||||
private static void validateOptionalCustomer(AuthenticatedUser user, Long customerId) { FirstlegScopeGuard.platform(user); if (customerId != null) FirstlegScopeGuard.platform(user, customerId); }
|
||||
}
|
||||
|
||||
@@ -83,9 +83,6 @@ public final class OceanBookingDtos {
|
||||
LocalDateTime estimatedSailingAt,
|
||||
LocalDateTime estimatedArrivalAt,
|
||||
@NotBlank @Size(max = 1000) String reason) {}
|
||||
public record AllocationRequest(@NotNull @Positive Long requirementId,
|
||||
@NotNull @Positive Long containerBookingId,
|
||||
@NotNull @Min(0) Integer expectedVersion) {}
|
||||
public record Detail(OceanBookingEntity oceanBooking,
|
||||
List<OceanBookingPartyEntity> parties,
|
||||
List<OceanBookingContainerRequirementEntity> containerRequirements,
|
||||
|
||||
+1
-9
@@ -17,6 +17,7 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
|
||||
<script>
|
||||
SELECT * FROM fl_ocean_booking
|
||||
WHERE (#{query.customerId} IS NULL OR customer_id=#{query.customerId})
|
||||
AND status IN ('CONFIRMED','CHANGE_PENDING','RELEASED','CANCELLED')
|
||||
AND (#{query.status} IS NULL OR status=#{query.status})
|
||||
AND (#{query.keyword} IS NULL OR booking_no LIKE CONCAT('%',#{query.keyword},'%')
|
||||
OR customer_name LIKE CONCAT('%',#{query.keyword},'%')
|
||||
@@ -95,13 +96,4 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
|
||||
int confirmChange(@Param("oceanBookingId") long oceanBookingId, @Param("expectedVersion") int expectedVersion,
|
||||
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_ocean_booking SET version=version+1,updated_by=#{operatorId},updated_by_name=#{operatorName},
|
||||
updated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE id=#{oceanBookingId} AND version=#{expectedVersion}
|
||||
AND status IN ('SUBMITTED','CONFIRMED','CHANGE_PENDING')
|
||||
""")
|
||||
int touchForAllocation(@Param("oceanBookingId") long oceanBookingId,
|
||||
@Param("expectedVersion") int expectedVersion,
|
||||
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||
}
|
||||
|
||||
+1
-12
@@ -3,10 +3,6 @@ package com.baiyue.firstleg.oceanbooking.repository;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.mapper.ContainerBookingMapper;
|
||||
import com.baiyue.firstleg.mapper.LoadingPlanMapper;
|
||||
import com.baiyue.firstleg.entity.LoadingPlanEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ReleaseRequest;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.*;
|
||||
@@ -24,8 +20,6 @@ public class OceanBookingRepository {
|
||||
@Resource private OceanBookingContainerAllocationMapper allocations;
|
||||
@Resource private OceanBookingStatusHistoryMapper histories;
|
||||
@Resource private OceanBookingOperationMapper operations;
|
||||
@Resource private ContainerBookingMapper containerBookings;
|
||||
@Resource private LoadingPlanMapper loadingPlans;
|
||||
|
||||
public PageResult<OceanBookingEntity> page(Query query) {
|
||||
return PageResult.from(bookings.selectPage(new Page<>(query.page(), query.size()), query));
|
||||
@@ -53,9 +47,6 @@ public class OceanBookingRepository {
|
||||
public int confirmChange(long id, int version, long operatorId, String operatorName) {
|
||||
return bookings.confirmChange(id, version, operatorId, operatorName);
|
||||
}
|
||||
public int touchForAllocation(long id, int version, long operatorId, String operatorName) {
|
||||
return bookings.touchForAllocation(id, version, operatorId, operatorName);
|
||||
}
|
||||
public List<OceanBookingPartyEntity> parties(long id) { return parties.selectList(new LambdaQueryWrapper<OceanBookingPartyEntity>().eq(OceanBookingPartyEntity::getOceanBookingId, id).orderByAsc(OceanBookingPartyEntity::getId)); }
|
||||
public List<OceanBookingContainerRequirementEntity> requirements(long id) { return requirements.selectList(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerRequirementEntity::getSortNo)); }
|
||||
public List<OceanBookingContainerAllocationEntity> allocations(long id) { return allocations.selectList(new LambdaQueryWrapper<OceanBookingContainerAllocationEntity>().eq(OceanBookingContainerAllocationEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerAllocationEntity::getAllocatedAt)); }
|
||||
@@ -67,14 +58,12 @@ public class OceanBookingRepository {
|
||||
public void replaceParties(long id, List<OceanBookingPartyEntity> values) { parties.delete(new LambdaQueryWrapper<OceanBookingPartyEntity>().eq(OceanBookingPartyEntity::getOceanBookingId, id)); values.forEach(parties::insert); }
|
||||
public void replaceRequirements(long id, List<OceanBookingContainerRequirementEntity> values) { requirements.delete(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id)); values.forEach(requirements::insert); }
|
||||
public OceanBookingContainerRequirementEntity requirement(long id, long requirementId) { return requirements.selectOne(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id).eq(OceanBookingContainerRequirementEntity::getId, requirementId)); }
|
||||
public ContainerBookingEntity containerBooking(long id) { return containerBookings.selectById(id); }
|
||||
public void insert(ContainerBookingEntity value) { containerBookings.insert(value); }
|
||||
public void insert(LoadingPlanEntity value) { loadingPlans.insert(value); }
|
||||
public int syncReleasedTransport(long id, ReleaseRequest request, long operatorId, String operatorName) {
|
||||
return containerBookings.syncReleasedTransport(id, request.billOfLadingNo().trim(), request.vesselName().trim(),
|
||||
request.voyageNo().trim(), request.estimatedSailingAt(), operatorId, operatorName);
|
||||
}
|
||||
public void insert(OceanBookingContainerAllocationEntity value) { allocations.insert(value); }
|
||||
public void deleteAllocation(long containerBookingId) { allocations.delete(new LambdaQueryWrapper<OceanBookingContainerAllocationEntity>().eq(OceanBookingContainerAllocationEntity::getContainerBookingId, containerBookingId)); }
|
||||
public void insert(OceanBookingStatusHistoryEntity value) { histories.insert(value); }
|
||||
public boolean claimOperation(Long id, long operatorId, String action, String key, String hash) { return operations.claim(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId(), id, operatorId, action, key, hash) == 1; }
|
||||
public OceanBookingOperationEntity operation(long operatorId, String action, String key) { return operations.selectOne(new LambdaQueryWrapper<OceanBookingOperationEntity>().eq(OceanBookingOperationEntity::getOperatorId, operatorId).eq(OceanBookingOperationEntity::getOperation, action).eq(OceanBookingOperationEntity::getIdempotencyKey, key)); }
|
||||
|
||||
+6
-77
@@ -4,8 +4,6 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
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.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPlanEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.domain.OceanBookingStatus;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.*;
|
||||
@@ -17,7 +15,6 @@ import com.baiyue.firstleg.service.LoadingPickService;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
@@ -34,6 +31,9 @@ import java.util.Set;
|
||||
@Service
|
||||
public class OceanBookingService {
|
||||
private static final Set<String> PARTY_TYPES = Set.of("SHIPPER", "CONSIGNEE", "NOTIFY_PARTY");
|
||||
private static final Set<OceanBookingStatus> OPERATIONAL_LIST_STATUSES = Set.of(
|
||||
OceanBookingStatus.CONFIRMED, OceanBookingStatus.CHANGE_PENDING,
|
||||
OceanBookingStatus.RELEASED, OceanBookingStatus.CANCELLED);
|
||||
@Resource private OceanBookingRepository repository;
|
||||
@Resource private PriceBookQueryRepository priceBooks;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@@ -41,7 +41,9 @@ public class OceanBookingService {
|
||||
|
||||
public PageResult<OceanBookingEntity> page(Query query) {
|
||||
String status = trim(query.status());
|
||||
if (status != null) parseStatus(status);
|
||||
if (status != null && !OPERATIONAL_LIST_STATUSES.contains(parseStatus(status))) {
|
||||
throw badRequest("订舱列表仅支持已确认、变更中、已放舱或已取消状态");
|
||||
}
|
||||
return repository.page(new Query(query.customerId(), status, trim(query.keyword()), query.page(), query.size()));
|
||||
}
|
||||
|
||||
@@ -116,7 +118,6 @@ public class OceanBookingService {
|
||||
if (repository.transition(id, OceanBookingStatus.SUBMITTED.name(), OceanBookingStatus.CONFIRMED.name(),
|
||||
null, null, request.expectedVersion(), operatorId, name) != 1) throw concurrent();
|
||||
OceanBookingEntity after = required(id);
|
||||
generateContainers(after, repository.requirements(id), operatorId, name);
|
||||
addHistory(id, before.getStatus(), after.getStatus(), action, request.reason(), before, after,
|
||||
operatorId, name);
|
||||
return complete(id, operatorId, action, key, detail(after));
|
||||
@@ -210,33 +211,6 @@ public class OceanBookingService {
|
||||
return complete(id, operatorId, action, key, detail(after));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Detail allocate(long id, long operatorId, String name, String key, AllocationRequest request) {
|
||||
String action = "ALLOCATE_CONTAINER";
|
||||
Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request));
|
||||
if (replay != null) return replay;
|
||||
OceanBookingEntity booking = requiredVersion(id, request.expectedVersion());
|
||||
if (!Set.of(OceanBookingStatus.SUBMITTED.name(), OceanBookingStatus.CONFIRMED.name(),
|
||||
OceanBookingStatus.CHANGE_PENDING.name()).contains(booking.getStatus())) {
|
||||
throw conflict("当前订舱状态不可分配柜次");
|
||||
}
|
||||
OceanBookingContainerRequirementEntity requirement = repository.requirement(id, request.requirementId());
|
||||
if (requirement == null) throw new BizException(ResultCode.NOT_FOUND, "箱型需求不存在");
|
||||
ContainerBookingEntity container = repository.containerBooking(request.containerBookingId());
|
||||
if (container == null) throw new BizException(ResultCode.NOT_FOUND, "柜次不存在");
|
||||
if (!requirement.getContainerType().equals(container.getContainerType())) throw conflict("柜次箱型与订舱需求不一致");
|
||||
requireSameChannel(booking, container);
|
||||
long used = repository.allocations(id).stream().filter(v -> request.requirementId().equals(v.getRequirementId())).count();
|
||||
if (used >= requirement.getQuantity()) throw conflict("该箱型需求已分配完毕");
|
||||
if (repository.touchForAllocation(id, request.expectedVersion(), operatorId, name) != 1) throw concurrent();
|
||||
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity();
|
||||
allocation.setId(IdWorker.getId()); allocation.setOceanBookingId(id);
|
||||
allocation.setRequirementId(request.requirementId()); allocation.setContainerBookingId(request.containerBookingId());
|
||||
allocation.setAllocatedBy(operatorId); allocation.setAllocatedByName(name); allocation.setAllocatedAt(LocalDateTime.now());
|
||||
try { repository.insert(allocation); } catch (DuplicateKeyException ex) { throw conflict("柜次已被其他订舱占用"); }
|
||||
return complete(id, operatorId, action, key, detail(id));
|
||||
}
|
||||
|
||||
private Detail transition(long id, long operatorId, String name, String key, String action,
|
||||
OceanBookingStatus from, OceanBookingStatus to, ActionRequest request,
|
||||
boolean failure) {
|
||||
@@ -348,44 +322,6 @@ public class OceanBookingService {
|
||||
v.setEstimatedSailingAt(null); v.setEstimatedArrivalAt(null); v.setRemark(trim(d.remark()));
|
||||
}
|
||||
|
||||
private void generateContainers(OceanBookingEntity booking,
|
||||
List<OceanBookingContainerRequirementEntity> requirements,
|
||||
long operatorId, String operatorName) {
|
||||
for (OceanBookingContainerRequirementEntity requirement : requirements) {
|
||||
for (int sequence = 1; sequence <= requirement.getQuantity(); sequence++) {
|
||||
ContainerBookingEntity container = new ContainerBookingEntity();
|
||||
container.setId(IdWorker.getId()); container.setBookingNo("CB" + container.getId());
|
||||
container.setCabinetNo("CAB-" + booking.getBookingNo() + '-' + requirement.getSortNo() + '-' + sequence);
|
||||
container.setContainerNo(null); container.setChannelCode(booking.getChannelCode());
|
||||
container.setChannelName(booking.getChannelName()); container.setOceanBookingId(booking.getId());
|
||||
container.setOceanBookingRequirementId(requirement.getId());
|
||||
container.setWarehouseId(booking.getWarehouseId()); container.setWarehouseName(booking.getWarehouseName());
|
||||
container.setContainerType(requirement.getContainerType());
|
||||
container.setCapacityCbm(requirement.getCapacityCbm());
|
||||
container.setMaxWeightKg(requirement.getMaxWeightKg());
|
||||
container.setShippingCompanyId(booking.getShippingCompanyId());
|
||||
container.setShippingCompanyName(booking.getShippingCompanyName());
|
||||
container.setDeparturePortCode(booking.getDeparturePortCode());
|
||||
container.setDeparturePortName(booking.getDeparturePortName());
|
||||
container.setDestinationPortCode(booking.getDestinationPortCode());
|
||||
container.setDestinationPortName(booking.getDestinationPortName());
|
||||
container.setCreatedBy(operatorId); container.setCreatedByName(operatorName);
|
||||
container.setUpdatedBy(operatorId); container.setUpdatedByName(operatorName); container.setVersion(0);
|
||||
repository.insert(container);
|
||||
LoadingPlanEntity plan = new LoadingPlanEntity();
|
||||
plan.setPlanNo("LP-" + container.getId()); plan.setContainerBookingId(container.getId());
|
||||
plan.setPlanStatus("EDITING"); plan.setCreatedBy(operatorId); plan.setCreatedByName(operatorName);
|
||||
plan.setUpdatedBy(operatorId); plan.setUpdatedByName(operatorName); plan.setVersion(0);
|
||||
repository.insert(plan);
|
||||
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity();
|
||||
allocation.setId(IdWorker.getId()); allocation.setOceanBookingId(booking.getId());
|
||||
allocation.setRequirementId(requirement.getId()); allocation.setContainerBookingId(container.getId());
|
||||
allocation.setAllocatedBy(operatorId); allocation.setAllocatedByName(operatorName);
|
||||
allocation.setAllocatedAt(LocalDateTime.now()); repository.insert(allocation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateConfirmation(OceanBookingEntity booking,
|
||||
List<OceanBookingContainerRequirementEntity> requirements) {
|
||||
if (!StringUtils.hasText(booking.getChannelCode()) || !StringUtils.hasText(booking.getChannelName())
|
||||
@@ -398,13 +334,6 @@ public class OceanBookingService {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static void requireSameChannel(OceanBookingEntity booking, ContainerBookingEntity container) {
|
||||
if (!StringUtils.hasText(booking.getChannelCode()) || !StringUtils.hasText(container.getChannelCode())) {
|
||||
throw conflict("订舱或柜次缺少渠道快照,请先完成存量数据回填");
|
||||
}
|
||||
if (!booking.getChannelCode().equals(container.getChannelCode())) throw conflict("柜次渠道与订舱渠道不一致");
|
||||
}
|
||||
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 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); } }
|
||||
|
||||
@@ -32,6 +32,13 @@ public class ContainerRepository {
|
||||
public int updateDraft(ContainerBookingEntity value, int version, long operatorId, String operatorName) {
|
||||
return mapper.updateDraft(value, version, operatorId, operatorName);
|
||||
}
|
||||
public int updateLink(ContainerBookingEntity value, int version, long operatorId, String operatorName) { return mapper.updateLink(value, version, operatorId, operatorName); }
|
||||
public int clearLink(long id, int version, long operatorId, String operatorName) { return mapper.clearLink(id, version, operatorId, operatorName); }
|
||||
public boolean hasActiveLoadingItems(long id) { return mapper.hasActiveLoadingItems(id) > 0; }
|
||||
public boolean hasPickTask(long id) { return mapper.hasPickTask(id) > 0; }
|
||||
public boolean hasEvidence(long id) { return mapper.hasEvidence(id) > 0; }
|
||||
public boolean hasDocuments(long id) { return mapper.hasDocuments(id) > 0; }
|
||||
public boolean hasMismatchedCustomerItems(long id, long customerId) { return mapper.hasMismatchedCustomerItems(id, customerId) > 0; }
|
||||
public int updateTransport(long id, String containerNo, String voyageNo, int version,
|
||||
long operatorId, String operatorName) {
|
||||
return mapper.updateTransport(id, containerNo, voyageNo, version, operatorId, operatorName);
|
||||
|
||||
@@ -11,6 +11,9 @@ import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.Query;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingUnlinkRequest;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPlanEntity;
|
||||
import com.baiyue.firstleg.entity.ContainerTransportHistoryEntity;
|
||||
@@ -18,6 +21,11 @@ import com.baiyue.firstleg.mapper.ContainerTransportHistoryMapper;
|
||||
import com.baiyue.firstleg.repository.ContainerRepository;
|
||||
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
||||
import com.baiyue.firstleg.repository.SmartLoadingRepository;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRepository;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
|
||||
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -34,6 +42,8 @@ public class ContainerService {
|
||||
@Resource private SmartLoadingRepository smartLoadingRepository;
|
||||
@Resource private LoadingPickRepository loadingPickRepository;
|
||||
@Resource private LoadingPickService loadingPickService;
|
||||
@Resource private PriceBookQueryRepository priceBooks;
|
||||
@Resource private OceanBookingRepository oceanBookings;
|
||||
@Autowired(required = false) private ContainerTransportHistoryMapper transportHistories;
|
||||
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
|
||||
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
|
||||
@@ -44,20 +54,70 @@ public class ContainerService {
|
||||
public ContainerBookingEntity detail(long id) { return required(id); }
|
||||
|
||||
@Transactional
|
||||
public ContainerBookingEntity create(long operatorId, String operatorName, SaveRequest request) {
|
||||
validateSchedule(request);
|
||||
public ContainerBookingEntity createSimulation(long operatorId, String operatorName, SimulationCreateRequest request) {
|
||||
WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.warehouseId());
|
||||
ensureUnique(trim(request.containerNo()), request.cabinetNo().trim(), null);
|
||||
ContainerBookingEntity value = new ContainerBookingEntity();
|
||||
value.setId(IdWorker.getId());
|
||||
value.setBookingNo("CB" + value.getId());
|
||||
copy(value, request);
|
||||
Long versionId = priceBooks.activeVersionId();
|
||||
if (versionId == null) throw conflict("当前没有 ACTIVE 价格版本");
|
||||
ChannelRow channel = priceBooks.channels(versionId, warehouse.id()).stream()
|
||||
.filter(row -> request.channelCode().trim().equals(row.getChannelCode())).findFirst()
|
||||
.orElseThrow(() -> conflict("所选物流渠道已失效,请重新选择"));
|
||||
ContainerBookingEntity value = new ContainerBookingEntity(); value.setId(IdWorker.getId());
|
||||
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.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
|
||||
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0);
|
||||
try { repository.insert(value); } catch (DuplicateKeyException ex) { throw duplicate(); }
|
||||
value.setPlanStatus(SmartLoadingStatus.WAIT_LOADING.name());
|
||||
return value;
|
||||
value.setContainerType(request.containerType().trim()); value.setCapacityCbm(request.capacityCbm());
|
||||
value.setMaxWeightKg(request.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;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ContainerBookingEntity linkOceanBooking(long id, long operatorId, String operatorName, OceanBookingLinkRequest request) {
|
||||
ContainerBookingEntity container = required(id); requireVersion(container, request.expectedVersion()); requireFirstLinkMutable(container);
|
||||
if (container.getOceanBookingId() != null) throw conflict("模拟柜已关联真实订舱柜");
|
||||
OceanBookingEntity booking = oceanBookings.find(request.oceanBookingId());
|
||||
if (booking == null || !("CONFIRMED".equals(booking.getStatus()) || "RELEASED".equals(booking.getStatus()))) throw conflict("只能关联已确认的真实订舱柜");
|
||||
OceanBookingContainerRequirementEntity requirement = oceanBookings.requirement(booking.getId(), request.requirementId());
|
||||
if (requirement == null) throw new BizException(ResultCode.NOT_FOUND, "真实订舱柜箱型分配不存在");
|
||||
if (!java.util.Objects.equals(container.getWarehouseId(), booking.getWarehouseId())
|
||||
|| !java.util.Objects.equals(container.getChannelCode(), booking.getChannelCode())
|
||||
|| !java.util.Objects.equals(container.getContainerType(), requirement.getContainerType())
|
||||
|| container.getCapacityCbm() == null || requirement.getCapacityCbm() == null
|
||||
|| container.getMaxWeightKg() == null || requirement.getMaxWeightKg() == null
|
||||
|| container.getCapacityCbm().compareTo(requirement.getCapacityCbm()) != 0
|
||||
|| container.getMaxWeightKg().compareTo(requirement.getMaxWeightKg()) != 0) throw conflict("模拟柜与真实订舱柜仓库、渠道、箱型或容量不一致");
|
||||
long allocated = oceanBookings.allocations(booking.getId()).stream()
|
||||
.filter(a -> request.requirementId().equals(a.getRequirementId()) && a.getContainerBookingId() != null).count();
|
||||
if (requirement.getQuantity() == null || requirement.getQuantity() <= 0 || allocated >= requirement.getQuantity()) {
|
||||
throw conflict("真实订舱柜箱型分配已关联完毕");
|
||||
}
|
||||
if (booking.getCustomerId() != null && repository.hasMismatchedCustomerItems(id, booking.getCustomerId())) throw conflict("模拟柜已排货客户与真实订舱客户范围不一致");
|
||||
container.setOceanBookingId(booking.getId()); container.setOceanBookingRequirementId(requirement.getId());
|
||||
container.setCustomerId(booking.getCustomerId()); container.setCustomerName(booking.getCustomerName());
|
||||
if (repository.updateLink(container, request.expectedVersion(), operatorId, operatorName) != 1) throw conflict("货柜状态或版本已变化,请刷新后重试");
|
||||
com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity allocation = new com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity();
|
||||
allocation.setId(IdWorker.getId()); allocation.setOceanBookingId(booking.getId()); allocation.setRequirementId(requirement.getId()); allocation.setContainerBookingId(id); allocation.setAllocatedBy(operatorId); allocation.setAllocatedByName(operatorName); allocation.setAllocatedAt(LocalDateTime.now()); oceanBookings.insert(allocation);
|
||||
return required(id);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ContainerBookingEntity unlinkOceanBooking(long id, long operatorId, String operatorName, OceanBookingUnlinkRequest request) {
|
||||
ContainerBookingEntity container = required(id); requireVersion(container, request.expectedVersion()); requireUnlinkMutable(container);
|
||||
if (container.getOceanBookingId() == null) throw conflict("模拟柜尚未关联真实订舱柜");
|
||||
oceanBookings.deleteAllocation(id);
|
||||
if (repository.clearLink(id, request.expectedVersion(), operatorId, operatorName) != 1) throw conflict("货柜状态或版本已变化,请刷新后重试"); return required(id);
|
||||
}
|
||||
|
||||
private void requireFirstLinkMutable(ContainerBookingEntity container) {
|
||||
SmartLoadingStatus value = status(container);
|
||||
if ((value != SmartLoadingStatus.WAIT_LOADING && value != SmartLoadingStatus.EDITING)
|
||||
|| repository.hasPickTask(container.getId()) || repository.hasEvidence(container.getId())
|
||||
|| repository.hasDocuments(container.getId())) throw conflict("已进入PDA、证据、单证或不可逆装柜阶段,不能关联订舱");
|
||||
}
|
||||
private void requireUnlinkMutable(ContainerBookingEntity container) {
|
||||
if (status(container) != SmartLoadingStatus.WAIT_LOADING || repository.hasActiveLoadingItems(container.getId())
|
||||
|| repository.hasPickTask(container.getId()) || repository.hasEvidence(container.getId())
|
||||
|| repository.hasDocuments(container.getId())) throw conflict("已有排货、PDA、证据或单证事实,不能解除订舱关联");
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
||||
@@ -47,7 +47,8 @@ public class SmartLoadingService {
|
||||
ContainerBookingEntity booking = requireBooking(bookingId);
|
||||
if (!Objects.equals(booking.getWarehouseId(), warehouseId)) throw validation("柜次仓库与候选仓库不一致");
|
||||
if (!StringUtils.hasText(booking.getChannelCode())) throw validation("柜次缺少渠道快照,请先完成存量数据回填");
|
||||
return candidates(warehouseId).stream().filter(candidate -> sameChannel(booking, candidate)).toList();
|
||||
return candidates(warehouseId).stream()
|
||||
.filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)).toList();
|
||||
}
|
||||
|
||||
public Plan draftByBooking(long bookingId) {
|
||||
@@ -100,6 +101,7 @@ public class SmartLoadingService {
|
||||
Candidate candidate = repository.candidate(booking.getWarehouseId(), request.inboundId(), request.inboundDetailId());
|
||||
if (candidate == null) throw conflict("入库货物不存在或尚未完成入库");
|
||||
requireSameChannel(booking, candidate);
|
||||
requireSameCustomerScope(booking, candidate);
|
||||
OrderCargoClient.CargoBinding cargo = loadCargo(candidate);
|
||||
LoadingPlanItemEntity existing = repository.activeItem(planId, candidate.accountId(), request.inboundDetailId());
|
||||
int total = request.packageCount() + (existing == null ? 0 : existing.getPackageCount());
|
||||
@@ -143,6 +145,7 @@ public class SmartLoadingService {
|
||||
for (Candidate candidate : candidates(booking.getWarehouseId())) {
|
||||
if (!Objects.equals(candidate.warehouseId(), booking.getWarehouseId())) continue;
|
||||
if (!sameChannel(booking, candidate)) continue;
|
||||
if (!sameCustomerScope(booking, candidate)) continue;
|
||||
if (StringUtils.hasText(booking.getDestinationWarehouseCode())
|
||||
&& !Objects.equals(booking.getDestinationWarehouseCode(), candidate.destinationWarehouseCode())) continue;
|
||||
int count = fittingBoxes(booking, repository.items(planId), candidate);
|
||||
@@ -359,12 +362,18 @@ public class SmartLoadingService {
|
||||
return StringUtils.hasText(booking.getChannelCode()) && StringUtils.hasText(candidate.channelCode())
|
||||
&& booking.getChannelCode().equals(candidate.channelCode());
|
||||
}
|
||||
private static boolean sameCustomerScope(ContainerBookingEntity booking, Candidate candidate) {
|
||||
return booking.getCustomerId() == null || Objects.equals(booking.getCustomerId(), candidate.accountId());
|
||||
}
|
||||
private static void requireSameChannel(ContainerBookingEntity booking, Candidate candidate) {
|
||||
if (!StringUtils.hasText(booking.getChannelCode()) || !StringUtils.hasText(candidate.channelCode())) {
|
||||
throw validation("柜次或入库货物缺少渠道快照,请先完成存量数据回填");
|
||||
}
|
||||
if (!sameChannel(booking, candidate)) throw validation("禁止跨渠道混装");
|
||||
}
|
||||
private static void requireSameCustomerScope(ContainerBookingEntity booking, Candidate candidate) {
|
||||
if (!sameCustomerScope(booking, candidate)) throw forbidden();
|
||||
}
|
||||
private void touch(LoadingPlanEntity p,long op,String name){if(repository.touch(p.getId(),p.getVersion(),op,name)!=1)throw conflict("计划已被并发修改");}
|
||||
private Plan replay(String operation, long planId, String action, Long itemId, String requestHash) {
|
||||
LoadingPlanHistoryEntity history = repository.operation(operation);
|
||||
|
||||
@@ -35,6 +35,8 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
|
||||
channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照',
|
||||
ocean_booking_id BIGINT UNSIGNED NULL COMMENT '来源订舱ID',
|
||||
ocean_booking_requirement_id BIGINT UNSIGNED NULL COMMENT '来源订舱箱型需求ID',
|
||||
customer_id BIGINT UNSIGNED NULL COMMENT '已关联真实订舱指定的客户范围',
|
||||
customer_name VARCHAR(128) NULL COMMENT '已关联真实订舱客户名称快照',
|
||||
warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '装柜仓库ID;平台公共资源',
|
||||
warehouse_name VARCHAR(128) NOT NULL COMMENT '装柜仓库名称快照',
|
||||
bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号',
|
||||
|
||||
@@ -238,16 +238,29 @@ paths:
|
||||
schema: {$ref: '#/components/schemas/ContainerBookingPageEnvelope'}
|
||||
'403': {description: 无货柜查询权限}
|
||||
post:
|
||||
summary: 新增平台货柜
|
||||
description: 需要 platform:firstleg:container:create 权限;新增后聚合状态为 WAIT_LOADING(待装柜)
|
||||
summary: 新增内部模拟柜
|
||||
description: 需要 platform:firstleg:container:create 权限;服务端生成稳定 containerBookingId/cabinetNo,不要求真实柜号或订舱
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ContainerSaveRequest'}
|
||||
schema: {$ref: '#/components/schemas/ContainerSimulationCreateRequest'}
|
||||
responses:
|
||||
'200': {description: 成功或重复号、参数校验等业务失败}
|
||||
'403': {description: 无新增按钮权限}
|
||||
/api/platform/v1/containers/{id}/ocean-booking-link:
|
||||
post:
|
||||
summary: 将内部模拟柜关联到真实已确认订舱柜
|
||||
description: 需要 platform:firstleg:container:edit;首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items;存在 PDA、有效装柜证据、业务单证或已进入 LOADING/COMPLETED 等不可逆事实时拒绝。
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingLinkRequest'}}}}
|
||||
responses: {'200': {description: 关联后的同一 containerBookingId/cabinetNo}}
|
||||
delete:
|
||||
summary: 解除内部模拟柜的真实订舱关联
|
||||
description: 需要 platform:firstleg:container:edit;解绑或换绑仅允许完全空的 WAIT_LOADING 模拟柜;任何排货、PDA、有效装柜证据或业务单证事实均拒绝。
|
||||
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingUnlinkRequest'}}}}
|
||||
responses: {'200': {description: 解除后的同一 containerBookingId/cabinetNo}}
|
||||
/api/platform/v1/containers/{id}:
|
||||
get:
|
||||
summary: 查询平台货柜详情
|
||||
@@ -1405,3 +1418,25 @@ components:
|
||||
assigneeId: {type: integer, format: int64}
|
||||
assigneeName: {type: string, maxLength: 64}
|
||||
reason: {type: string, maxLength: 500}
|
||||
ContainerSimulationCreateRequest:
|
||||
type: object
|
||||
required: [channelCode, warehouseId, containerType, capacityCbm, maxWeightKg]
|
||||
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}
|
||||
ContainerOceanBookingLinkRequest:
|
||||
type: object
|
||||
required: [oceanBookingId, requirementId, expectedVersion]
|
||||
properties:
|
||||
oceanBookingId: {type: integer, format: int64, minimum: 1}
|
||||
requirementId: {type: integer, format: int64, minimum: 1}
|
||||
expectedVersion: {type: integer, minimum: 0}
|
||||
ContainerOceanBookingUnlinkRequest:
|
||||
type: object
|
||||
required: [expectedVersion, reason]
|
||||
properties:
|
||||
expectedVersion: {type: integer, minimum: 0}
|
||||
reason: {type: string, minLength: 1, maxLength: 500}
|
||||
|
||||
@@ -9,6 +9,16 @@ paths:
|
||||
- {name: originWarehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
||||
responses: {'200': {description: Available channel options, content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/ChannelOption'}}}}}}
|
||||
/api/platform/v1/ocean-bookings:
|
||||
get:
|
||||
summary: List only real confirmed booking records
|
||||
description: 'Requires platform:firstleg:ocean-booking:list. The operational list exposes only CONFIRMED, CHANGE_PENDING, RELEASED and CANCELLED bookings; DRAFT, SUBMITTED and FAILED are excluded even when a status query is supplied.'
|
||||
parameters:
|
||||
- {name: customerId, in: query, schema: {type: integer, format: int64, minimum: 1}}
|
||||
- {name: status, in: query, schema: {type: string, enum: [CONFIRMED, CHANGE_PENDING, RELEASED, CANCELLED]}}
|
||||
- {name: keyword, in: query, schema: {type: string, maxLength: 128}}
|
||||
- {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}}
|
||||
- {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 20}}
|
||||
responses: {'200': {description: Operational real booking page}}
|
||||
post:
|
||||
summary: Create an ocean booking draft with immutable channel and warehouse snapshots
|
||||
parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
@@ -16,10 +26,10 @@ paths:
|
||||
responses: {'200': {description: Draft detail}}
|
||||
/api/platform/v1/ocean-bookings/{oceanBookingId}/confirm:
|
||||
post:
|
||||
summary: Confirm booking and atomically create one stable cabinet and editing loading plan per requested quantity
|
||||
summary: Confirm a real booked container record without creating internal cabinets
|
||||
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}}}
|
||||
responses: {'200': {description: CONFIRMED detail; no real container number required}}
|
||||
responses: {'200': {description: CONFIRMED real booking detail; internal cabinets are linked explicitly from the container API}}
|
||||
/api/platform/v1/ocean-bookings/{oceanBookingId}/release:
|
||||
post:
|
||||
summary: Confirm released transport data and synchronize associated cabinet transport snapshots
|
||||
|
||||
+6
@@ -21,6 +21,8 @@ class ContainerControllerContractTest {
|
||||
assertPermission("page", "platform:firstleg:container:list");
|
||||
assertPermission("detail", "platform:firstleg:container:list");
|
||||
assertPermission("create", "platform:firstleg:container:create");
|
||||
assertPermission("linkOceanBooking", "platform:firstleg:container:edit");
|
||||
assertPermission("unlinkOceanBooking", "platform:firstleg:container:edit");
|
||||
assertPermission("updateDraft", "platform:firstleg:container:edit");
|
||||
assertPermission("updateTransport", "platform:firstleg:container:edit");
|
||||
assertPermission("uploadClearance", "platform:firstleg:customs:manage");
|
||||
@@ -54,6 +56,10 @@ class ContainerControllerContractTest {
|
||||
|
||||
assertThat(section)
|
||||
.contains("/api/platform/v1/containers/{id}/transport:")
|
||||
.contains("/api/platform/v1/containers/{id}/ocean-booking-link:")
|
||||
.contains("首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 柜")
|
||||
.contains("解绑或换绑仅允许完全空的 WAIT_LOADING 模拟柜")
|
||||
.contains("任何排货、PDA、有效装柜证据或业务单证事实均拒绝")
|
||||
.contains("/api/platform/v1/containers/{id}/clearance/document/upload:")
|
||||
.contains("multipart/form-data")
|
||||
.contains("ContainerDocumentUploadRequest")
|
||||
|
||||
+3
-2
@@ -31,8 +31,7 @@ class PlatformOceanBookingControllerContractTest {
|
||||
Map.entry("cancel", "platform:firstleg:ocean-booking:cancel"),
|
||||
Map.entry("requestChange", "platform:firstleg:ocean-booking:change"),
|
||||
Map.entry("confirmChange", "platform:firstleg:ocean-booking:change"),
|
||||
Map.entry("failChange", "platform:firstleg:ocean-booking:change"),
|
||||
Map.entry("allocate", "platform:firstleg:ocean-booking:allocate"));
|
||||
Map.entry("failChange", "platform:firstleg:ocean-booking:change"));
|
||||
permissions.forEach((name, permission) -> {
|
||||
Method method = method(name);
|
||||
assertThat(method.getAnnotation(PreAuthorize.class).value())
|
||||
@@ -58,6 +57,8 @@ class PlatformOceanBookingControllerContractTest {
|
||||
assertThat(yaml).contains("/api/platform/v1/ocean-bookings/channels:",
|
||||
"originWarehouseId", "platform:firstleg:ocean-booking:list", "ChannelOption")
|
||||
.contains("required: [channelCode, warehouseId, warehouseName, serviceTerm, freightTerm, parties, containerRequirements]")
|
||||
.contains("CONFIRMED, CHANGE_PENDING, RELEASED, CANCELLED")
|
||||
.contains("DRAFT, SUBMITTED and FAILED are excluded")
|
||||
.contains("customerId: {type: integer, format: int64, minimum: 1, nullable: true")
|
||||
.contains("customerName: {type: string, maxLength: 128, nullable: true")
|
||||
.contains("serviceTerm: {type: string, maxLength: 32, description: Required free-text")
|
||||
|
||||
+13
-36
@@ -7,14 +7,13 @@ import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ContainerRequiremen
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.CreateRequest;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Detail;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.DraftData;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.AllocationRequest;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.PartyRequest;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ReleaseRequest;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.UpdateDraftRequest;
|
||||
import com.baiyue.firstleg.service.LoadingPickService;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingOperationEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity;
|
||||
@@ -144,42 +143,20 @@ class OceanBookingServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void allocatesContainerWithExplicitTimestampAndVersionGuard() {
|
||||
when(repository.find(1L)).thenReturn(booking("CONFIRMED", 5), booking("CONFIRMED", 6));
|
||||
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
|
||||
requirement.setId(11L); requirement.setOceanBookingId(1L); requirement.setContainerType("40HQ");
|
||||
requirement.setQuantity(1);
|
||||
ContainerBookingEntity container = new ContainerBookingEntity();
|
||||
container.setChannelCode("CHANNEL-A");
|
||||
container.setId(12L); container.setContainerType("40HQ");
|
||||
when(repository.requirement(1L, 11L)).thenReturn(requirement);
|
||||
when(repository.containerBooking(12L)).thenReturn(container);
|
||||
when(repository.touchForAllocation(1L, 5, 9L, "admin")).thenReturn(1);
|
||||
|
||||
Detail result = service.allocate(1L, 9L, "admin", "allocate-1",
|
||||
new AllocationRequest(11L, 12L, 5));
|
||||
|
||||
ArgumentCaptor<OceanBookingContainerAllocationEntity> allocation =
|
||||
ArgumentCaptor.forClass(OceanBookingContainerAllocationEntity.class);
|
||||
verify(repository).insert(allocation.capture());
|
||||
assertThat(allocation.getValue().getOceanBookingId()).isEqualTo(1L);
|
||||
assertThat(allocation.getValue().getRequirementId()).isEqualTo(11L);
|
||||
assertThat(allocation.getValue().getContainerBookingId()).isEqualTo(12L);
|
||||
assertThat(allocation.getValue().getAllocatedAt()).isNotNull();
|
||||
assertThat(result.oceanBooking().getVersion()).isEqualTo(6);
|
||||
void rejectsDraftAndSubmittedStatusesFromOperationalList() {
|
||||
for (String status : List.of("DRAFT", "SUBMITTED", "FAILED")) {
|
||||
assertThatThrownBy(() -> service.page(new Query(null, status, null, 1, 20)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("仅支持");
|
||||
}
|
||||
verify(repository, never()).page(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsAllocationAfterReleaseBeforeMutation() {
|
||||
when(repository.find(1L)).thenReturn(booking("RELEASED", 5));
|
||||
|
||||
assertThatThrownBy(() -> service.allocate(1L, 9L, "admin", "allocate-released",
|
||||
new AllocationRequest(11L, 12L, 5)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("当前订舱状态不可分配柜次");
|
||||
|
||||
verify(repository, never()).touchForAllocation(anyLong(), anyInt(), anyLong(), anyString());
|
||||
verify(repository, never()).insert(any(OceanBookingContainerAllocationEntity.class));
|
||||
void passesOnlyConfirmedLifecycleStatusesToOperationalList() {
|
||||
service.page(new Query(null, "CHANGE_PENDING", null, 1, 20));
|
||||
org.mockito.ArgumentCaptor<Query> query = org.mockito.ArgumentCaptor.forClass(Query.class);
|
||||
verify(repository).page(query.capture());
|
||||
assertThat(query.getValue().status()).isEqualTo("CHANGE_PENDING");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -5,6 +5,8 @@ import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPlanEntity;
|
||||
@@ -13,6 +15,11 @@ import com.baiyue.firstleg.mapper.ContainerTransportHistoryMapper;
|
||||
import com.baiyue.firstleg.repository.ContainerRepository;
|
||||
import com.baiyue.firstleg.repository.LoadingPickRepository;
|
||||
import com.baiyue.firstleg.repository.SmartLoadingRepository;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRepository;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
|
||||
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -31,6 +38,7 @@ import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@DisplayName("货柜基础资料服务")
|
||||
@@ -41,6 +49,8 @@ class ContainerServiceTest {
|
||||
@MockBean private SmartLoadingRepository smartLoadingRepository;
|
||||
@MockBean private LoadingPickRepository loadingPickRepository;
|
||||
@MockBean private LoadingPickService loadingPickService;
|
||||
@MockBean private PriceBookQueryRepository priceBooks;
|
||||
@MockBean private OceanBookingRepository oceanBookings;
|
||||
@MockBean private ContainerTransportHistoryMapper transportHistories;
|
||||
@Resource private ContainerService service;
|
||||
|
||||
@@ -51,19 +61,24 @@ class ContainerServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("新增货柜保存最终数据库契约字段并生成平台预订单号")
|
||||
void createsContainerBooking() {
|
||||
ContainerBookingEntity created = service.create(9L, "admin", request("柜-001"));
|
||||
@DisplayName("新增模拟柜从有效渠道冻结快照并生成稳定内部编号")
|
||||
void createsSimulationContainerBooking() {
|
||||
ChannelRow channel = channel();
|
||||
when(priceBooks.activeVersionId()).thenReturn(51L);
|
||||
when(priceBooks.channels(51L, 101L)).thenReturn(java.util.List.of(channel));
|
||||
ContainerBookingEntity created = service.createSimulation(9L, "admin", simulation());
|
||||
|
||||
ArgumentCaptor<ContainerBookingEntity> captor = ArgumentCaptor.forClass(ContainerBookingEntity.class);
|
||||
verify(repository).insert(captor.capture());
|
||||
ContainerBookingEntity saved = captor.getValue();
|
||||
assertThat(created).isSameAs(saved);
|
||||
assertThat(saved.getBookingNo()).startsWith("CB");
|
||||
assertThat(saved.getCabinetNo()).isEqualTo("柜-001");
|
||||
assertThat(saved.getCabinetNo()).startsWith("CAB-");
|
||||
assertThat(saved.getContainerNo()).isNull();
|
||||
assertThat(saved.getOceanBookingId()).isNull();
|
||||
assertThat(saved.getChannelName()).isEqualTo("美西海运");
|
||||
assertThat(saved.getWarehouseId()).isEqualTo(101L);
|
||||
assertThat(saved.getWarehouseName()).isEqualTo("上海中转仓");
|
||||
assertThat(saved.getDeparturePortCode()).isEqualTo("CNSHA");
|
||||
assertThat(saved.getCapacityCbm()).isEqualByComparingTo("68.0000");
|
||||
assertThat(saved.getPlanStatus()).isEqualTo("WAIT_LOADING");
|
||||
}
|
||||
@@ -210,25 +225,13 @@ class ContainerServiceTest {
|
||||
.hasMessageContaining("刷新后重试");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("新增时校验重复集装箱号和内部柜号")
|
||||
void rejectsDuplicateNumbers() {
|
||||
when(repository.containerNoExists("MSCU1234567", null)).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.create(9L, "admin", request("柜-001")))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("集装箱号已存在");
|
||||
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("虚拟柜拒绝选择非国内中转仓")
|
||||
void rejectsNonDomesticWarehouse() {
|
||||
when(warehouses.active(anyString(), anyLong(), anyLong()))
|
||||
.thenReturn(activeWarehouse(101L, "FBA", "美国目的仓"));
|
||||
|
||||
assertThatThrownBy(() -> service.create(9L, "admin", request("柜-001")))
|
||||
assertThatThrownBy(() -> service.createSimulation(9L, "admin", simulation()))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("国内中转仓");
|
||||
|
||||
@@ -238,7 +241,9 @@ class ContainerServiceTest {
|
||||
@Test
|
||||
@DisplayName("虚拟柜仓库名称以仓库主数据为准")
|
||||
void replacesRequestedWarehouseName() {
|
||||
service.create(9L, "admin", request("柜-001"));
|
||||
when(priceBooks.activeVersionId()).thenReturn(51L);
|
||||
when(priceBooks.channels(51L, 101L)).thenReturn(java.util.List.of(channel()));
|
||||
service.createSimulation(9L, "admin", simulation());
|
||||
|
||||
ArgumentCaptor<ContainerBookingEntity> captor = ArgumentCaptor.forClass(ContainerBookingEntity.class);
|
||||
verify(repository).insert(captor.capture());
|
||||
@@ -251,13 +256,50 @@ class ContainerServiceTest {
|
||||
when(warehouses.active(anyString(), anyLong(), anyLong()))
|
||||
.thenThrow(new IllegalStateException("warehouse service unavailable"));
|
||||
|
||||
assertThatThrownBy(() -> service.create(9L, "admin", request("柜-001")))
|
||||
assertThatThrownBy(() -> service.createSimulation(9L, "admin", simulation()))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("暂时不可用");
|
||||
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@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"));
|
||||
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"));
|
||||
when(repository.find(11L)).thenReturn(container);
|
||||
when(oceanBookings.find(21L)).thenReturn(booking);
|
||||
when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
|
||||
when(oceanBookings.allocations(21L)).thenReturn(java.util.List.of());
|
||||
when(repository.updateLink(any(), org.mockito.ArgumentMatchers.eq(2), org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq("admin"))).thenReturn(1);
|
||||
|
||||
ContainerBookingEntity result = service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(21L, 31L, 2));
|
||||
|
||||
assertThat(result).isSameAs(container);
|
||||
assertThat(container.getCabinetNo()).isEqualTo("柜-001");
|
||||
assertThat(container.getOceanBookingId()).isEqualTo(21L);
|
||||
verify(repository, never()).hasActiveLoadingItems(11L);
|
||||
verify(oceanBookings).insert(any(com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("首次关联遇到有效装柜证据时拒绝")
|
||||
void rejectsLinkWhenEvidenceExists() {
|
||||
ContainerBookingEntity container = booking("EDITING", 2);
|
||||
when(repository.find(11L)).thenReturn(container);
|
||||
when(repository.hasEvidence(11L)).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(21L, 31L, 2)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("证据");
|
||||
verifyNoInteractions(oceanBookings);
|
||||
}
|
||||
|
||||
private static ApiResponse<WarehouseMasterDataClient.Detail> activeWarehouse(long warehouseId,
|
||||
String type,
|
||||
String name) {
|
||||
@@ -283,4 +325,12 @@ class ContainerServiceTest {
|
||||
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"));
|
||||
}
|
||||
|
||||
private static ChannelRow channel() {
|
||||
ChannelRow channel = new ChannelRow(); channel.setChannelCode("SEA-US"); channel.setChannelName("美西海运"); return channel;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,22 @@ class SmartLoadingServiceTest {
|
||||
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsManualAssignmentOutsideLinkedBookingCustomerScope() {
|
||||
LoadingPlanEntity plan = plan("EDITING", 0);
|
||||
ContainerBookingEntity linked = booking();
|
||||
linked.setCustomerId(88L);
|
||||
when(repository.plan(1L)).thenReturn(plan);
|
||||
when(repository.booking(99L)).thenReturn(linked);
|
||||
when(repository.candidate(100L, 10L, 20L)).thenReturn(candidate(5));
|
||||
|
||||
BizException error = catchThrowableOfType(() -> service.assign(1L,
|
||||
new AssignRequest(10L, 20L, 1, 0, "assign-customer-mismatch"), 7L, "u"), BizException.class);
|
||||
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.FORBIDDEN);
|
||||
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void allowsVolumeAndWeightWithinOneHundredTenPercent() {
|
||||
stubAssignment();
|
||||
|
||||
Reference in New Issue
Block a user