Merge branch 'dev' into dev-lyhfive
# Conflicts: # deploy/sql/头程数据库统一增量-MySQL8.sql # deploy/sql/海外区域统一增量-MySQL8.sql # lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java # lastleg-service/src/main/resources/db/lastleg.sql # lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java
This commit is contained in:
@@ -18,7 +18,8 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
"com.baiyue.firstleg.outbound.dispatch.mapper",
|
||||
"com.baiyue.firstleg.portschedule.mapper",
|
||||
"com.baiyue.firstleg.customs.mapper",
|
||||
"com.baiyue.firstleg.customerstatistics.adapter.out.persistence"
|
||||
"com.baiyue.firstleg.customerstatistics.adapter.out.persistence",
|
||||
"com.baiyue.firstleg.pricebookquery.adapter.out.persistence"
|
||||
})
|
||||
@EnableFeignClients(basePackages = "com.baiyue.firstleg")
|
||||
@EnableScheduling
|
||||
|
||||
+15
@@ -128,6 +128,21 @@ public class PlatformContainerController {
|
||||
return ApiResponse.success(loadingLists.containerClearance(id));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/clearance/document/{fileId}/download")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||
@Operation(summary = "下载货柜目的港清关附件", description = "校验当前清关附件和 File 业务归属,完成态仍可下载。")
|
||||
public ResponseEntity<byte[]> downloadClearance(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id,
|
||||
@PathVariable @Positive long fileId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
var download = loadingLists.downloadContainerClearance(id, fileId);
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.attachment()
|
||||
.filename(download.fileName(), StandardCharsets.UTF_8).build().toString())
|
||||
.contentType(download.contentType()).contentLength(download.content().length)
|
||||
.body(download.content());
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/clearance/document")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||
@Operation(summary = "上传或替换货柜目的港清关资料")
|
||||
|
||||
+21
@@ -25,6 +25,10 @@ import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
@@ -40,6 +44,8 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/** 基于同一装柜计划聚合提供平台预装清单和已确认装柜清单。 */
|
||||
@Tag(name = "平台预装与装柜清单", description = "预装单向推进和已确认装柜清单只读查询")
|
||||
@Validated
|
||||
@@ -124,6 +130,21 @@ public class PlatformLoadingListController {
|
||||
return ApiResponse.success(service.loadingDeclaration(planId, warehouseId));
|
||||
}
|
||||
|
||||
@Operation(summary = "下载装柜清单出口报关附件", description = "完成态仍可下载;服务端校验装柜计划、仓库、当前附件及 File 业务归属。")
|
||||
@GetMapping("/loading-lists/{planId}/customs/document/{fileId}/download")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:loading-list:list')")
|
||||
public ResponseEntity<byte[]> downloadDeclaration(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId,
|
||||
@PathVariable @Positive long fileId, @RequestParam @Positive long warehouseId) {
|
||||
FirstlegScopeGuard.platform(user);
|
||||
var download = service.downloadLoadingDeclaration(planId, warehouseId, fileId);
|
||||
return ResponseEntity.ok().cacheControl(CacheControl.noStore())
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment()
|
||||
.filename(download.fileName(), StandardCharsets.UTF_8).build().toString())
|
||||
.contentType(download.contentType()).contentLength(download.content().length)
|
||||
.body(download.content());
|
||||
}
|
||||
|
||||
@Operation(summary = "上传或替换装柜清单出口报关文档")
|
||||
@PostMapping("/loading-lists/{planId}/customs/document")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:customs:manage')")
|
||||
|
||||
@@ -2,4 +2,10 @@ package com.baiyue.firstleg.customs.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.customs.entity.CustomsCaseEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
@Mapper public interface CustomsCaseMapper extends BaseMapper<CustomsCaseEntity> {}
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
@Mapper public interface CustomsCaseMapper extends BaseMapper<CustomsCaseEntity> {
|
||||
@Select("SELECT COUNT(*) FROM fl_container_document WHERE container_booking_id=#{containerBookingId} "
|
||||
+ "AND document_type='CUSTOMS_DECLARATION' AND completed_at IS NOT NULL")
|
||||
int completedLoadingDeclaration(@Param("containerBookingId") long containerBookingId);
|
||||
}
|
||||
|
||||
@@ -81,6 +81,9 @@ public class CustomsRepository {
|
||||
.eq(CustomsCaseEntity::getContainerBookingId, containerBookingId)
|
||||
.orderByDesc(CustomsCaseEntity::getUpdatedAt).last("LIMIT 1"));
|
||||
}
|
||||
public boolean loadingDeclarationCompleted(long containerBookingId) {
|
||||
return cases.completedLoadingDeclaration(containerBookingId) == 1;
|
||||
}
|
||||
public boolean containerCarriesOrder(long containerId, long tenantId, long orderId) {
|
||||
LoadingPlanEntity plan = loadingPlans.selectOne(new LambdaQueryWrapper<LoadingPlanEntity>()
|
||||
.eq(LoadingPlanEntity::getContainerBookingId, containerId));
|
||||
|
||||
+3
-6
@@ -1,6 +1,5 @@
|
||||
package com.baiyue.firstleg.customs.service;
|
||||
|
||||
import com.baiyue.firstleg.customs.entity.CustomsCaseEntity;
|
||||
import com.baiyue.firstleg.customs.repository.CustomsRepository;
|
||||
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.GateItem;
|
||||
import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor;
|
||||
@@ -18,13 +17,11 @@ public class CustomsDispatchGateContributor implements OutboundDispatchGateContr
|
||||
if (context.tenantId() <= 0 || context.customerId() <= 0) {
|
||||
return gate(false, "INVALID_CUSTOMER_SCOPE");
|
||||
}
|
||||
CustomsCaseEntity value = repository.gateCase(context.tenantId(), context.orderId(),
|
||||
context.inboundId(), context.containerBookingId());
|
||||
if (value == null) return gate(false, "MISSING");
|
||||
return gate("CLEARANCE_COMPLETED".equals(value.getStatus()), value.getStatus());
|
||||
boolean completed = repository.loadingDeclarationCompleted(context.containerBookingId());
|
||||
return gate(completed, completed ? "COMPLETED" : "MISSING");
|
||||
}
|
||||
|
||||
private static GateItem gate(boolean passed, String actual) {
|
||||
return new GateItem("CUSTOMS_CLEARANCE", "报关完成", passed, actual, "CLEARANCE_COMPLETED");
|
||||
return new GateItem("CUSTOMS_CLEARANCE", "报关完成", passed, actual, "COMPLETED");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,9 +41,15 @@ public final class InboundMeasurementRequests {
|
||||
@NotBlank @Size(max = 32) String itemNo,
|
||||
@NotBlank @Size(max = 64) String boxSpecification,
|
||||
@NotNull @Positive @Max(1000000) Integer packageCount,
|
||||
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal lengthCm,
|
||||
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal widthCm,
|
||||
@NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal heightCm,
|
||||
@NotNull @DecimalMin(value = "0.001", message = "箱规长度必须大于等于 0.001 cm")
|
||||
@DecimalMax(value = "999999999.999", message = "箱规长度不能超过 999,999,999.999 cm")
|
||||
@Digits(integer = 9, fraction = 3, message = "箱规长度最多 9 位整数、3 位小数") BigDecimal lengthCm,
|
||||
@NotNull @DecimalMin(value = "0.001", message = "箱规宽度必须大于等于 0.001 cm")
|
||||
@DecimalMax(value = "999999999.999", message = "箱规宽度不能超过 999,999,999.999 cm")
|
||||
@Digits(integer = 9, fraction = 3, message = "箱规宽度最多 9 位整数、3 位小数") BigDecimal widthCm,
|
||||
@NotNull @DecimalMin(value = "0.001", message = "箱规高度必须大于等于 0.001 cm")
|
||||
@DecimalMax(value = "999999999.999", message = "箱规高度不能超过 999,999,999.999 cm")
|
||||
@Digits(integer = 9, fraction = 3, message = "箱规高度最多 9 位整数、3 位小数") BigDecimal heightCm,
|
||||
@Positive Long intelligentImageFileId,
|
||||
@Size(max = 500) String overLimitWarning,
|
||||
@DecimalMin("0.000") @Digits(integer = 9, fraction = 3) BigDecimal outerLengthCm,
|
||||
|
||||
@@ -171,8 +171,6 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
|
||||
@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},
|
||||
updated_by=#{operatorId}, updated_by_name=#{operatorName},
|
||||
|
||||
@@ -4,7 +4,7 @@ import com.baiyue.firstleg.entity.ContainerTransportHistoryEntity;
|
||||
import org.apache.ibatis.annotations.*;
|
||||
import java.util.List;
|
||||
@Mapper public interface ContainerOverviewMapper {
|
||||
@Select("SELECT * FROM fl_container_document WHERE container_booking_id=#{id} AND document_type IN ('DELIVERY_ORDER','PICKUP_ORDER') ORDER BY document_type") List<ContainerDocumentEntity> documents(@Param("id") long id);
|
||||
@Select("SELECT * FROM fl_container_document WHERE container_booking_id=#{id} AND document_type IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION','DELIVERY_ORDER','PICKUP_ORDER') ORDER BY document_type") List<ContainerDocumentEntity> documents(@Param("id") long id);
|
||||
@Select("SELECT * FROM fl_container_transport_history WHERE container_booking_id=#{id} ORDER BY created_at,id") List<ContainerTransportHistoryEntity> transport(@Param("id") long id);
|
||||
@Select("SELECT id,action,reason,operator_id operatorId,operator_name operatorName,occurred_at occurredAt,request_hash requestHash FROM fl_container_exception_history WHERE container_booking_id=#{id} ORDER BY occurred_at,id") List<ExceptionRow> exceptions(@Param("id") long id);
|
||||
@Insert("INSERT INTO fl_container_exception_history(id,container_booking_id,action,reason,operator_id,operator_name,operation_id,request_hash,occurred_at) VALUES(#{id},#{bookingId},#{action},#{reason},#{operatorId},#{operatorName},#{operationId},#{requestHash},CURRENT_TIMESTAMP(3))") int insertException(@Param("id") long id,@Param("bookingId") long bookingId,@Param("action") String action,@Param("reason") String reason,@Param("operatorId") long operatorId,@Param("operatorName") String operatorName,@Param("operationId") String operationId,@Param("requestHash") String requestHash);
|
||||
|
||||
@@ -29,6 +29,14 @@ public interface LoadingCompletedEventMapper {
|
||||
FROM fl_loading_plan p
|
||||
JOIN fl_container_booking b ON b.id = p.container_booking_id
|
||||
JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0
|
||||
JOIN fl_loading_pick_item actual_pick
|
||||
ON actual_pick.loading_plan_item_id = i.id
|
||||
AND actual_pick.item_status IN ('PICKED','PARTIAL')
|
||||
AND actual_pick.picked_package_count > 0
|
||||
AND actual_pick.picked_package_count = i.package_count
|
||||
JOIN fl_loading_pick_task actual_task
|
||||
ON actual_task.id = actual_pick.pick_task_id
|
||||
AND actual_task.loading_plan_id = p.id
|
||||
JOIN fl_inbound_order o ON o.id = i.inbound_id AND o.tenant_id = i.account_id AND o.deleted = 0
|
||||
LEFT JOIN fl_inbound_order root_order ON root_order.id = COALESCE(o.root_inbound_id, o.id)
|
||||
WHERE p.id = #{planId} AND p.plan_status = 'COMPLETED'
|
||||
@@ -46,12 +54,20 @@ public interface LoadingCompletedEventMapper {
|
||||
i.product_name_zh, i.package_count, i.product_quantity,
|
||||
label.box_code
|
||||
FROM fl_loading_plan_item i
|
||||
JOIN fl_loading_pick_item actual_pick
|
||||
ON actual_pick.loading_plan_item_id = i.id
|
||||
AND actual_pick.item_status IN ('PICKED','PARTIAL')
|
||||
AND actual_pick.picked_package_count > 0
|
||||
AND actual_pick.picked_package_count = i.package_count
|
||||
JOIN fl_loading_pick_task actual_task
|
||||
ON actual_task.id = actual_pick.pick_task_id
|
||||
AND actual_task.loading_plan_id = #{planId}
|
||||
JOIN fl_inbound_pallet_allocation a
|
||||
ON a.tenant_id = i.account_id AND a.inbound_id = i.inbound_id
|
||||
AND a.allocation_status IN ('CONFIRMED','TRANSFERRED','RELEASED')
|
||||
AND a.picked_package_count > 0
|
||||
LEFT JOIN fl_inbound_carton_label label
|
||||
ON label.tenant_id = i.account_id AND label.inbound_id = i.inbound_id
|
||||
ON label.tenant_id = i.account_id
|
||||
AND label.inbound_detail_id = i.inbound_detail_id AND label.batch_status = 'ACTIVE'
|
||||
WHERE i.loading_plan_id = #{planId} AND i.deleted = 0
|
||||
ORDER BY i.account_id, i.inbound_id, a.allocation_no,
|
||||
|
||||
+5
@@ -27,6 +27,11 @@ public class PlatformOceanBookingController {
|
||||
|
||||
@GetMapping @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')")
|
||||
public ApiResponse<PageResult<OceanBookingEntity>> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.page(query)); }
|
||||
@GetMapping("/link-candidates") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')")
|
||||
public ApiResponse<PageResult<LinkCandidate>> linkCandidates(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @Valid LinkCandidateQuery query) {
|
||||
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.linkCandidates(query));
|
||||
}
|
||||
@GetMapping("/channels") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')")
|
||||
public ApiResponse<List<ChannelOption>> channels(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long originWarehouseId) {
|
||||
|
||||
@@ -26,6 +26,22 @@ public final class OceanBookingDtos {
|
||||
public Query { if (page == 0) page = 1; if (size == 0) size = 20; }
|
||||
}
|
||||
|
||||
public record LinkCandidateQuery(@NotNull @Positive Long warehouseId,
|
||||
@NotBlank @Size(max = 64) String channelCode,
|
||||
@NotBlank @Size(max = 32) String containerType,
|
||||
@Min(1) Long page,
|
||||
@Min(1) @Max(200) Long size) {
|
||||
public LinkCandidateQuery {
|
||||
if (page == null) page = 1L;
|
||||
if (size == null) size = 20L;
|
||||
}
|
||||
}
|
||||
|
||||
public record LinkCandidate(Long oceanBookingId, String bookingNo, Long requirementId,
|
||||
String containerType, BigDecimal capacityCbm, BigDecimal maxWeightKg,
|
||||
int requiredQuantity, long allocatedQuantity, long remainingQuantity,
|
||||
String customerName, String warehouseName, String channelName) {}
|
||||
|
||||
public record PartyRequest(@NotBlank @Size(max = 24) String partyType,
|
||||
@NotBlank @Size(max = 255) String partyName,
|
||||
@Size(max = 1000) String address,
|
||||
@@ -44,6 +60,7 @@ public final class OceanBookingDtos {
|
||||
@Size(max = 128) String billOfLadingNo,
|
||||
@Positive Long shippingCompanyId,
|
||||
@Size(max = 128) String shippingCompanyName,
|
||||
@Size(max = 16) String carrierCode,
|
||||
@Positive Long bookingAgentId,
|
||||
@Size(max = 128) String bookingAgentName,
|
||||
@Size(max = 128) String vesselName,
|
||||
|
||||
@@ -27,6 +27,7 @@ public class OceanBookingEntity {
|
||||
private String billOfLadingNo;
|
||||
private Long shippingCompanyId;
|
||||
private String shippingCompanyName;
|
||||
private String carrierCode;
|
||||
private Long bookingAgentId;
|
||||
private String bookingAgentName;
|
||||
private String vesselName;
|
||||
|
||||
+13
@@ -3,8 +3,21 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
@Mapper public interface OceanBookingContainerAllocationMapper extends BaseMapper<OceanBookingContainerAllocationEntity> {
|
||||
@Update("UPDATE fl_ocean_booking_container_allocation SET container_booking_id=#{targetId} WHERE container_booking_id=#{sourceId}")
|
||||
int reassign(@Param("sourceId") long sourceId, @Param("targetId") long targetId);
|
||||
|
||||
@Select("""
|
||||
SELECT COUNT(*) FROM fl_ocean_booking_container_allocation allocation
|
||||
JOIN fl_container_booking container ON container.id=allocation.container_booking_id
|
||||
AND container.record_status='ACTIVE'
|
||||
AND container.ocean_booking_id=allocation.ocean_booking_id
|
||||
AND container.ocean_booking_requirement_id=allocation.requirement_id
|
||||
WHERE allocation.ocean_booking_id=#{oceanBookingId}
|
||||
AND allocation.requirement_id=#{requirementId}
|
||||
""")
|
||||
long countActiveLinked(@Param("oceanBookingId") long oceanBookingId,
|
||||
@Param("requirementId") long requirementId);
|
||||
}
|
||||
|
||||
+36
-4
@@ -3,6 +3,8 @@ package com.baiyue.firstleg.oceanbooking.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidate;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidateQuery;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Delete;
|
||||
@@ -13,7 +15,7 @@ import org.apache.ibatis.annotations.Update;
|
||||
|
||||
@Mapper
|
||||
public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
|
||||
@Update("UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},shipping_company_name=#{value.shippingCompanyName},booking_agent_id=#{value.bookingAgentId},booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},place_of_delivery=#{value.placeOfDelivery},service_term=#{value.serviceTerm},freight_term=#{value.freightTerm},estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},remark=#{value.remark},updated_by=#{operatorId},updated_by_name=#{operatorName},version=version+1,updated_at=NOW() WHERE id=#{id} AND status='DRAFT' AND version=#{version}")
|
||||
@Update("UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},shipping_company_name=#{value.shippingCompanyName},carrier_code=#{value.carrierCode},booking_agent_id=#{value.bookingAgentId},booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},place_of_delivery=#{value.placeOfDelivery},service_term=#{value.serviceTerm},freight_term=#{value.freightTerm},estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},remark=#{value.remark},updated_by=#{operatorId},updated_by_name=#{operatorName},version=version+1,updated_at=NOW() WHERE id=#{id} AND status='DRAFT' AND version=#{version}")
|
||||
int updateDraft(@Param("id") long id, @Param("value") OceanBookingEntity value, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
|
||||
@Delete("DELETE FROM fl_ocean_booking WHERE id=#{id} AND status='DRAFT' AND version=#{version}")
|
||||
int deleteDraft(@Param("id") long id, @Param("version") int version);
|
||||
@@ -24,9 +26,9 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
|
||||
WHERE party.ocean_booking_id=booking.id AND party.party_type='SHIPPER' LIMIT 1) shipper_name,
|
||||
(SELECT party.party_name FROM fl_ocean_booking_party party
|
||||
WHERE party.ocean_booking_id=booking.id AND party.party_type='CONSIGNEE' LIMIT 1) consignee_name,
|
||||
(SELECT COUNT(*) FROM fl_ocean_booking_container_allocation allocation
|
||||
JOIN fl_container_booking container ON container.id=allocation.container_booking_id
|
||||
WHERE allocation.ocean_booking_id=booking.id AND container.record_status='ACTIVE') container_count
|
||||
COALESCE((SELECT SUM(requirement.quantity)
|
||||
FROM fl_ocean_booking_container_requirement requirement
|
||||
WHERE requirement.ocean_booking_id=booking.id),0) container_count
|
||||
FROM fl_ocean_booking booking
|
||||
WHERE (#{query.customerId} IS NULL OR booking.customer_id=#{query.customerId})
|
||||
AND booking.status IN ('DRAFT','RELEASED')
|
||||
@@ -41,6 +43,36 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
|
||||
""")
|
||||
Page<OceanBookingEntity> selectPage(Page<OceanBookingEntity> page, @Param("query") Query query);
|
||||
|
||||
@Select("""
|
||||
SELECT booking.id ocean_booking_id,booking.booking_no,requirement.id requirement_id,
|
||||
requirement.container_type,requirement.capacity_cbm,requirement.max_weight_kg,
|
||||
requirement.quantity required_quantity,
|
||||
COALESCE(allocated.allocated_quantity,0) allocated_quantity,
|
||||
requirement.quantity-COALESCE(allocated.allocated_quantity,0) remaining_quantity,
|
||||
booking.customer_name,booking.warehouse_name,booking.channel_name
|
||||
FROM fl_ocean_booking booking
|
||||
JOIN fl_ocean_booking_container_requirement requirement
|
||||
ON requirement.ocean_booking_id=booking.id
|
||||
LEFT JOIN (
|
||||
SELECT allocation.ocean_booking_id,allocation.requirement_id,COUNT(*) allocated_quantity
|
||||
FROM fl_ocean_booking_container_allocation allocation
|
||||
JOIN fl_container_booking container ON container.id=allocation.container_booking_id
|
||||
AND container.record_status='ACTIVE'
|
||||
AND container.ocean_booking_id=allocation.ocean_booking_id
|
||||
AND container.ocean_booking_requirement_id=allocation.requirement_id
|
||||
GROUP BY allocation.ocean_booking_id,allocation.requirement_id
|
||||
) allocated ON allocated.ocean_booking_id=booking.id
|
||||
AND allocated.requirement_id=requirement.id
|
||||
WHERE booking.status='RELEASED'
|
||||
AND booking.warehouse_id=#{query.warehouseId}
|
||||
AND booking.channel_code=#{query.channelCode}
|
||||
AND requirement.container_type=#{query.containerType}
|
||||
AND requirement.quantity>COALESCE(allocated.allocated_quantity,0)
|
||||
ORDER BY booking.updated_at DESC,booking.id DESC,requirement.sort_no ASC,requirement.id ASC
|
||||
""")
|
||||
Page<LinkCandidate> selectLinkCandidates(Page<LinkCandidate> page,
|
||||
@Param("query") LinkCandidateQuery query);
|
||||
|
||||
@Update("""
|
||||
UPDATE fl_ocean_booking SET status=#{toStatus},
|
||||
updated_by=#{operatorId},updated_by_name=#{operatorName},
|
||||
|
||||
+8
@@ -5,6 +5,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.RealContainer;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidate;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidateQuery;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.*;
|
||||
import com.baiyue.firstleg.oceanbooking.mapper.*;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
@@ -30,6 +32,9 @@ public class OceanBookingRepository {
|
||||
public PageResult<OceanBookingEntity> page(Query query) {
|
||||
return PageResult.from(bookings.selectPage(new Page<>(query.page(), query.size()), query));
|
||||
}
|
||||
public PageResult<LinkCandidate> linkCandidates(LinkCandidateQuery query) {
|
||||
return PageResult.from(bookings.selectLinkCandidates(new Page<>(query.page(), query.size()), query));
|
||||
}
|
||||
public OceanBookingEntity find(long id) { return bookings.selectById(id); }
|
||||
public void insert(OceanBookingEntity value) { bookings.insert(value); }
|
||||
public int transition(long id, String from, String to, int version, long operatorId, String operatorName) {
|
||||
@@ -44,6 +49,9 @@ public class OceanBookingRepository {
|
||||
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)); }
|
||||
public long activeLinkedCount(long oceanBookingId, long requirementId) {
|
||||
return allocations.countActiveLinked(oceanBookingId, requirementId);
|
||||
}
|
||||
public List<RealContainer> containers(long id) {
|
||||
Map<Long, OceanBookingContainerRequirementEntity> requirementById = requirements(id).stream()
|
||||
.collect(Collectors.toMap(OceanBookingContainerRequirementEntity::getId, Function.identity()));
|
||||
|
||||
+16
-4
@@ -11,9 +11,9 @@ 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;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRepository;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase;
|
||||
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
|
||||
import com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
@@ -36,10 +36,11 @@ 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<String> CARRIER_CODES = Set.of("MATSON", "SML", "ZIM", "COSCO", "EMC", "HEDE", "OTHER");
|
||||
private static final Set<OceanBookingStatus> OPERATIONAL_LIST_STATUSES = Set.of(
|
||||
OceanBookingStatus.DRAFT, OceanBookingStatus.RELEASED);
|
||||
@Resource private OceanBookingRepository repository;
|
||||
@Resource private PriceBookQueryRepository priceBooks;
|
||||
@Resource private PriceBookQueryUseCase priceBooks;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@Resource private WarehouseSupplierSnapshotService suppliers;
|
||||
@Resource private WarehouseMasterDataClient warehouses;
|
||||
@@ -55,6 +56,11 @@ public class OceanBookingService {
|
||||
return repository.page(new Query(query.customerId(), status, trim(query.keyword()), query.page(), query.size()));
|
||||
}
|
||||
|
||||
public PageResult<LinkCandidate> linkCandidates(LinkCandidateQuery query) {
|
||||
return repository.linkCandidates(new LinkCandidateQuery(query.warehouseId(),
|
||||
query.channelCode().trim(), query.containerType().trim(), query.page(), query.size()));
|
||||
}
|
||||
|
||||
public List<ChannelOption> channels(long originWarehouseId) {
|
||||
if (originWarehouseId <= 0) throw badRequest("请选择有效起运仓");
|
||||
Long versionId = priceBooks.activeVersionId();
|
||||
@@ -191,6 +197,10 @@ public class OceanBookingService {
|
||||
ContainerSpecificationCatalog.requireMatch(container.containerType(), container.capacityCbm(), container.maxWeightKg());
|
||||
}
|
||||
}
|
||||
if (StringUtils.hasText(data.carrierCode())
|
||||
&& !CARRIER_CODES.contains(data.carrierCode().trim().toUpperCase())) {
|
||||
throw badRequest("carrierCode仅支持MATSON、SML、ZIM、COSCO、EMC、HEDE或OTHER");
|
||||
}
|
||||
}
|
||||
private static void validateRange(LocalDateTime from, LocalDateTime to) { if (from != null && to != null && to.isBefore(from)) throw badRequest("预计到港时间不能早于预计开船时间"); }
|
||||
private static void validateRelease(OceanBookingEntity booking, List<OceanBookingPartyEntity> parties,
|
||||
@@ -201,6 +211,7 @@ public class OceanBookingService {
|
||||
|| !StringUtils.hasText(booking.getVoyageNo()) || !StringUtils.hasText(booking.getServiceTerm())
|
||||
|| !StringUtils.hasText(booking.getFreightTerm()) || booking.getEstimatedSailingAt() == null
|
||||
|| booking.getEstimatedArrivalAt() == null || booking.getShippingCompanyId() == null
|
||||
|| !StringUtils.hasText(booking.getCarrierCode())
|
||||
|| booking.getBookingAgentId() == null || parties == null || parties.size() != 3
|
||||
|| requirements == null || requirements.isEmpty()) throw badRequest("订舱资料尚未填写完整,无法发布");
|
||||
validateRange(booking.getEstimatedSailingAt(), booking.getEstimatedArrivalAt());
|
||||
@@ -247,6 +258,7 @@ public class OceanBookingService {
|
||||
v.setWarehouseId(d.warehouseId()); v.setWarehouseName(resolved.warehouseName());
|
||||
v.setBillOfLadingNo(trim(d.billOfLadingNo()));
|
||||
v.setShippingCompanyId(d.shippingCompanyId()); v.setShippingCompanyName(trim(d.shippingCompanyName()));
|
||||
v.setCarrierCode(d.carrierCode() == null ? null : d.carrierCode().trim().toUpperCase());
|
||||
v.setBookingAgentId(d.bookingAgentId()); v.setBookingAgentName(resolved.bookingAgentName());
|
||||
v.setVesselName(trim(d.vesselName())); v.setVoyageNo(trim(d.voyageNo()));
|
||||
v.setDeparturePortCode(trim(d.departurePortCode())); v.setDeparturePortName(trim(d.departurePortName()));
|
||||
|
||||
+18
-12
@@ -28,33 +28,39 @@ public class PlatformOutboundDispatchController {
|
||||
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.list(query));
|
||||
}
|
||||
|
||||
@GetMapping("/detail")
|
||||
@GetMapping("/{containerBookingId}")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:view')")
|
||||
public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) {
|
||||
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(query));
|
||||
public ApiResponse<Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @jakarta.validation.constraints.Positive long containerBookingId) {
|
||||
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.detail(containerBookingId));
|
||||
}
|
||||
|
||||
@GetMapping("/check-gate")
|
||||
@GetMapping("/{containerBookingId}/gate")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:check')")
|
||||
public ApiResponse<GateResult> checkGate(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) {
|
||||
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.checkGate(query));
|
||||
public ApiResponse<ContainerGateResult> checkGate(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @jakarta.validation.constraints.Positive long containerBookingId) {
|
||||
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.checkGate(containerBookingId));
|
||||
}
|
||||
|
||||
@PostMapping("/confirm-dispatch")
|
||||
@PostMapping("/{containerBookingId}/confirm")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:confirm')")
|
||||
@BusinessAudit(event="OUTBOUND_CONFIRM_DISPATCH",resourceType="INBOUND",resourceId="#request.inboundId",description="确认国内出仓")
|
||||
@BusinessAudit(event="OUTBOUND_CONFIRM_DISPATCH",resourceType="CONTAINER_BOOKING",resourceId="#containerBookingId",description="柜级确认国内出仓")
|
||||
public ApiResponse<Detail> confirm(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @jakarta.validation.constraints.Positive long containerBookingId,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody ActionRequest request) {
|
||||
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.confirm(user.userId(), user.username(), key, request));
|
||||
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.confirm(
|
||||
containerBookingId, user.userId(), user.username(), key, request));
|
||||
}
|
||||
|
||||
@PostMapping("/reverse-dispatch")
|
||||
@PostMapping("/{containerBookingId}/reverse")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:outbound-dispatch:reverse')")
|
||||
@BusinessAudit(event="OUTBOUND_REVERSE_DISPATCH",resourceType="INBOUND",resourceId="#request.inboundId",description="撤销国内出仓")
|
||||
@BusinessAudit(event="OUTBOUND_REVERSE_DISPATCH",resourceType="CONTAINER_BOOKING",resourceId="#containerBookingId",description="柜级撤销国内出仓")
|
||||
public ApiResponse<Detail> reverse(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable @jakarta.validation.constraints.Positive long containerBookingId,
|
||||
@RequestHeader("Idempotency-Key") String key,
|
||||
@Valid @RequestBody ActionRequest request) {
|
||||
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.reverse(user.userId(), user.username(), key, request));
|
||||
FirstlegScopeGuard.platform(user); return ApiResponse.success(service.reverse(
|
||||
containerBookingId, user.userId(), user.username(), key, request));
|
||||
}
|
||||
}
|
||||
|
||||
+15
-8
@@ -2,6 +2,7 @@ package com.baiyue.firstleg.outbound.dispatch.dto;
|
||||
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
@@ -10,21 +11,27 @@ import java.util.List;
|
||||
public final class OutboundDispatchDtos {
|
||||
private OutboundDispatchDtos() {}
|
||||
|
||||
public record Query(@Positive Long orderId, @Positive Long inboundId, @Positive Long containerBookingId) {}
|
||||
public record Query(@NotNull @Positive Long containerBookingId) {}
|
||||
|
||||
public record ActionRequest(@Positive Long inboundId, @Positive Long containerBookingId,
|
||||
@PositiveOrZero int expectedVersion, @Size(max = 500) String remark) {}
|
||||
public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,
|
||||
@Size(max = 500) String remark) {}
|
||||
|
||||
public record GateItem(String code, String name, boolean passed, String actual, String requirement) {}
|
||||
public record GateResult(boolean passed, List<GateItem> items, LocalDateTime checkedAt) {}
|
||||
|
||||
public record Aggregate(Long orderId, String orderNo, Long inboundId, String inboundCode, Long tenantId,
|
||||
Long customerId, String customerName, String inboundStatus,
|
||||
Long containerBookingId, String cabinetNo, String containerNo,
|
||||
public record InboundBinding(Long orderId, String orderNo, Long inboundId, String inboundCode,
|
||||
Long tenantId, Long customerId, String customerName,
|
||||
String inboundStatus, Long pickTaskId, String pickTaskStatus) {}
|
||||
|
||||
public record InboundGate(Long inboundId, String inboundCode, GateResult gate) {}
|
||||
public record ContainerGateResult(boolean passed, List<InboundGate> inbounds, LocalDateTime checkedAt) {}
|
||||
|
||||
public record Aggregate(Long containerBookingId, String cabinetNo, String containerNo,
|
||||
Long loadingPlanId, String loadingPlanStatus,
|
||||
Long pickTaskId, String pickTaskStatus, Long oceanBookingId, String oceanBookingStatus,
|
||||
Long oceanBookingId, String oceanBookingStatus,
|
||||
Long truckAppointmentId, String truckAppointmentStatus,
|
||||
String dispatchStatus, Integer version, GateResult gate) {}
|
||||
String dispatchStatus, Integer version, List<InboundBinding> inbounds,
|
||||
ContainerGateResult gate) {}
|
||||
|
||||
public record TimelineItem(String module, String action, String fromStatus, String toStatus,
|
||||
Long operatorId, String operatorName, String remark, LocalDateTime occurredAt) {}
|
||||
|
||||
+5
@@ -14,6 +14,11 @@ public interface OutboundDispatchMapper extends BaseMapper<OutboundDispatchEntit
|
||||
OutboundDispatchEntity find(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
|
||||
@Param("containerBookingId") long containerBookingId);
|
||||
|
||||
@Select("SELECT * FROM fl_outbound_dispatch WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} "
|
||||
+ "AND container_booking_id=#{containerBookingId} FOR UPDATE")
|
||||
OutboundDispatchEntity findForUpdate(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
|
||||
@Param("containerBookingId") long containerBookingId);
|
||||
|
||||
@Update("UPDATE fl_outbound_dispatch SET status='WAITING_FOR_LOADING',confirmed_by=#{operatorId},"
|
||||
+ "confirmed_by_name=#{operatorName},confirmed_at=CURRENT_TIMESTAMP(3),reversed_by=NULL,reversed_by_name=NULL,"
|
||||
+ "reversed_at=NULL,remark=#{remark},version=version+1,updated_at=CURRENT_TIMESTAMP(3) "
|
||||
|
||||
+36
-19
@@ -11,7 +11,6 @@ import java.util.List;
|
||||
@Mapper
|
||||
public interface OutboundDispatchReadMapper {
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT DISTINCT inbound.order_id orderId,inbound.order_no orderNo,inbound.id inboundId,
|
||||
inbound.inbound_code inboundCode,inbound.tenant_id tenantId,inbound.customer_id customerId,
|
||||
inbound.customer_name customerName,inbound.process_status inboundStatus,
|
||||
@@ -22,45 +21,62 @@ public interface OutboundDispatchReadMapper {
|
||||
JOIN fl_container_booking booking ON booking.id=plan.container_booking_id
|
||||
JOIN fl_inbound_order inbound ON inbound.id=item.inbound_id AND inbound.tenant_id=item.account_id AND inbound.deleted=0
|
||||
WHERE item.deleted=0
|
||||
<if test="orderId != null">AND inbound.order_id=#{orderId}</if>
|
||||
<if test="inboundId != null">AND inbound.id=#{inboundId}</if>
|
||||
<if test="containerBookingId != null">AND booking.id=#{containerBookingId}</if>
|
||||
AND booking.id=#{containerBookingId}
|
||||
ORDER BY inbound.id,booking.id
|
||||
</script>
|
||||
""")
|
||||
List<Binding> bindings(@Param("orderId") Long orderId, @Param("inboundId") Long inboundId,
|
||||
@Param("containerBookingId") Long containerBookingId);
|
||||
List<Binding> bindings(@Param("containerBookingId") long containerBookingId);
|
||||
|
||||
@Select("""
|
||||
SELECT inbound.order_id orderId,inbound.order_no orderNo,inbound.id inboundId,
|
||||
inbound.inbound_code inboundCode,inbound.tenant_id tenantId,inbound.customer_id customerId,
|
||||
inbound.customer_name customerName,inbound.process_status inboundStatus,
|
||||
booking.id containerBookingId,booking.cabinet_no cabinetNo,booking.container_no containerNo,
|
||||
plan.id loadingPlanId,plan.plan_status loadingPlanStatus
|
||||
FROM fl_loading_plan_item item
|
||||
JOIN fl_loading_plan plan ON plan.id=item.loading_plan_id
|
||||
JOIN fl_container_booking booking ON booking.id=plan.container_booking_id
|
||||
JOIN fl_inbound_order inbound ON inbound.id=item.inbound_id AND inbound.tenant_id=item.account_id AND inbound.deleted=0
|
||||
WHERE item.deleted=0 AND booking.id=#{containerBookingId}
|
||||
ORDER BY inbound.id,booking.id
|
||||
FOR UPDATE
|
||||
""")
|
||||
List<Binding> bindingsForUpdate(@Param("containerBookingId") long containerBookingId);
|
||||
|
||||
@Select("SELECT id,task_status status FROM fl_loading_pick_task WHERE loading_plan_id=#{planId} "
|
||||
+ "ORDER BY started_at DESC,id DESC LIMIT 1")
|
||||
IdStatus pick(@Param("planId") long planId);
|
||||
|
||||
@Select("""
|
||||
SELECT booking.id,booking.status
|
||||
SELECT booking.id,booking.status,booking.customer_id customerId
|
||||
FROM fl_ocean_booking_container_allocation allocation
|
||||
JOIN fl_ocean_booking booking ON booking.id=allocation.ocean_booking_id
|
||||
WHERE allocation.container_booking_id=#{containerBookingId} AND booking.customer_id=#{tenantId}
|
||||
WHERE allocation.container_booking_id=#{containerBookingId}
|
||||
ORDER BY booking.updated_at DESC,booking.id DESC LIMIT 1
|
||||
""")
|
||||
IdStatus ocean(@Param("containerBookingId") long containerBookingId, @Param("tenantId") long tenantId);
|
||||
OceanBinding ocean(@Param("containerBookingId") long containerBookingId);
|
||||
|
||||
@Select("""
|
||||
SELECT id,status FROM fl_truck_appointment
|
||||
WHERE container_booking_id=#{containerBookingId} AND loading_plan_id=#{loadingPlanId}
|
||||
AND customer_id=#{tenantId}
|
||||
ORDER BY updated_at DESC,id DESC LIMIT 1
|
||||
""")
|
||||
IdStatus truck(@Param("containerBookingId") long containerBookingId, @Param("loadingPlanId") long loadingPlanId,
|
||||
@Param("tenantId") long tenantId);
|
||||
IdStatus truck(@Param("containerBookingId") long containerBookingId, @Param("loadingPlanId") long loadingPlanId);
|
||||
|
||||
@Select("""
|
||||
SELECT COUNT(DISTINCT CASE WHEN status='ACTIVE' AND file_id IS NOT NULL AND evidence_type IN
|
||||
('PHOTO_STEP_1','PHOTO_STEP_2','PHOTO_STEP_3','PHOTO_STEP_4','SEAL') THEN evidence_type END) evidenceCount,
|
||||
MAX(CASE WHEN status='ACTIVE' AND evidence_type='SEAL' AND file_id IS NOT NULL AND seal_no IS NOT NULL
|
||||
AND seal_no<>'' THEN 1 ELSE 0 END) validSeal
|
||||
FROM fl_loading_evidence WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId}
|
||||
SELECT COUNT(DISTINCT CASE WHEN evidence.status='ACTIVE' AND evidence.file_id IS NOT NULL
|
||||
AND evidence.evidence_type IN ('PHOTO_STEP_1','PHOTO_STEP_2','PHOTO_STEP_3','PHOTO_STEP_4','SEAL')
|
||||
THEN evidence.evidence_type END) evidenceCount,
|
||||
MAX(CASE WHEN evidence.status='ACTIVE' AND evidence.evidence_type='SEAL'
|
||||
AND evidence.file_id IS NOT NULL AND evidence.seal_no IS NOT NULL
|
||||
AND evidence.seal_no<>'' THEN 1 ELSE 0 END) validSeal
|
||||
FROM fl_loading_plan plan
|
||||
JOIN fl_loading_plan_item item ON item.loading_plan_id=plan.id AND item.deleted=0
|
||||
JOIN fl_loading_evidence evidence ON evidence.inbound_id=item.inbound_id
|
||||
AND evidence.tenant_id=item.account_id
|
||||
WHERE plan.id=#{loadingPlanId} AND plan.container_booking_id=#{containerBookingId}
|
||||
""")
|
||||
Evidence evidence(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
|
||||
Evidence evidence(@Param("containerBookingId") long containerBookingId,
|
||||
@Param("loadingPlanId") long loadingPlanId);
|
||||
|
||||
@Select("""
|
||||
SELECT module,action,fromStatus,toStatus,operatorId,operatorName,remark,occurredAt FROM (
|
||||
@@ -122,5 +138,6 @@ public interface OutboundDispatchReadMapper {
|
||||
Long customerId, String customerName, String inboundStatus, Long containerBookingId,
|
||||
String cabinetNo, String containerNo, Long loadingPlanId, String loadingPlanStatus) {}
|
||||
record IdStatus(Long id, String status) {}
|
||||
record OceanBinding(Long id, String status, Long customerId) {}
|
||||
record Evidence(Integer evidenceCount, Integer validSeal) {}
|
||||
}
|
||||
|
||||
+11
-10
@@ -1,7 +1,6 @@
|
||||
package com.baiyue.firstleg.outbound.dispatch.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.Query;
|
||||
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.TimelineItem;
|
||||
import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchEntity;
|
||||
import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchHistoryEntity;
|
||||
@@ -22,22 +21,24 @@ public class OutboundDispatchRepository {
|
||||
@Resource private OutboundDispatchOperationMapper operations;
|
||||
@Resource private OutboundDispatchReadMapper reads;
|
||||
|
||||
public List<OutboundDispatchReadMapper.Binding> bindings(Query query) {
|
||||
return reads.bindings(query.orderId(), query.inboundId(), query.containerBookingId());
|
||||
public List<OutboundDispatchReadMapper.Binding> bindings(long containerId) { return reads.bindings(containerId); }
|
||||
public List<OutboundDispatchReadMapper.Binding> lockBindings(long containerId) {
|
||||
return reads.bindingsForUpdate(containerId);
|
||||
}
|
||||
public OutboundDispatchReadMapper.IdStatus pick(long planId) { return reads.pick(planId); }
|
||||
public OutboundDispatchReadMapper.IdStatus ocean(long containerId, long tenantId) {
|
||||
return reads.ocean(containerId, tenantId);
|
||||
public OutboundDispatchReadMapper.OceanBinding ocean(long containerId) { return reads.ocean(containerId); }
|
||||
public OutboundDispatchReadMapper.IdStatus truck(long containerId, long planId) {
|
||||
return reads.truck(containerId, planId);
|
||||
}
|
||||
public OutboundDispatchReadMapper.IdStatus truck(long containerId, long planId, long tenantId) {
|
||||
return reads.truck(containerId, planId, tenantId);
|
||||
}
|
||||
public OutboundDispatchReadMapper.Evidence evidence(long tenantId, long inboundId) {
|
||||
return reads.evidence(tenantId, inboundId);
|
||||
public OutboundDispatchReadMapper.Evidence evidence(long containerId, long loadingPlanId) {
|
||||
return reads.evidence(containerId, loadingPlanId);
|
||||
}
|
||||
public OutboundDispatchEntity dispatch(long tenantId, long inboundId, long containerId) {
|
||||
return dispatches.find(tenantId, inboundId, containerId);
|
||||
}
|
||||
public OutboundDispatchEntity dispatchLocked(long tenantId, long inboundId, long containerId) {
|
||||
return dispatches.findForUpdate(tenantId, inboundId, containerId);
|
||||
}
|
||||
public void insert(OutboundDispatchEntity value) { dispatches.insert(value); }
|
||||
public int confirm(long id, int version, long operatorId, String operatorName, String remark) {
|
||||
return dispatches.confirm(id, version, operatorId, operatorName, remark);
|
||||
|
||||
+195
-92
@@ -23,8 +23,7 @@ import java.util.*;
|
||||
|
||||
@Service
|
||||
public class OutboundDispatchService {
|
||||
private static final Set<String> REQUIRED_CONTRIBUTORS = Set.of("CUSTOMS_CLEARANCE", "SAILING_SCHEDULE");
|
||||
private static final Set<String> TRUCK_READY = Set.of("DEPARTED", "COMPLETED");
|
||||
private static final Set<String> REQUIRED_CONTRIBUTORS = Set.of("CUSTOMS_CLEARANCE");
|
||||
|
||||
private final OutboundDispatchRepository repository;
|
||||
private final Map<String, OutboundDispatchGateContributor> contributors;
|
||||
@@ -51,94 +50,134 @@ public class OutboundDispatchService {
|
||||
}
|
||||
|
||||
public List<Aggregate> list(Query query) {
|
||||
requireSelector(query);
|
||||
return repository.bindings(query).stream().map(this::aggregate).toList();
|
||||
return List.of(aggregate(bindings(query.containerBookingId())));
|
||||
}
|
||||
|
||||
public Detail detail(Query query) {
|
||||
OutboundDispatchReadMapper.Binding binding = single(query);
|
||||
Aggregate aggregate = aggregate(binding);
|
||||
return new Detail(aggregate, repository.timeline(binding, aggregate.oceanBookingId(), aggregate.truckAppointmentId()));
|
||||
public Detail detail(long containerBookingId) {
|
||||
List<OutboundDispatchReadMapper.Binding> bindings = bindings(containerBookingId);
|
||||
Aggregate aggregate = aggregate(bindings);
|
||||
List<TimelineItem> timeline = bindings.stream()
|
||||
.flatMap(binding -> repository.timeline(binding, aggregate.oceanBookingId(), aggregate.truckAppointmentId()).stream())
|
||||
.distinct()
|
||||
.sorted(Comparator.comparing(TimelineItem::occurredAt,
|
||||
Comparator.nullsLast(Comparator.naturalOrder())).thenComparing(TimelineItem::module))
|
||||
.toList();
|
||||
return new Detail(aggregate, timeline);
|
||||
}
|
||||
|
||||
public GateResult checkGate(Query query) { return aggregate(single(query)).gate(); }
|
||||
public ContainerGateResult checkGate(long containerBookingId) {
|
||||
return aggregate(bindings(containerBookingId)).gate();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Detail confirm(long operatorId, String operatorName, String key, ActionRequest request) {
|
||||
public Detail confirm(long containerBookingId, long operatorId, String operatorName,
|
||||
String key, ActionRequest request) {
|
||||
validateKey(key);
|
||||
Query query = new Query(null, request.inboundId(), request.containerBookingId());
|
||||
OutboundDispatchReadMapper.Binding binding = single(query);
|
||||
String hash = hash("CONFIRM", request);
|
||||
Detail replay = replay(operatorId, "CONFIRM_DISPATCH", key, hash, query);
|
||||
String hash = hash("CONFIRM", containerBookingId, request);
|
||||
Detail replay = replay(containerBookingId, operatorId, "CONFIRM_DISPATCH", key, hash);
|
||||
if (replay != null) return replay;
|
||||
|
||||
Aggregate before = aggregate(binding);
|
||||
if (!before.gate().passed()) throw conflict("国内出仓门禁未通过");
|
||||
OutboundDispatchEntity state = state(binding);
|
||||
if (!Objects.equals(state.getVersion(), request.expectedVersion())) throw concurrent();
|
||||
if (OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(state.getStatus())) {
|
||||
throw conflict("当前出仓单已确认派车");
|
||||
List<OutboundDispatchReadMapper.Binding> bindings = lockedBindings(containerBookingId);
|
||||
Aggregate before = aggregate(bindings);
|
||||
if (!before.gate().passed()) throw conflict("柜内全部入库绑定尚未通过国内出仓门禁");
|
||||
List<OutboundDispatchEntity> states = bindings.stream().map(this::stateLocked).toList();
|
||||
StateView visible = visibleState(states);
|
||||
if (!Objects.equals(visible.version(), request.expectedVersion())) throw concurrent();
|
||||
if (OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(visible.status())) {
|
||||
throw conflict("当前货柜已确认离仓");
|
||||
}
|
||||
if (repository.confirm(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1) {
|
||||
throw concurrent();
|
||||
for (OutboundDispatchEntity state : states) {
|
||||
if (repository.confirm(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1) {
|
||||
throw concurrent();
|
||||
}
|
||||
}
|
||||
history(state, "CONFIRM_DISPATCH", OutboundDispatchStatus.READY_FOR_DISPATCH.name(),
|
||||
OutboundDispatchEntity representative = states.get(0);
|
||||
history(representative, "CONFIRM_DISPATCH", OutboundDispatchStatus.READY_FOR_DISPATCH.name(),
|
||||
OutboundDispatchStatus.WAITING_FOR_LOADING.name(), request.remark(), operatorId, operatorName);
|
||||
|
||||
OutboundDispatchReadMapper.OceanBinding ocean = repository.ocean(containerBookingId);
|
||||
if (carrierSchedules != null) {
|
||||
carrierSchedules.ensureFromOutboundConfirm(binding.tenantId(), before.oceanBookingId(),
|
||||
binding.containerBookingId(), operatorId, operatorName);
|
||||
if (ocean == null || ocean.id() == null || ocean.customerId() == null) {
|
||||
throw conflict("当前货柜未关联有效订舱,无法创建船司查询任务");
|
||||
}
|
||||
carrierSchedules.ensureFromOutboundConfirm(ocean.customerId(), ocean.id(), containerBookingId,
|
||||
operatorId, operatorName);
|
||||
}
|
||||
if (repository.completeOperation(state.getId(), operatorId, "CONFIRM_DISPATCH", key) != 1) throw concurrent();
|
||||
return detail(query);
|
||||
if (repository.completeOperation(representative.getId(), operatorId, "CONFIRM_DISPATCH", key) != 1) {
|
||||
throw concurrent();
|
||||
}
|
||||
return detail(containerBookingId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Detail reverse(long operatorId, String operatorName, String key, ActionRequest request) {
|
||||
public Detail reverse(long containerBookingId, long operatorId, String operatorName,
|
||||
String key, ActionRequest request) {
|
||||
validateKey(key);
|
||||
Query query = new Query(null, request.inboundId(), request.containerBookingId());
|
||||
OutboundDispatchReadMapper.Binding binding = single(query);
|
||||
String hash = hash("REVERSE", request);
|
||||
Detail replay = replay(operatorId, "REVERSE_DISPATCH", key, hash, query);
|
||||
String hash = hash("REVERSE", containerBookingId, request);
|
||||
Detail replay = replay(containerBookingId, operatorId, "REVERSE_DISPATCH", key, hash);
|
||||
if (replay != null) return replay;
|
||||
|
||||
OutboundDispatchEntity state = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId());
|
||||
if (state == null || !OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(state.getStatus())) {
|
||||
throw conflict("当前出仓单不在等待装船状态");
|
||||
List<OutboundDispatchReadMapper.Binding> bindings = lockedBindings(containerBookingId);
|
||||
List<OutboundDispatchEntity> states = bindings.stream().map(this::existingStateLocked).toList();
|
||||
StateView visible = visibleState(states);
|
||||
if (!OutboundDispatchStatus.WAITING_FOR_LOADING.name().equals(visible.status())) {
|
||||
throw conflict("当前货柜不在已确认离仓状态");
|
||||
}
|
||||
if (!Objects.equals(state.getVersion(), request.expectedVersion())) throw concurrent();
|
||||
OutboundDispatchReadMapper.IdStatus ocean = repository.ocean(binding.containerBookingId(), binding.tenantId());
|
||||
if (carrierSchedules != null && ocean != null && carrierSchedules.hasOutboundDepartureFact(
|
||||
binding.tenantId(), ocean.id(), binding.containerBookingId())) {
|
||||
throw conflict("已产生实际DEPARTED_WAREHOUSE船节点,禁止撤销国内出仓");
|
||||
if (!Objects.equals(visible.version(), request.expectedVersion())) throw concurrent();
|
||||
OutboundDispatchReadMapper.OceanBinding ocean = repository.ocean(containerBookingId);
|
||||
if (carrierSchedules != null) {
|
||||
if (ocean == null || ocean.id() == null || ocean.customerId() == null) {
|
||||
throw conflict("无法核验货柜后续船节点,禁止撤销国内出仓");
|
||||
}
|
||||
if (carrierSchedules.hasOutboundDepartureFact(ocean.customerId(), ocean.id(), containerBookingId)) {
|
||||
throw conflict("已产生实际DEPARTED_WAREHOUSE船节点,禁止撤销国内出仓");
|
||||
}
|
||||
}
|
||||
if (repository.reverse(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1) {
|
||||
for (OutboundDispatchEntity state : states) {
|
||||
if (repository.reverse(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1) {
|
||||
throw concurrent();
|
||||
}
|
||||
}
|
||||
OutboundDispatchEntity representative = states.get(0);
|
||||
history(representative, "REVERSE_DISPATCH", OutboundDispatchStatus.WAITING_FOR_LOADING.name(),
|
||||
OutboundDispatchStatus.READY_FOR_DISPATCH.name(), request.remark(), operatorId, operatorName);
|
||||
if (repository.completeOperation(representative.getId(), operatorId, "REVERSE_DISPATCH", key) != 1) {
|
||||
throw concurrent();
|
||||
}
|
||||
history(state, "REVERSE_DISPATCH", OutboundDispatchStatus.WAITING_FOR_LOADING.name(),
|
||||
OutboundDispatchStatus.READY_FOR_DISPATCH.name(), request.remark(), operatorId, operatorName);
|
||||
if (repository.completeOperation(state.getId(), operatorId, "REVERSE_DISPATCH", key) != 1) throw concurrent();
|
||||
return detail(query);
|
||||
return detail(containerBookingId);
|
||||
}
|
||||
|
||||
private Aggregate aggregate(OutboundDispatchReadMapper.Binding binding) {
|
||||
var pick = repository.pick(binding.loadingPlanId());
|
||||
var ocean = repository.ocean(binding.containerBookingId(), binding.tenantId());
|
||||
var truck = repository.truck(binding.containerBookingId(), binding.loadingPlanId(), binding.tenantId());
|
||||
var evidence = repository.evidence(binding.tenantId(), binding.inboundId());
|
||||
var dispatch = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId());
|
||||
GateResult gate = gate(binding, pick, ocean, truck, evidence);
|
||||
return new Aggregate(binding.orderId(), binding.orderNo(), binding.inboundId(), binding.inboundCode(),
|
||||
binding.tenantId(), binding.customerId(), binding.customerName(), binding.inboundStatus(),
|
||||
binding.containerBookingId(), binding.cabinetNo(), binding.containerNo(),
|
||||
binding.loadingPlanId(), binding.loadingPlanStatus(),
|
||||
id(pick), status(pick), id(ocean), status(ocean), id(truck), status(truck),
|
||||
dispatch == null ? OutboundDispatchStatus.READY_FOR_DISPATCH.name() : dispatch.getStatus(),
|
||||
dispatch == null ? 0 : dispatch.getVersion(), gate);
|
||||
private Aggregate aggregate(List<OutboundDispatchReadMapper.Binding> bindings) {
|
||||
OutboundDispatchReadMapper.Binding first = validateContainer(bindings);
|
||||
var pick = repository.pick(first.loadingPlanId());
|
||||
var ocean = repository.ocean(first.containerBookingId());
|
||||
var truck = repository.truck(first.containerBookingId(), first.loadingPlanId());
|
||||
var evidence = repository.evidence(first.containerBookingId(), first.loadingPlanId());
|
||||
List<GateItem> containerContributorGates = contributorGates(first, ocean);
|
||||
List<InboundGate> inboundGates = new ArrayList<>();
|
||||
List<InboundBinding> inboundDtos = new ArrayList<>();
|
||||
List<OutboundDispatchEntity> states = new ArrayList<>();
|
||||
for (OutboundDispatchReadMapper.Binding binding : bindings) {
|
||||
GateResult gate = gate(binding, pick, ocean, evidence, containerContributorGates);
|
||||
inboundGates.add(new InboundGate(binding.inboundId(), binding.inboundCode(), gate));
|
||||
inboundDtos.add(new InboundBinding(binding.orderId(), binding.orderNo(), binding.inboundId(),
|
||||
binding.inboundCode(), binding.tenantId(), binding.customerId(), binding.customerName(),
|
||||
binding.inboundStatus(), id(pick), status(pick)));
|
||||
OutboundDispatchEntity state = repository.dispatch(binding.tenantId(), binding.inboundId(),
|
||||
binding.containerBookingId());
|
||||
if (state != null) states.add(state);
|
||||
}
|
||||
StateView visible = visibleStateForRead(states, bindings.size());
|
||||
ContainerGateResult containerGate = new ContainerGateResult(
|
||||
inboundGates.stream().allMatch(value -> value.gate().passed()), List.copyOf(inboundGates), LocalDateTime.now());
|
||||
return new Aggregate(first.containerBookingId(), first.cabinetNo(), first.containerNo(),
|
||||
first.loadingPlanId(), first.loadingPlanStatus(), id(ocean), status(ocean), id(truck), status(truck),
|
||||
visible.status(), visible.version(), List.copyOf(inboundDtos), containerGate);
|
||||
}
|
||||
|
||||
private GateResult gate(OutboundDispatchReadMapper.Binding binding, OutboundDispatchReadMapper.IdStatus pick,
|
||||
OutboundDispatchReadMapper.IdStatus ocean, OutboundDispatchReadMapper.IdStatus truck,
|
||||
OutboundDispatchReadMapper.Evidence evidence) {
|
||||
OutboundDispatchReadMapper.OceanBinding ocean, OutboundDispatchReadMapper.Evidence evidence,
|
||||
List<GateItem> containerContributorGates) {
|
||||
List<GateItem> items = new ArrayList<>();
|
||||
items.add(item("INBOUND_LOADED", "入库单装柜完成", "LOADED".equals(binding.inboundStatus()),
|
||||
binding.inboundStatus(), "LOADED"));
|
||||
@@ -148,14 +187,19 @@ public class OutboundDispatchService {
|
||||
status(pick), "COMPLETED"));
|
||||
items.add(item("OCEAN_BOOKING_RELEASED", "订舱已放舱且分配当前柜次",
|
||||
ocean != null && "RELEASED".equals(ocean.status()), status(ocean), "RELEASED"));
|
||||
items.add(item("TRUCK_DEPARTED", "关联拖车已离仓", truck != null && TRUCK_READY.contains(truck.status()),
|
||||
status(truck), "DEPARTED或COMPLETED"));
|
||||
boolean evidenceReady = evidence != null && evidence.evidenceCount() != null
|
||||
&& evidence.evidenceCount() >= 5 && Objects.equals(evidence.validSeal(), 1);
|
||||
items.add(item("LOADING_EVIDENCE_COMPLETE", "四步照片与铅封完整", evidenceReady,
|
||||
evidence == null ? null : evidence.evidenceCount() + "个证据/有效铅封=" + evidence.validSeal(),
|
||||
"PHOTO_STEP_1..4和含铅封号的SEAL"));
|
||||
|
||||
items.addAll(containerContributorGates);
|
||||
return new GateResult(items.stream().allMatch(GateItem::passed), List.copyOf(items), LocalDateTime.now());
|
||||
}
|
||||
|
||||
private List<GateItem> contributorGates(OutboundDispatchReadMapper.Binding binding,
|
||||
OutboundDispatchReadMapper.OceanBinding ocean) {
|
||||
List<GateItem> items = new ArrayList<>();
|
||||
var context = new OutboundDispatchGateContributor.Context(binding.tenantId(), binding.customerId(),
|
||||
binding.orderId(), binding.inboundId(), binding.containerBookingId(), binding.loadingPlanId(), id(ocean));
|
||||
for (String code : REQUIRED_CONTRIBUTORS.stream().sorted().toList()) {
|
||||
@@ -171,77 +215,136 @@ public class OutboundDispatchService {
|
||||
items.add(item(code, contributorName(code), false, "ASSESSMENT_ERROR", "模块返回可放行状态"));
|
||||
}
|
||||
}
|
||||
return new GateResult(items.stream().allMatch(GateItem::passed), List.copyOf(items), LocalDateTime.now());
|
||||
return List.copyOf(items);
|
||||
}
|
||||
|
||||
private Detail replay(long operatorId, String operation, String key, String hash, Query query) {
|
||||
private Detail replay(long containerBookingId, long operatorId, String operation, String key, String hash) {
|
||||
if (repository.claim(operatorId, operation, key, hash)) return null;
|
||||
var existing = repository.operation(operatorId, operation, key);
|
||||
if (existing == null || !Objects.equals(existing.getRequestHash(), hash)) {
|
||||
throw conflict("Idempotency-Key已用于不同请求");
|
||||
}
|
||||
if (!"SUCCESS".equals(existing.getStatus())) throw conflict("相同请求正在处理中");
|
||||
return detail(query);
|
||||
return detail(containerBookingId);
|
||||
}
|
||||
|
||||
private OutboundDispatchEntity state(OutboundDispatchReadMapper.Binding binding) {
|
||||
OutboundDispatchEntity state = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId());
|
||||
private OutboundDispatchEntity stateLocked(OutboundDispatchReadMapper.Binding binding) {
|
||||
OutboundDispatchEntity state = repository.dispatchLocked(binding.tenantId(), binding.inboundId(),
|
||||
binding.containerBookingId());
|
||||
if (state != null) return state;
|
||||
state = new OutboundDispatchEntity();
|
||||
state.setId(IdWorker.getId()); state.setTenantId(binding.tenantId()); state.setOrderId(binding.orderId());
|
||||
state.setInboundId(binding.inboundId()); state.setContainerBookingId(binding.containerBookingId());
|
||||
state.setStatus(OutboundDispatchStatus.READY_FOR_DISPATCH.name()); state.setVersion(0);
|
||||
try { repository.insert(state); }
|
||||
catch (DuplicateKeyException ex) {
|
||||
state = repository.dispatch(binding.tenantId(), binding.inboundId(), binding.containerBookingId());
|
||||
state.setId(IdWorker.getId());
|
||||
state.setTenantId(binding.tenantId());
|
||||
state.setOrderId(binding.orderId());
|
||||
state.setInboundId(binding.inboundId());
|
||||
state.setContainerBookingId(binding.containerBookingId());
|
||||
state.setStatus(OutboundDispatchStatus.READY_FOR_DISPATCH.name());
|
||||
state.setVersion(0);
|
||||
try {
|
||||
repository.insert(state);
|
||||
} catch (DuplicateKeyException ex) {
|
||||
state = repository.dispatchLocked(binding.tenantId(), binding.inboundId(), binding.containerBookingId());
|
||||
if (state == null) throw ex;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
private OutboundDispatchEntity existingStateLocked(OutboundDispatchReadMapper.Binding binding) {
|
||||
OutboundDispatchEntity state = repository.dispatchLocked(binding.tenantId(), binding.inboundId(),
|
||||
binding.containerBookingId());
|
||||
if (state == null) throw conflict("当前货柜尚未确认离仓");
|
||||
return state;
|
||||
}
|
||||
|
||||
private void history(OutboundDispatchEntity state, String action, String from, String to, String remark,
|
||||
long operatorId, String operatorName) {
|
||||
OutboundDispatchHistoryEntity history = new OutboundDispatchHistoryEntity();
|
||||
history.setId(IdWorker.getId()); history.setDispatchId(state.getId()); history.setTenantId(state.getTenantId());
|
||||
history.setInboundId(state.getInboundId()); history.setContainerBookingId(state.getContainerBookingId());
|
||||
history.setAction(action); history.setFromStatus(from); history.setToStatus(to); history.setRemark(remark);
|
||||
history.setOperatorId(operatorId); history.setOperatorName(operatorName); repository.history(history);
|
||||
history.setId(IdWorker.getId());
|
||||
history.setDispatchId(state.getId());
|
||||
history.setTenantId(state.getTenantId());
|
||||
history.setInboundId(state.getInboundId());
|
||||
history.setContainerBookingId(state.getContainerBookingId());
|
||||
history.setAction(action);
|
||||
history.setFromStatus(from);
|
||||
history.setToStatus(to);
|
||||
history.setRemark(remark);
|
||||
history.setOperatorId(operatorId);
|
||||
history.setOperatorName(operatorName);
|
||||
repository.history(history);
|
||||
}
|
||||
|
||||
private OutboundDispatchReadMapper.Binding single(Query query) {
|
||||
requireSelector(query);
|
||||
List<OutboundDispatchReadMapper.Binding> bindings = repository.bindings(query);
|
||||
if (bindings.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "未找到匹配的入库单与柜次关联");
|
||||
if (bindings.size() != 1) throw new BizException(ResultCode.BAD_REQUEST, "查询条件匹配多个出仓聚合,请同时指定inboundId和containerBookingId");
|
||||
return bindings.get(0);
|
||||
private List<OutboundDispatchReadMapper.Binding> bindings(long containerBookingId) {
|
||||
return normalize(repository.bindings(containerBookingId));
|
||||
}
|
||||
|
||||
private static void requireSelector(Query query) {
|
||||
if (query == null || query.orderId() == null && query.inboundId() == null && query.containerBookingId() == null) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "orderId、inboundId、containerBookingId至少提供一个");
|
||||
private List<OutboundDispatchReadMapper.Binding> lockedBindings(long containerBookingId) {
|
||||
return normalize(repository.lockBindings(containerBookingId));
|
||||
}
|
||||
|
||||
private static List<OutboundDispatchReadMapper.Binding> normalize(List<OutboundDispatchReadMapper.Binding> values) {
|
||||
if (values == null || values.isEmpty()) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "未找到当前货柜的真实入库绑定");
|
||||
}
|
||||
LinkedHashMap<Long, OutboundDispatchReadMapper.Binding> unique = new LinkedHashMap<>();
|
||||
for (OutboundDispatchReadMapper.Binding value : values) unique.putIfAbsent(value.inboundId(), value);
|
||||
return List.copyOf(unique.values());
|
||||
}
|
||||
|
||||
private static OutboundDispatchReadMapper.Binding validateContainer(List<OutboundDispatchReadMapper.Binding> bindings) {
|
||||
OutboundDispatchReadMapper.Binding first = bindings.get(0);
|
||||
boolean inconsistent = bindings.stream().anyMatch(binding ->
|
||||
!Objects.equals(first.containerBookingId(), binding.containerBookingId())
|
||||
|| !Objects.equals(first.loadingPlanId(), binding.loadingPlanId()));
|
||||
if (inconsistent) throw conflict("货柜入库绑定存在跨柜次数据冲突");
|
||||
return first;
|
||||
}
|
||||
|
||||
private static StateView visibleStateForRead(List<OutboundDispatchEntity> states, int bindingCount) {
|
||||
if (states.isEmpty()) return new StateView(OutboundDispatchStatus.READY_FOR_DISPATCH.name(), 0);
|
||||
if (states.size() != bindingCount) throw conflict("货柜出仓状态不完整,请联系管理员处理");
|
||||
return visibleState(states);
|
||||
}
|
||||
|
||||
private static StateView visibleState(List<OutboundDispatchEntity> states) {
|
||||
if (states.isEmpty()) throw conflict("货柜出仓状态不存在");
|
||||
String status = states.get(0).getStatus();
|
||||
Integer version = states.get(0).getVersion();
|
||||
if (states.stream().anyMatch(value -> !Objects.equals(status, value.getStatus())
|
||||
|| !Objects.equals(version, value.getVersion()))) {
|
||||
throw conflict("货柜出仓状态或版本不一致,请联系管理员处理");
|
||||
}
|
||||
return new StateView(status, version);
|
||||
}
|
||||
|
||||
private static void validateKey(String key) {
|
||||
if (key == null || key.isBlank() || key.length() > 128) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "Idempotency-Key不能为空且长度不能超过128");
|
||||
}
|
||||
}
|
||||
private static String hash(String action, ActionRequest request) {
|
||||
String source = action + "|" + request.inboundId() + "|" + request.containerBookingId() + "|"
|
||||
+ request.expectedVersion() + "|" + Objects.toString(request.remark(), "");
|
||||
|
||||
private static String hash(String action, long containerBookingId, ActionRequest request) {
|
||||
String source = action + "|" + containerBookingId + "|" + request.expectedVersion() + "|"
|
||||
+ Objects.toString(request.remark(), "");
|
||||
try {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256").digest(source.getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(digest);
|
||||
} catch (Exception ex) { throw new IllegalStateException(ex); }
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static Long id(OutboundDispatchReadMapper.IdStatus value) { return value == null ? null : value.id(); }
|
||||
private static Long id(OutboundDispatchReadMapper.OceanBinding value) { return value == null ? null : value.id(); }
|
||||
private static String status(OutboundDispatchReadMapper.IdStatus value) { return value == null ? null : value.status(); }
|
||||
private static String status(OutboundDispatchReadMapper.OceanBinding value) { return value == null ? null : value.status(); }
|
||||
private static GateItem item(String code, String name, boolean pass, String actual, String requirement) {
|
||||
return new GateItem(code, name, pass, actual, requirement);
|
||||
}
|
||||
private static String contributorName(String code) {
|
||||
return "CUSTOMS_CLEARANCE".equals(code) ? "报关协同完成" : "船期有效且未过期";
|
||||
return "报关协同完成";
|
||||
}
|
||||
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
|
||||
private static BizException concurrent() { return conflict("数据已被其他人修改,请刷新后重试"); }
|
||||
|
||||
private record StateView(String status, Integer version) {}
|
||||
}
|
||||
|
||||
+2
@@ -97,6 +97,8 @@ public class PlatformCarrierScheduleQueryController {
|
||||
|
||||
@PostMapping("/{queryId}/manual-nodes")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:port-schedule-query:manual')")
|
||||
@Operation(summary = "人工补录预计或实际标准船节点",
|
||||
description = "预计节点不推进实际进度;人工实际节点必须严格晚于当前最新实际节点,同级或更早节点返回 A0409。")
|
||||
@BusinessAudit(event = "CARRIER_SCHEDULE_QUERY_MANUAL_NODE", resourceType = "CARRIER_SCHEDULE_QUERY",
|
||||
resourceId = "#queryId", description = "人工补录船节点事实")
|
||||
public ApiResponse<Detail> manualNode(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
|
||||
+19
-10
@@ -36,23 +36,26 @@ public class CarrierScheduleAdapterRegistry {
|
||||
|
||||
public PreparedAttempt prepare(String carrierCode, String trackingType, String trackingNo) {
|
||||
Definition definition = required(carrierCode);
|
||||
boolean automated = automated(definition);
|
||||
return new PreparedAttempt(definition.code(), definition.accessMode(),
|
||||
officialUrl(definition, trackingType, trackingNo), automated(definition) ? "READY" : "MANUAL_REQUIRED",
|
||||
definition.coverageGapCode(), definition.manualReason());
|
||||
officialUrl(definition, trackingType, trackingNo), automated ? "READY" : "MANUAL_REQUIRED",
|
||||
definition.coverageGapCode(), automated ? null : manualReason(definition));
|
||||
}
|
||||
|
||||
/** Outbound confirmation is a physical fact and must not be blocked by provider coverage. */
|
||||
public PreparedAttempt prepareOutbound(String carrierCode, String trackingType, String trackingNo) {
|
||||
String normalized = carrierCode == null ? "" : carrierCode.trim().toUpperCase(Locale.ROOT);
|
||||
if (normalized.isEmpty() || "OTHER".equals(normalized)) {
|
||||
return new PreparedAttempt("OTHER", "MANUAL", "", "MANUAL_REQUIRED",
|
||||
"UNSUPPORTED_CARRIER", "船司未接入自动查询,需人工补录船节点");
|
||||
}
|
||||
return prepare(normalized, trackingType, trackingNo);
|
||||
}
|
||||
|
||||
public String normalize(String carrierCode) {
|
||||
return required(carrierCode).code();
|
||||
}
|
||||
|
||||
public String resolve(String carrierName) {
|
||||
String normalized = carrierName == null ? "" : carrierName.trim().toUpperCase(Locale.ROOT);
|
||||
return DEFINITIONS.values().stream().filter(value -> value.matches(normalized))
|
||||
.findFirst().map(Definition::code)
|
||||
.orElseThrow(() -> new BizException(ResultCode.BAD_REQUEST,
|
||||
"出仓订舱船司无法映射至已支持的船节点供应商:" + carrierName));
|
||||
}
|
||||
|
||||
private static Definition required(String carrierCode) {
|
||||
String normalized = carrierCode == null ? "" : carrierCode.trim().toUpperCase(Locale.ROOT);
|
||||
Definition value = DEFINITIONS.get(normalized);
|
||||
@@ -105,6 +108,12 @@ public class CarrierScheduleAdapterRegistry {
|
||||
};
|
||||
}
|
||||
|
||||
private static String manualReason(Definition definition) {
|
||||
return definition.manualReason() == null
|
||||
? definition.name() + "自动查询当前未启用,需人工补录船节点"
|
||||
: definition.manualReason();
|
||||
}
|
||||
|
||||
private record Definition(String code, String name, String officialUrl, String accessMode,
|
||||
String coverageGapCode, String manualReason) {
|
||||
CarrierDescriptor descriptor(boolean automated) {
|
||||
|
||||
+49
-17
@@ -35,6 +35,7 @@ import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -95,7 +96,6 @@ public class CarrierScheduleQueryService {
|
||||
ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId);
|
||||
if (booking == null || container == null) throw new BizException(ResultCode.NOT_FOUND, "出仓订舱或柜次不存在");
|
||||
validateLinks(customerId, oceanBookingId, containerBookingId);
|
||||
String carrierCode = adapters.resolve(booking.getShippingCompanyName());
|
||||
String trackingType;
|
||||
String trackingNo;
|
||||
if (StringUtils.hasText(container.getContainerNo())) {
|
||||
@@ -107,36 +107,58 @@ public class CarrierScheduleQueryService {
|
||||
} else {
|
||||
throw badRequest("确认国内出仓前必须具备柜号、提单号或订舱号之一");
|
||||
}
|
||||
PreparedAttempt strategy = adapters.prepare(carrierCode, trackingType, trackingNo);
|
||||
PreparedAttempt strategy = adapters.prepareOutbound(booking.getCarrierCode(), trackingType, trackingNo);
|
||||
LocalDateTime now = now();
|
||||
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity();
|
||||
value.setId(IdWorker.getId()); value.setQueryNo("CSQ" + value.getId());
|
||||
value.setCustomerId(customerId); value.setOceanBookingId(oceanBookingId);
|
||||
value.setContainerBookingId(containerBookingId); value.setCarrierCode(carrierCode);
|
||||
value.setContainerBookingId(containerBookingId); value.setCarrierCode(strategy.carrierCode());
|
||||
value.setTrackingType(trackingType); value.setTrackingNo(trackingNo);
|
||||
value.setOfficialUrl(strategy.officialUrl()); value.setAccessMode(strategy.accessMode());
|
||||
value.setStatus("READY"); value.setTriggerSource("OUTBOUND_CONFIRM");
|
||||
value.setStatus(strategy.status()); value.setTriggerSource("OUTBOUND_CONFIRM");
|
||||
value.setLatestStandardNode(CarrierScheduleNode.DEPARTED_WAREHOUSE.name());
|
||||
value.setLatestNodeObservedAt(now); value.setWindowStartedAt(now); value.setAttemptCount(0);
|
||||
value.setConsecutiveFailures(0); value.setNextAllowedAt(CarrierScheduleQuerySlots.nextSlot(now));
|
||||
value.setConsecutiveFailures(0); value.setNextAllowedAt("MANUAL".equals(strategy.accessMode())
|
||||
? null : CarrierScheduleQuerySlots.nextSlot(now));
|
||||
value.setManualReason(outboundManualReason(strategy, booking));
|
||||
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
|
||||
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0);
|
||||
try { repository.insert(value); }
|
||||
catch (DuplicateKeyException exception) {
|
||||
CarrierScheduleQueryEntity concurrent = repository.findByBusinessScope(customerId, oceanBookingId,
|
||||
containerBookingId);
|
||||
if (concurrent != null) return concurrent;
|
||||
throw exception;
|
||||
String persistenceStage = "QUERY_TASK";
|
||||
try {
|
||||
try { repository.insert(value); }
|
||||
catch (DuplicateKeyException exception) {
|
||||
CarrierScheduleQueryEntity concurrent = repository.findByBusinessScope(customerId, oceanBookingId,
|
||||
containerBookingId);
|
||||
if (concurrent != null) return concurrent;
|
||||
throw exception;
|
||||
}
|
||||
persistenceStage = "NODE_FACT";
|
||||
CarrierScheduleNodeFactEntity departed = addNode(value.getId(), "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM",
|
||||
CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM",
|
||||
sha256("OUTBOUND_CONFIRM|" + containerBookingId));
|
||||
if (departed != null) {
|
||||
persistenceStage = "CROSS_REGION_OUTBOX";
|
||||
nodeOutbox.publish(value, departed, requireContainerNo(containerBookingId));
|
||||
}
|
||||
persistenceStage = "QUERY_HISTORY";
|
||||
addHistory(value.getId(), "AUTO_CREATE", null, 0, "国内确认出仓自动创建", null, value,
|
||||
operatorId, operatorName);
|
||||
} catch (DataIntegrityViolationException exception) {
|
||||
throw conflict("船节点持久化契约与当前服务不一致(" + persistenceStage
|
||||
+ "),请确认OTHER/MANUAL约束及节点事实表已初始化");
|
||||
}
|
||||
CarrierScheduleNodeFactEntity departed = addNode(value.getId(), "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM",
|
||||
CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM",
|
||||
sha256("OUTBOUND_CONFIRM|" + containerBookingId));
|
||||
if (departed != null) nodeOutbox.publish(value, departed, requireContainerNo(containerBookingId));
|
||||
addHistory(value.getId(), "AUTO_CREATE", null, 0, "国内确认出仓自动创建", null, value,
|
||||
operatorId, operatorName);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static String outboundManualReason(PreparedAttempt strategy, OceanBookingEntity booking) {
|
||||
if (!"MANUAL_REQUIRED".equals(strategy.status())) return null;
|
||||
String reason = strategy.manualReason();
|
||||
if (!"MANUAL".equals(strategy.accessMode())) return reason;
|
||||
return reason + ";carrierCode=" + Objects.toString(booking.getCarrierCode(), "")
|
||||
+ ";shippingCompanyId=" + booking.getShippingCompanyId()
|
||||
+ ";shippingCompanyName=" + Objects.toString(booking.getShippingCompanyName(), "");
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Detail applyProviderObservation(long id, ProviderObservationRequest request) {
|
||||
CarrierScheduleQueryEntity value = requiredLocked(id);
|
||||
@@ -183,6 +205,7 @@ public class CarrierScheduleQueryService {
|
||||
}
|
||||
CarrierScheduleQueryEntity value = requiredLockedVersion(id, request.expectedVersion());
|
||||
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
|
||||
requireManualActualAdvance(value, node, request.actualAt());
|
||||
CarrierScheduleQueryEntity before = copy(value);
|
||||
String providerEventId = sha256("MANUAL|" + id + '|' + key);
|
||||
CarrierScheduleNodeFactEntity fact = addNode(id, "MANUAL", providerEventId, node.name(), node,
|
||||
@@ -204,6 +227,15 @@ public class CarrierScheduleQueryService {
|
||||
return complete(id, operatorId, key, detail(after));
|
||||
}
|
||||
|
||||
private void requireManualActualAdvance(CarrierScheduleQueryEntity value, CarrierScheduleNode node,
|
||||
LocalDateTime actualAt) {
|
||||
if (actualAt == null || value.getLatestStandardNode() == null) return;
|
||||
CarrierScheduleNode current = CarrierScheduleNode.require(value.getLatestStandardNode());
|
||||
if (node.sequence() <= current.sequence()) {
|
||||
throw conflict("人工实际节点必须严格晚于当前最新实际节点");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean advanceActualNode(CarrierScheduleQueryEntity value, CarrierScheduleNode node,
|
||||
LocalDateTime actualAt, LocalDateTime observedAt,
|
||||
long operatorId, String operatorName) {
|
||||
|
||||
+7
-6
@@ -1,9 +1,10 @@
|
||||
package com.baiyue.firstleg.mapper;
|
||||
package com.baiyue.firstleg.pricebookquery.adapter.out.persistence;
|
||||
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelPrefixRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelPrefixRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.port.out.PriceBookQueryReader;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
@@ -12,7 +13,7 @@ import java.util.List;
|
||||
|
||||
/** Dedicated read model for merchant channel selection and reference estimates. */
|
||||
@Mapper
|
||||
public interface PriceBookQueryMapper {
|
||||
public interface PriceBookQueryMapper extends PriceBookQueryReader {
|
||||
@Select("""
|
||||
SELECT channel_code AS channelCode, inbound_code_prefix AS inboundCodePrefix,
|
||||
overseas_inbound_code_prefix AS overseasInboundCodePrefix
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.baiyue.firstleg.pricebookquery.application;
|
||||
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelPrefixRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase;
|
||||
import com.baiyue.firstleg.pricebookquery.application.port.out.PriceBookQueryReader;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class PriceBookQueryService implements PriceBookQueryUseCase {
|
||||
@Resource private PriceBookQueryReader reader;
|
||||
|
||||
@Override public Long activeVersionId() { return reader.findActiveVersionId(); }
|
||||
@Override public List<ChannelRow> channels(long versionId, long originWarehouseId) {
|
||||
return reader.findChannels(versionId, originWarehouseId);
|
||||
}
|
||||
@Override public List<ChannelTaxModeRow> channelTaxModes(long versionId, long originWarehouseId) {
|
||||
return reader.findChannelTaxModes(versionId, originWarehouseId);
|
||||
}
|
||||
@Override public List<RateRow> fbaRates(long versionId, long originWarehouseId, String channelCode,
|
||||
String destinationCode, String pricingTaxMode) {
|
||||
return reader.findFbaRates(versionId, originWarehouseId, channelCode, destinationCode, pricingTaxMode);
|
||||
}
|
||||
@Override public List<RateRow> overseasRates(long versionId, long originWarehouseId, String channelCode,
|
||||
long destinationWarehouseId, String pricingTaxMode) {
|
||||
return reader.findOverseasRates(versionId, originWarehouseId, channelCode, destinationWarehouseId,
|
||||
pricingTaxMode);
|
||||
}
|
||||
@Override public List<RateRow> regionTiers(long versionId, long originWarehouseId, String channelCode,
|
||||
String regionCode, String pricingTaxMode) {
|
||||
return reader.findRegionTiers(versionId, originWarehouseId, channelCode, regionCode, pricingTaxMode);
|
||||
}
|
||||
@Override public boolean channelAvailable(long versionId, long originWarehouseId, String channelCode) {
|
||||
return channels(versionId, originWarehouseId).stream()
|
||||
.anyMatch(channel -> channelCode.equals(channel.getChannelCode()));
|
||||
}
|
||||
@Override public ChannelPrefixRow channelPrefix(String channelCode) {
|
||||
return reader.findChannelPrefix(channelCode);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
package com.baiyue.firstleg.pricebookquery.application.model;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.firstleg.pricebookquery.application.port.in;
|
||||
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelPrefixRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface PriceBookQueryUseCase {
|
||||
Long activeVersionId();
|
||||
List<ChannelRow> channels(long versionId, long originWarehouseId);
|
||||
List<ChannelTaxModeRow> channelTaxModes(long versionId, long originWarehouseId);
|
||||
List<RateRow> fbaRates(long versionId, long originWarehouseId, String channelCode,
|
||||
String destinationCode, String pricingTaxMode);
|
||||
List<RateRow> overseasRates(long versionId, long originWarehouseId, String channelCode,
|
||||
long destinationWarehouseId, String pricingTaxMode);
|
||||
List<RateRow> regionTiers(long versionId, long originWarehouseId, String channelCode,
|
||||
String regionCode, String pricingTaxMode);
|
||||
boolean channelAvailable(long versionId, long originWarehouseId, String channelCode);
|
||||
ChannelPrefixRow channelPrefix(String channelCode);
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.baiyue.firstleg.pricebookquery.application.port.out;
|
||||
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelPrefixRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface PriceBookQueryReader {
|
||||
Long findActiveVersionId();
|
||||
List<ChannelRow> findChannels(long versionId, long originWarehouseId);
|
||||
List<ChannelTaxModeRow> findChannelTaxModes(long versionId, long originWarehouseId);
|
||||
List<RateRow> findFbaRates(long versionId, long originWarehouseId, String channelCode,
|
||||
String destinationCode, String pricingTaxMode);
|
||||
List<RateRow> findOverseasRates(long versionId, long originWarehouseId, String channelCode,
|
||||
long destinationWarehouseId, String pricingTaxMode);
|
||||
List<RateRow> findRegionTiers(long versionId, long originWarehouseId, String channelCode,
|
||||
String regionCode, String pricingTaxMode);
|
||||
ChannelPrefixRow findChannelPrefix(String channelCode);
|
||||
}
|
||||
@@ -7,9 +7,9 @@ import com.baiyue.firstleg.domain.PriceRegion;
|
||||
import com.baiyue.firstleg.domain.PricingDestinationType;
|
||||
import com.baiyue.firstleg.domain.PricingTaxMode;
|
||||
import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRepository;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
@@ -21,7 +21,7 @@ import java.util.Locale;
|
||||
/** Single active-price-book lookup used by merchant estimates and inbound automatic billing. */
|
||||
@Component
|
||||
public class ActivePriceBookEstimator {
|
||||
@Resource private PriceBookQueryRepository repository;
|
||||
@Resource private PriceBookQueryUseCase repository;
|
||||
@Resource private FbaWarehouseRateEstimator fbaEstimator;
|
||||
@Resource private RegionWeightTierEstimator regionEstimator;
|
||||
@Resource private PriceBookWarehouseRegionResolver warehouseRegionResolver;
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.baiyue.firstleg.pricing;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@@ -2,7 +2,7 @@ package com.baiyue.firstleg.pricing;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@@ -46,7 +46,6 @@ public class ContainerRepository {
|
||||
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);
|
||||
|
||||
@@ -106,9 +106,13 @@ public class LoadingListRepository {
|
||||
String fromStatus, String toStatus, String beforeSnapshot,
|
||||
String afterSnapshot, long operatorId, String operatorName,
|
||||
String operationReason) {
|
||||
mapper.insertHistory(new HistoryWrite(IdWorker.getId(), planId, operationId, actionType,
|
||||
int changed = mapper.insertHistory(new HistoryWrite(IdWorker.getId(), planId, operationId, actionType,
|
||||
fromStatus, toStatus, beforeSnapshot, afterSnapshot, operatorId, operatorName,
|
||||
operationReason));
|
||||
if (changed != 1) {
|
||||
throw new com.baiyue.common.core.exception.BizException(
|
||||
com.baiyue.common.core.api.ResultCode.CONFLICT, "装柜历史已变化,请刷新后重试");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<Long, List<Customer>> customerMap(List<AggregateRow> rows) {
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baiyue.firstleg.mapper.PriceBookQueryMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public class PriceBookQueryRepository {
|
||||
@Resource
|
||||
private PriceBookQueryMapper mapper;
|
||||
|
||||
public Long activeVersionId() {
|
||||
return mapper.findActiveVersionId();
|
||||
}
|
||||
|
||||
public List<PriceBookQueryRows.ChannelRow> channels(long versionId, long originWarehouseId) {
|
||||
return mapper.findChannels(versionId, originWarehouseId);
|
||||
}
|
||||
|
||||
public List<PriceBookQueryRows.ChannelTaxModeRow> channelTaxModes(long versionId, long originWarehouseId) {
|
||||
return mapper.findChannelTaxModes(versionId, originWarehouseId);
|
||||
}
|
||||
|
||||
public List<PriceBookQueryRows.RateRow> fbaRates(long versionId, long originWarehouseId,
|
||||
String channelCode, String destinationCode,
|
||||
String pricingTaxMode) {
|
||||
return mapper.findFbaRates(versionId, originWarehouseId, channelCode, destinationCode, pricingTaxMode);
|
||||
}
|
||||
|
||||
public List<PriceBookQueryRows.RateRow> overseasRates(long versionId, long originWarehouseId,
|
||||
String channelCode, long destinationWarehouseId,
|
||||
String pricingTaxMode) {
|
||||
return mapper.findOverseasRates(versionId, originWarehouseId, channelCode, destinationWarehouseId,
|
||||
pricingTaxMode);
|
||||
}
|
||||
|
||||
public List<PriceBookQueryRows.RateRow> regionTiers(long versionId, long originWarehouseId,
|
||||
String channelCode, String regionCode,
|
||||
String pricingTaxMode) {
|
||||
return mapper.findRegionTiers(versionId, originWarehouseId, channelCode, regionCode, pricingTaxMode);
|
||||
}
|
||||
|
||||
public boolean channelAvailable(long versionId, long originWarehouseId, String channelCode) {
|
||||
return channels(versionId, originWarehouseId).stream()
|
||||
.anyMatch(channel -> channelCode.equals(channel.getChannelCode()));
|
||||
}
|
||||
|
||||
public PriceBookQueryRows.ChannelPrefixRow channelPrefix(String channelCode) {
|
||||
return mapper.findChannelPrefix(channelCode);
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,8 @@ 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.pricebookquery.application.model.PriceBookQueryRows.ChannelRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase;
|
||||
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
|
||||
@@ -44,7 +44,7 @@ public class ContainerService {
|
||||
@Resource private SmartLoadingRepository smartLoadingRepository;
|
||||
@Resource private LoadingPickRepository loadingPickRepository;
|
||||
@Resource private LoadingPickService loadingPickService;
|
||||
@Resource private PriceBookQueryRepository priceBooks;
|
||||
@Resource private PriceBookQueryUseCase priceBooks;
|
||||
@Resource private OceanBookingRepository oceanBookings;
|
||||
@Resource private WarehouseSupplierSnapshotService suppliers;
|
||||
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
|
||||
@@ -85,17 +85,14 @@ public class ContainerService {
|
||||
if (booking == null || !"RELEASED".equals(booking.getStatus())) throw conflict("只能关联已发布订舱");
|
||||
OceanBookingContainerRequirementEntity requirement = oceanBookings.requirement(request.oceanBookingId(), request.requirementId());
|
||||
if (requirement == null) throw new BizException(ResultCode.NOT_FOUND, "订舱箱型需求不存在");
|
||||
long allocated = oceanBookings.allocations(request.oceanBookingId()).stream().filter(a -> request.requirementId().equals(a.getRequirementId())).count();
|
||||
long allocated = oceanBookings.activeLinkedCount(request.oceanBookingId(), request.requirementId());
|
||||
if (allocated >= requirement.getQuantity()) throw conflict("该订舱箱型需求已达到数量上限");
|
||||
if (repository.containerNoExists(request.containerNo().trim(), id)) throw conflict("集装箱号已存在");
|
||||
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("模拟柜与真实订舱柜仓库、渠道、箱型或容量不一致");
|
||||
if (booking.getCustomerId() != null && repository.hasMismatchedCustomerItems(id, booking.getCustomerId())) throw conflict("模拟柜已排货客户与真实订舱客户范围不一致");
|
||||
|| !java.util.Objects.equals(container.getContainerType(), requirement.getContainerType())) {
|
||||
throw conflict("模拟柜与真实订舱柜仓库、渠道或柜型不一致");
|
||||
}
|
||||
if (repository.linkReleasedBooking(id, request.oceanBookingId(), request.requirementId(), request.containerNo().trim(), booking,
|
||||
request.expectedVersion(), operatorId, operatorName) != 1) {
|
||||
throw conflict("真实订舱柜已被消费或货柜版本发生变化,请刷新后重试");
|
||||
|
||||
@@ -14,7 +14,7 @@ import com.baiyue.firstleg.entity.InboundFeeItemEntity;
|
||||
import com.baiyue.firstleg.entity.InboundPricingEntity;
|
||||
import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity;
|
||||
import com.baiyue.firstleg.pricing.ActivePriceBookEstimator;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow;
|
||||
import com.baiyue.firstleg.repository.FbaPricingRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -31,6 +31,7 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import feign.FeignException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -55,7 +56,7 @@ public class LoadingListService {
|
||||
public static final String DELIVERY_ORDER = "DELIVERY_ORDER";
|
||||
public static final String PICKUP_ORDER = "PICKUP_ORDER";
|
||||
private static final String CLEARANCE_FILE_TYPE = "CONTAINER_CLEARANCE_DOCUMENT";
|
||||
private static final String DECLARATION_FILE_TYPE = "LOADING_CUSTOMS_DOCUMENT";
|
||||
private static final String DECLARATION_FILE_TYPE = "LOADING_CUSTOMS_DECLARATION";
|
||||
private static final String DO_FILE_TYPE = "CONTAINER_DELIVERY_ORDER";
|
||||
private static final String PU_FILE_TYPE = "CONTAINER_PICKUP_ORDER";
|
||||
@Resource
|
||||
@@ -128,6 +129,41 @@ public class LoadingListService {
|
||||
}
|
||||
catch(FeignException e){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}
|
||||
}
|
||||
public DocumentDownload downloadContainerClearance(long bookingId, long fileId) {
|
||||
requireBookingPlan(bookingId);
|
||||
ContainerDocumentEntity document = repository.document(bookingId, DESTINATION_CLEARANCE);
|
||||
if (document == null || !Long.valueOf(fileId).equals(document.getFileId())) {
|
||||
throw notFound("目的港清关附件不存在或不属于当前货柜");
|
||||
}
|
||||
String businessId = Long.toString(bookingId);
|
||||
try {
|
||||
var metadata = documentFiles.metadata(internalToken, 0, CLEARANCE_FILE_TYPE, businessId, fileId);
|
||||
if (metadata == null || !metadata.successful() || metadata.data() == null
|
||||
|| !Long.valueOf(fileId).equals(metadata.data().fileId())
|
||||
|| !Long.valueOf(0).equals(metadata.data().tenantId())
|
||||
|| !CLEARANCE_FILE_TYPE.equals(metadata.data().businessType())
|
||||
|| !businessId.equals(metadata.data().businessId())) {
|
||||
throw notFound("目的港清关附件不存在或归属不匹配");
|
||||
}
|
||||
var response = documentFiles.download(internalToken, 0, CLEARANCE_FILE_TYPE, businessId, fileId);
|
||||
if (response == null || !response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
|
||||
throw notFound("目的港清关附件不存在");
|
||||
}
|
||||
MediaType contentType;
|
||||
try {
|
||||
contentType = MediaType.parseMediaType(metadata.data().contentType());
|
||||
} catch (RuntimeException exception) {
|
||||
contentType = MediaType.APPLICATION_OCTET_STREAM;
|
||||
}
|
||||
String fileName = StringUtils.hasText(document.getFileName())
|
||||
? document.getFileName() : metadata.data().fileName();
|
||||
return new DocumentDownload(fileName, contentType, response.getBody());
|
||||
} catch (FeignException.NotFound exception) {
|
||||
throw notFound("目的港清关附件不存在或归属不匹配");
|
||||
} catch (FeignException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用");
|
||||
}
|
||||
}
|
||||
public record DocumentDownload(String fileName,MediaType contentType,byte[] content) {}
|
||||
private static void requireLogisticsType(String type){ if(!DELIVERY_ORDER.equals(type)&&!PICKUP_ORDER.equals(type)) throw badRequest("documentType只能是DELIVERY_ORDER或PICKUP_ORDER"); }
|
||||
private static String fileType(String type){ return switch(type){case DELIVERY_ORDER->DO_FILE_TYPE; case PICKUP_ORDER->PU_FILE_TYPE; default->throw badRequest("documentType只能是DELIVERY_ORDER或PICKUP_ORDER");}; }
|
||||
@@ -169,6 +205,44 @@ public class LoadingListService {
|
||||
return document(repository.document(plan.getContainerBookingId(), CUSTOMS_DECLARATION));
|
||||
}
|
||||
|
||||
public DocumentDownload downloadLoadingDeclaration(long planId, long warehouseId, long fileId) {
|
||||
AggregateRow plan = requirePlan(planId);
|
||||
requireWarehouse(plan, warehouseId);
|
||||
requireCompletedPlan(plan);
|
||||
ContainerDocumentEntity document = repository.document(plan.getContainerBookingId(), CUSTOMS_DECLARATION);
|
||||
if (document == null || !Long.valueOf(fileId).equals(document.getFileId())) {
|
||||
throw notFound("出口报关附件不存在或不属于当前装柜计划");
|
||||
}
|
||||
String businessId = Long.toString(planId);
|
||||
try {
|
||||
var metadata = documentFiles.metadata(internalToken, 0, DECLARATION_FILE_TYPE, businessId, fileId);
|
||||
if (metadata == null || !metadata.successful() || metadata.data() == null
|
||||
|| !Long.valueOf(fileId).equals(metadata.data().fileId())
|
||||
|| !Long.valueOf(0).equals(metadata.data().tenantId())
|
||||
|| !DECLARATION_FILE_TYPE.equals(metadata.data().businessType())
|
||||
|| !businessId.equals(metadata.data().businessId())) {
|
||||
throw notFound("出口报关附件不存在或归属不匹配");
|
||||
}
|
||||
var response = documentFiles.download(internalToken, 0, DECLARATION_FILE_TYPE, businessId, fileId);
|
||||
if (response == null || !response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
|
||||
throw notFound("出口报关附件不存在");
|
||||
}
|
||||
MediaType contentType;
|
||||
try {
|
||||
contentType = MediaType.parseMediaType(metadata.data().contentType());
|
||||
} catch (RuntimeException exception) {
|
||||
contentType = MediaType.APPLICATION_OCTET_STREAM;
|
||||
}
|
||||
String fileName = StringUtils.hasText(document.getFileName())
|
||||
? document.getFileName() : metadata.data().fileName();
|
||||
return new DocumentDownload(fileName, contentType, response.getBody());
|
||||
} catch (FeignException.NotFound exception) {
|
||||
throw notFound("出口报关附件不存在或归属不匹配");
|
||||
} catch (FeignException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用");
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "LOADING_CUSTOMS_DOCUMENT_ATTACH", resourceType = "LOADING_PLAN",
|
||||
resourceId = "#planId", description = "上传出口报关文档")
|
||||
@@ -264,12 +338,18 @@ public class LoadingListService {
|
||||
if (changed != 1) throw conflict("资料版本已变化,请刷新后重试");
|
||||
} catch (DuplicateKeyException exception) {
|
||||
throw conflict("资料已被其他操作上传,请刷新后重试");
|
||||
} catch (DataIntegrityViolationException exception) {
|
||||
throw conflict("资料状态或数据库约束已变化,请刷新后重试");
|
||||
}
|
||||
ContainerDocumentEntity saved = requireDocumentEntity(plan.getContainerBookingId(), documentType);
|
||||
repository.appendHistory(plan.getPlanId(), operationId, actionType,
|
||||
plan.getPlanStatus(), plan.getPlanStatus(), json(beforeSnapshot),
|
||||
json(values("requestHash", requestHash, "document", documentSnapshot(saved))),
|
||||
operatorId, trim(operatorName), trim(operationReason));
|
||||
try {
|
||||
repository.appendHistory(plan.getPlanId(), operationId, actionType,
|
||||
plan.getPlanStatus(), plan.getPlanStatus(), json(beforeSnapshot),
|
||||
json(values("requestHash", requestHash, "document", documentSnapshot(saved))),
|
||||
operatorId, trim(operatorName), trim(operationReason));
|
||||
} catch (DataIntegrityViolationException exception) {
|
||||
throw conflict("资料历史约束已变化,请刷新后重试");
|
||||
}
|
||||
return document(saved);
|
||||
}
|
||||
|
||||
|
||||
@@ -462,10 +462,12 @@ public class LoadingPickService {
|
||||
for (LoadingPickItemMapper.InboundOwner owner : owners) {
|
||||
LoadingInboundMapper.Snapshot snapshot = repository.inboundForUpdate(owner.tenantId(), owner.inboundId());
|
||||
if (snapshot == null) throw conflict("PDA任务关联入库单不存在");
|
||||
loadingDeliveryService.requirePdaCompletionReady(owner.tenantId(), owner.inboundId(),
|
||||
evidenceOwner.inboundId());
|
||||
completionStage(taskId, "完成前业务门禁", () ->
|
||||
loadingDeliveryService.requirePdaCompletionReady(owner.tenantId(), owner.inboundId(),
|
||||
evidenceOwner.inboundId()));
|
||||
}
|
||||
settlementService.settle(taskId, operatorId, operatorName);
|
||||
completionStage(taskId, "拆单与库位结算", () ->
|
||||
settlementService.settle(taskId, operatorId, operatorName));
|
||||
if (!repository.allPlanItemsPicked(task.getLoadingPlanId())) {
|
||||
throw conflict("柜次仍有未完成PDA拣货的箱数");
|
||||
}
|
||||
@@ -473,18 +475,33 @@ public class LoadingPickService {
|
||||
throw conflict("PDA任务已经复核");
|
||||
}
|
||||
insertOperation(taskId, operationId, requestHash, "COMPLETE_LOADING", operatorId, operatorName);
|
||||
loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName);
|
||||
completionStage(taskId, "装柜计划完成", () ->
|
||||
loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName));
|
||||
repository.syncInboundProjections(taskId, operatorId, operatorName);
|
||||
for (LoadingPickItemMapper.InboundOwner owner : owners) {
|
||||
loadingDeliveryService.completeAfterPdaSettlement(owner.tenantId(), owner.inboundId(),
|
||||
evidenceOwner.inboundId(), operatorId, operatorName,
|
||||
"PDA-" + operationId + '-' + owner.inboundId(), remark);
|
||||
completionStage(taskId, "入库单状态完成", () ->
|
||||
loadingDeliveryService.completeAfterPdaSettlement(owner.tenantId(), owner.inboundId(),
|
||||
evidenceOwner.inboundId(), operatorId, operatorName,
|
||||
"PDA-" + operationId + '-' + owner.inboundId(), remark));
|
||||
}
|
||||
loadingListService.publishCompletedEvent(task.getLoadingPlanId());
|
||||
completionStage(taskId, "完成事件发布", () ->
|
||||
loadingListService.publishCompletedEvent(task.getLoadingPlanId()));
|
||||
completeTruckAfterCommit(task.getContainerBookingId(), taskId, operatorId, operatorName);
|
||||
return view(requiredTask(taskId), null);
|
||||
}
|
||||
|
||||
private void completionStage(long taskId, String stage, Runnable action) {
|
||||
try {
|
||||
action.run();
|
||||
} catch (BizException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
log.error("Unexpected PDA loading completion failure taskId={} stage={}", taskId, stage, exception);
|
||||
throw new BizException(ResultCode.INTERNAL_ERROR,
|
||||
"装柜完成在【" + stage + "】阶段处理异常,请联系管理员并提供请求编号");
|
||||
}
|
||||
}
|
||||
|
||||
private void completeTruckAfterCommit(long containerBookingId, long taskId,
|
||||
long operatorId, String operatorName) {
|
||||
Runnable completion = () -> {
|
||||
|
||||
@@ -35,6 +35,8 @@ public class MeasurementBatchService {
|
||||
/** 已接收第三方测量数据,待平台提交审核。 */
|
||||
static final String STATUS_RECEIVED = "RECEIVED";
|
||||
static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167");
|
||||
private static final BigDecimal MAX_RAW_VOLUME_CBM = new BigDecimal("999999999999.999999");
|
||||
private static final BigDecimal MAX_TOTAL_VOLUME_CBM = new BigDecimal("99999999999999.9999");
|
||||
@Resource
|
||||
private InboundMeasurementRepository repository;
|
||||
@Resource
|
||||
@@ -140,7 +142,16 @@ public class MeasurementBatchService {
|
||||
BigDecimal unitVolume = weighted.unitVolumeCbm();
|
||||
BigDecimal packageCount = BigDecimal.valueOf(source.packageCount());
|
||||
BigDecimal totalVolumeRaw = unitVolumeRaw.multiply(packageCount).setScale(6, RoundingMode.HALF_UP);
|
||||
BigDecimal totalVolume = unitVolume.multiply(packageCount).setScale(4, RoundingMode.UNNECESSARY);
|
||||
BigDecimal totalVolume;
|
||||
try {
|
||||
totalVolume = unitVolume.multiply(packageCount).setScale(4, RoundingMode.UNNECESSARY);
|
||||
} catch (ArithmeticException exception) {
|
||||
throw measurementRangeExceeded(source);
|
||||
}
|
||||
if (totalVolumeRaw.compareTo(MAX_RAW_VOLUME_CBM) > 0
|
||||
|| totalVolume.compareTo(MAX_TOTAL_VOLUME_CBM) > 0) {
|
||||
throw measurementRangeExceeded(source);
|
||||
}
|
||||
|
||||
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
|
||||
item.setInboundDetailId(source.inboundDetailId());
|
||||
@@ -169,6 +180,13 @@ public class MeasurementBatchService {
|
||||
}).toList();
|
||||
}
|
||||
|
||||
private static BizException measurementRangeExceeded(MeasurementBatchCommand.Item source) {
|
||||
return new BizException(ResultCode.VALIDATION_FAILED,
|
||||
"箱规尺寸或箱数超出系统允许范围,明细 " + source.itemNo() + ":"
|
||||
+ source.lengthCm() + " × " + source.widthCm() + " × " + source.heightCm()
|
||||
+ " cm,箱数 " + source.packageCount());
|
||||
}
|
||||
|
||||
private void applyTotals(InboundMeasurementEntity measurement,
|
||||
List<InboundMeasurementItemEntity> items) {
|
||||
long packages = items.stream().mapToLong(InboundMeasurementItemEntity::getPackageCount).sum();
|
||||
|
||||
@@ -9,9 +9,9 @@ import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest;
|
||||
import com.baiyue.firstleg.dto.PriceBookDtos.EstimateResponse;
|
||||
import com.baiyue.firstleg.dto.PriceBookChannelValidation.Response;
|
||||
import com.baiyue.firstleg.pricing.ActivePriceBookEstimator;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRepository;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
@@ -26,7 +26,7 @@ import java.util.Map;
|
||||
public class MerchantPriceBookService {
|
||||
public static final String DISCLAIMER = "仅供参考,不含附加费,最终以实际结算为准";
|
||||
|
||||
@Resource private PriceBookQueryRepository repository;
|
||||
@Resource private PriceBookQueryUseCase repository;
|
||||
@Resource private ActivePriceBookEstimator estimator;
|
||||
|
||||
public List<CurrentChannel> channels(long merchantTenantId, long originWarehouseId) {
|
||||
|
||||
@@ -8,11 +8,11 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query (
|
||||
ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '关联海运订舱ID',
|
||||
container_booking_id BIGINT UNSIGNED NULL COMMENT '指定查询的柜次ID;整票订舱查询时为空',
|
||||
container_scope_id BIGINT UNSIGNED GENERATED ALWAYS AS (COALESCE(container_booking_id,0)) STORED COMMENT '柜次唯一作用域计算值;整票订舱查询固定为0',
|
||||
carrier_code VARCHAR(16) NOT NULL COMMENT '船公司编码:MATSON美森、SML森罗、ZIM以星、COSCO中远海运、EMC长荣、HEDE合德',
|
||||
carrier_code VARCHAR(16) NOT NULL COMMENT '船公司标准编码;OTHER表示人工跟踪',
|
||||
tracking_type VARCHAR(32) NOT NULL COMMENT '官网跟踪类型:BOOKING_NO订舱号、BILL_OF_LADING提单号、CONTAINER_NO箱号',
|
||||
tracking_no VARCHAR(128) NOT NULL COMMENT '提交至船公司官网的跟踪号码',
|
||||
official_url VARCHAR(500) NOT NULL COMMENT '船公司官网查询入口地址快照',
|
||||
access_mode VARCHAR(32) NOT NULL COMMENT '船司访问方式:PUBLIC_FORM、PUBLIC_WEB_PROXY、PUBLIC_HTML、HCAPTCHA、OFFICIAL_API',
|
||||
access_mode VARCHAR(32) NOT NULL COMMENT '船司访问方式;MANUAL表示仅人工补录节点',
|
||||
status VARCHAR(24) NOT NULL COMMENT '任务操作状态:READY、MANUAL_REQUIRED、STOPPED',
|
||||
trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '任务触发来源:OUTBOUND_CONFIRM或MANUAL',
|
||||
latest_standard_node VARCHAR(32) NULL COMMENT '最近实际标准船节点;预计事实不推进',
|
||||
@@ -54,11 +54,11 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query (
|
||||
CONSTRAINT fk_carrier_schedule_query_result FOREIGN KEY (result_port_schedule_id) REFERENCES fl_port_schedule(id),
|
||||
CONSTRAINT chk_carrier_schedule_query_customer CHECK (customer_id > 0),
|
||||
CONSTRAINT chk_carrier_schedule_query_carrier CHECK
|
||||
(carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE')),
|
||||
(carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')),
|
||||
CONSTRAINT chk_carrier_schedule_query_tracking CHECK
|
||||
(tracking_type IN ('BOOKING_NO','BILL_OF_LADING','CONTAINER_NO')),
|
||||
CONSTRAINT chk_carrier_schedule_query_access CHECK
|
||||
(access_mode IN ('PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API')),
|
||||
(access_mode IN ('PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API','MANUAL')),
|
||||
CONSTRAINT chk_carrier_schedule_query_status CHECK
|
||||
(status IN ('READY','MANUAL_REQUIRED','STOPPED')),
|
||||
CONSTRAINT chk_carrier_schedule_query_node CHECK
|
||||
|
||||
@@ -14,6 +14,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
|
||||
bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号',
|
||||
shipping_company_id BIGINT UNSIGNED NULL COMMENT '船公司逻辑ID快照',
|
||||
shipping_company_name VARCHAR(128) NULL COMMENT '船公司名称快照',
|
||||
carrier_code VARCHAR(16) NULL COMMENT '船司标准编码:MATSON/SML/ZIM/COSCO/EMC/HEDE/OTHER;发布前必填',
|
||||
booking_agent_id BIGINT UNSIGNED NULL COMMENT '订舱代理逻辑ID快照',
|
||||
booking_agent_name VARCHAR(128) NULL COMMENT '订舱代理名称快照',
|
||||
vessel_name VARCHAR(128) NULL COMMENT '当前确认船名快照',
|
||||
@@ -42,6 +43,8 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
|
||||
CONSTRAINT chk_ocean_booking_customer CHECK (customer_id > 0),
|
||||
CONSTRAINT chk_ocean_booking_status CHECK (status IN
|
||||
('DRAFT','RELEASED')),
|
||||
CONSTRAINT chk_ocean_booking_carrier_code CHECK
|
||||
(carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')),
|
||||
CONSTRAINT chk_ocean_booking_schedule CHECK
|
||||
(estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at)
|
||||
) ENGINE=InnoDB COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载';
|
||||
|
||||
@@ -70,7 +70,7 @@ BEGIN
|
||||
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
|
||||
WHERE container_booking_id = NEW.container_booking_id;
|
||||
IF current_plan_status = 'COMPLETED'
|
||||
AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN
|
||||
AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜物流文件不可新增';
|
||||
END IF;
|
||||
END$$
|
||||
@@ -84,7 +84,7 @@ BEGIN
|
||||
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
|
||||
WHERE container_booking_id = OLD.container_booking_id;
|
||||
IF current_plan_status = 'COMPLETED'
|
||||
AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN
|
||||
AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜物流文件不可修改';
|
||||
END IF;
|
||||
END$$
|
||||
|
||||
@@ -1122,7 +1122,7 @@ BEGIN
|
||||
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
|
||||
WHERE container_booking_id = NEW.container_booking_id;
|
||||
IF current_plan_status = 'COMPLETED'
|
||||
AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN
|
||||
AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增';
|
||||
END IF;
|
||||
END$$
|
||||
@@ -1138,7 +1138,7 @@ BEGIN
|
||||
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
|
||||
WHERE container_booking_id = OLD.container_booking_id;
|
||||
IF current_plan_status = 'COMPLETED'
|
||||
AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN
|
||||
AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN
|
||||
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改';
|
||||
END IF;
|
||||
END$$
|
||||
|
||||
@@ -18,7 +18,7 @@ paths:
|
||||
- {name: customerId, in: query, schema: {type: integer, format: int64, minimum: 1}}
|
||||
- {name: oceanBookingId, in: query, schema: {type: integer, format: int64, minimum: 1}}
|
||||
- {name: containerBookingId, in: query, schema: {type: integer, format: int64, minimum: 1}}
|
||||
- {name: carrierCode, in: query, schema: {$ref: '#/components/schemas/CarrierCode'}}
|
||||
- {name: carrierCode, in: query, schema: {$ref: '#/components/schemas/TaskCarrierCode'}}
|
||||
- {name: status, in: query, schema: {$ref: '#/components/schemas/QueryStatus'}}
|
||||
- {name: expired, in: query, schema: {type: boolean}}
|
||||
- {name: keyword, in: query, schema: {type: string, maxLength: 128}}
|
||||
@@ -82,7 +82,9 @@ paths:
|
||||
schema: {$ref: '#/components/schemas/ManualNodeRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: Idempotent fact append; only actualAt advances and actual EMPTY_RETURNED stops
|
||||
description: Idempotent fact append; estimated facts never advance actual progress, while a manual actual fact must have a strictly greater standard-node sequence than latestActualNode and actual EMPTY_RETURNED stops
|
||||
'409':
|
||||
description: A0409 when expectedVersion is stale or a manual actual node is equal to or earlier than latestActualNode
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}
|
||||
components:
|
||||
parameters:
|
||||
@@ -90,6 +92,10 @@ components:
|
||||
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
|
||||
schemas:
|
||||
CarrierCode: {type: string, enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]}
|
||||
TaskCarrierCode:
|
||||
type: string
|
||||
enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE, OTHER]
|
||||
description: OTHER is frozen on the ocean booking and creates a manual-only task after outbound confirmation.
|
||||
TrackingType: {type: string, enum: [BOOKING_NO, BILL_OF_LADING, CONTAINER_NO]}
|
||||
QueryStatus: {type: string, enum: [READY, MANUAL_REQUIRED, STOPPED]}
|
||||
StandardNode:
|
||||
@@ -118,7 +124,7 @@ components:
|
||||
ManualNodeRequest:
|
||||
type: object
|
||||
required: [expectedVersion, standardNode, observedAt, reason]
|
||||
description: estimatedAt and actualAt cannot both be null; estimatedAt never advances the latest actual node.
|
||||
description: estimatedAt and actualAt cannot both be null; estimatedAt never advances the latest actual node; when actualAt is present, standardNode must have a strictly greater sequence than latestActualNode.
|
||||
properties:
|
||||
expectedVersion: {type: integer, minimum: 0}
|
||||
standardNode: {$ref: '#/components/schemas/StandardNode'}
|
||||
@@ -137,11 +143,15 @@ components:
|
||||
customerId: {type: integer, format: int64}
|
||||
oceanBookingId: {type: integer, format: int64}
|
||||
containerBookingId: {type: integer, format: int64, nullable: true}
|
||||
carrierCode: {$ref: '#/components/schemas/CarrierCode'}
|
||||
carrierCode: {$ref: '#/components/schemas/TaskCarrierCode'}
|
||||
trackingType: {$ref: '#/components/schemas/TrackingType'}
|
||||
trackingNo: {type: string}
|
||||
officialUrl: {type: string, nullable: true}
|
||||
accessMode: {type: string, nullable: true}
|
||||
accessMode:
|
||||
type: string
|
||||
enum: [PUBLIC_FORM, PUBLIC_WEB_PROXY, PUBLIC_HTML, HCAPTCHA, OFFICIAL_API, MANUAL]
|
||||
nullable: true
|
||||
description: MANUAL tasks preserve physical outbound confirmation and accept platform manual node facts without scheduler/provider calls.
|
||||
status: {$ref: '#/components/schemas/QueryStatus'}
|
||||
triggerSource: {type: string}
|
||||
latestActualNode: {allOf: [{$ref: '#/components/schemas/StandardNode'}], nullable: true}
|
||||
@@ -150,7 +160,10 @@ components:
|
||||
sourceUpdatedAt: {type: string, format: date-time, nullable: true}
|
||||
expiresAt: {type: string, format: date-time, nullable: true}
|
||||
resultPortScheduleId: {type: integer, format: int64, nullable: true}
|
||||
manualReason: {type: string, nullable: true}
|
||||
manualReason:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Explicit provider-disabled or unsupported-carrier reason; unsupported outbound carriers retain the booking shippingCompanyId/name here and through oceanBookingId.
|
||||
lastErrorCode: {type: string, nullable: true}
|
||||
lastErrorMessage: {type: string, nullable: true}
|
||||
stoppedAt: {type: string, format: date-time, nullable: true}
|
||||
|
||||
@@ -356,6 +356,16 @@ paths:
|
||||
required: true
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/ContainerDocumentAttachRequest'}}}
|
||||
responses: {'200': {description: 成功、文件归属不匹配或版本冲突}}
|
||||
/api/platform/v1/containers/{id}/clearance/document/{fileId}/download:
|
||||
get:
|
||||
summary: 下载货柜目的港清关附件
|
||||
description: 需要 platform:firstleg:customs:manage 权限;服务端校验当前 DESTINATION_CLEARANCE fileId 及 File 业务归属,完成态仍可下载,响应禁止缓存
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/ContainerBookingId'}
|
||||
- {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
responses:
|
||||
'200': {description: 附件二进制内容,包含 Content-Disposition 与 Cache-Control no-store}
|
||||
'404': {description: 当前附件不存在或归属不匹配}
|
||||
/api/platform/v1/containers/{id}/clearance/document/upload:
|
||||
post:
|
||||
summary: 直接选择文件上传或替换货柜目的港清关资料
|
||||
|
||||
@@ -99,6 +99,29 @@ paths:
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/DocumentAttachRequest'}}}
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/BusinessDocumentResponse'}
|
||||
/api/platform/v1/first-leg/loading-lists/{planId}/customs/document/{fileId}/download:
|
||||
get:
|
||||
summary: 下载装柜清单出口报关附件
|
||||
description: 完成态仍可下载。服务端校验 planId、warehouseId、当前 CUSTOMS_DECLARATION 的 fileId,以及 File 元数据 tenantId=0、businessType=LOADING_CUSTOMS_DECLARATION、businessId=planId;错误 fileId 或归属返回 A0404。响应使用 attachment 文件名并禁止缓存。
|
||||
x-permission: platform:firstleg:loading-list:list
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/PlanId'}
|
||||
- {$ref: '#/components/parameters/WarehouseId'}
|
||||
- name: fileId
|
||||
in: path
|
||||
required: true
|
||||
description: File 服务长整型文件 ID
|
||||
schema: {$ref: '#/components/schemas/LongId'}
|
||||
responses:
|
||||
'200':
|
||||
description: 报关附件二进制内容;包含 Content-Disposition,Cache-Control 为 no-store
|
||||
headers:
|
||||
Content-Disposition: {schema: {type: string}}
|
||||
Cache-Control: {schema: {type: string, example: no-store}}
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema: {type: string, format: binary}
|
||||
'404': {description: 装柜计划、仓库、当前附件或 File 归属不匹配}
|
||||
/api/platform/v1/first-leg/loading-lists/{planId}/customs/document/upload:
|
||||
post:
|
||||
summary: 直接选择文件上传或替换装柜清单出口报关文档
|
||||
|
||||
@@ -2,7 +2,12 @@ openapi: 3.0.3
|
||||
info: {title: Ocean Booking API, version: 2.0.0}
|
||||
paths:
|
||||
/api/platform/v1/ocean-bookings:
|
||||
get: {summary: List DRAFT and RELEASED bookings, responses: {'200': {description: Booking page}}}
|
||||
get:
|
||||
summary: List DRAFT and RELEASED bookings
|
||||
description: containerCount is the sum of quantity across all container requirements for the booking. It is zero when no requirement exists and never represents linked or ACTIVE containers.
|
||||
responses:
|
||||
'200':
|
||||
description: Booking page; each row uses the frozen requirement-total containerCount semantic
|
||||
post:
|
||||
summary: Create an editable DRAFT
|
||||
description: Creates only the booking aggregate and container requirements; no containerNo, container row, or allocation is created.
|
||||
@@ -28,6 +33,23 @@ paths:
|
||||
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
|
||||
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ReleaseRequest'}}}}
|
||||
responses: {'200': {description: RELEASED detail}}
|
||||
/api/platform/v1/ocean-bookings/link-candidates:
|
||||
get:
|
||||
summary: Page released booking requirement candidates for one container
|
||||
description: Requires platform:firstleg:ocean-booking:list. Returns only linkable requirement candidates: RELEASED, remainingQuantity greater than zero, and exact warehouseId, channelCode and containerType. Capacity and max weight are derived display snapshots and never eligibility fields. Allocated quantity counts only ACTIVE containers still pointing to the same booking requirement and is calculated by one bounded set query; booking detail calls are not used. An empty result is a normal successful page. Final target-container mutability is revalidated by the link command.
|
||||
x-permission: platform:firstleg:ocean-booking:list
|
||||
parameters:
|
||||
- {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
||||
- {name: channelCode, in: query, required: true, schema: {type: string, maxLength: 64}}
|
||||
- {name: containerType, in: query, required: true, schema: {type: string, maxLength: 32}}
|
||||
- {name: page, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, default: 1}}
|
||||
- {name: size, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20}}
|
||||
responses:
|
||||
'200':
|
||||
description: Business envelope containing a bounded candidate page
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/LinkCandidateResponse'}
|
||||
/api/platform/v1/ocean-bookings/channels:
|
||||
get:
|
||||
summary: List ACTIVE channels for an origin warehouse
|
||||
@@ -58,6 +80,11 @@ components:
|
||||
billOfLadingNo: {type: string, nullable: true}
|
||||
shippingCompanyId: {type: integer, format: int64, nullable: true}
|
||||
shippingCompanyName: {type: string, nullable: true}
|
||||
carrierCode:
|
||||
type: string
|
||||
enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE, OTHER]
|
||||
nullable: true
|
||||
description: Frozen standard carrier code. Required before RELEASE; OTHER means manual carrier-node tracking.
|
||||
bookingAgentId:
|
||||
type: integer
|
||||
format: int64
|
||||
@@ -94,6 +121,46 @@ components:
|
||||
channelName: {type: string}
|
||||
priceModel: {type: string}
|
||||
pricingTaxModes: {type: array, items: {type: string}}
|
||||
LinkCandidate:
|
||||
type: object
|
||||
required: [oceanBookingId, bookingNo, requirementId, containerType, capacityCbm, maxWeightKg, requiredQuantity, allocatedQuantity, remainingQuantity, customerName, warehouseName, channelName]
|
||||
properties:
|
||||
oceanBookingId: {type: string, pattern: '^[0-9]+$'}
|
||||
bookingNo: {type: string}
|
||||
requirementId: {type: string, pattern: '^[0-9]+$'}
|
||||
containerType: {type: string}
|
||||
capacityCbm: {type: number}
|
||||
maxWeightKg: {type: number}
|
||||
requiredQuantity: {type: integer, minimum: 1}
|
||||
allocatedQuantity: {type: integer, format: int64, minimum: 0}
|
||||
remainingQuantity: {type: integer, format: int64, minimum: 1}
|
||||
customerName: {type: string}
|
||||
warehouseName: {type: string}
|
||||
channelName: {type: string}
|
||||
LinkCandidatePage:
|
||||
type: object
|
||||
required: [records, page, size, total, pages]
|
||||
properties:
|
||||
records: {type: array, items: {$ref: '#/components/schemas/LinkCandidate'}}
|
||||
page: {type: integer, format: int64, minimum: 1}
|
||||
size: {type: integer, format: int64, minimum: 1}
|
||||
total: {type: integer, format: int64, minimum: 0}
|
||||
pages: {type: integer, format: int64, minimum: 0}
|
||||
LinkCandidateResponse:
|
||||
type: object
|
||||
required: [code, data]
|
||||
properties:
|
||||
code: {type: string, example: '200'}
|
||||
message: {type: string, nullable: true}
|
||||
data: {$ref: '#/components/schemas/LinkCandidatePage'}
|
||||
OceanBookingListRow:
|
||||
type: object
|
||||
properties:
|
||||
containerCount:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 0
|
||||
description: Sum of all container requirement quantities; independent of allocation, linking or unlinking.
|
||||
Detail:
|
||||
type: object
|
||||
required: [oceanBooking, parties, containerRequirements, containers, histories]
|
||||
|
||||
@@ -36,6 +36,12 @@ paths:
|
||||
description: '权限:platform:firstleg:container:list;Firstleg代理File内部下载,不向调用方暴露内部令牌'
|
||||
parameters: [{$ref: '#/components/parameters/ContainerId'}, {$ref: '#/components/parameters/DocumentType'}, {name: fileId, in: path, required: true, schema: {type: integer, format: int64}}]
|
||||
responses: {'200': {description: file bytes, content: {application/octet-stream: {schema: {type: string, format: binary}}}}}
|
||||
/api/platform/v1/containers/{id}/clearance/document/{fileId}/download:
|
||||
get:
|
||||
operationId: downloadContainerDestinationClearance
|
||||
description: '权限:platform:firstleg:customs:manage;校验当前 DESTINATION_CLEARANCE fileId 以及 File tenantId=0、businessType=CONTAINER_CLEARANCE_DOCUMENT、businessId=id;返回 attachment 与 no-store,完成态仍可下载'
|
||||
parameters: [{$ref: '#/components/parameters/ContainerId'}, {name: fileId, in: path, required: true, schema: {type: integer, format: int64}}]
|
||||
responses: {'200': {description: clearance file bytes, content: {application/octet-stream: {schema: {type: string, format: binary}}}}, '404': {description: current file or ownership mismatch}}
|
||||
components:
|
||||
parameters:
|
||||
ContainerId: {name: id, in: path, required: true, schema: {type: integer, format: int64}}
|
||||
@@ -51,7 +57,7 @@ components:
|
||||
container: {$ref: '#/components/schemas/Container'}
|
||||
loadingPlanId: {type: integer, format: int64}
|
||||
pda: {$ref: '#/components/schemas/PdaSummary'}
|
||||
documents: {type: array, items: {$ref: '#/components/schemas/Document'}}
|
||||
documents: {type: array, description: '按 documentType 稳定排序返回 DESTINATION_CLEARANCE、CUSTOMS_DECLARATION、DELIVERY_ORDER、PICKUP_ORDER 的当前唯一记录和真实 version', items: {$ref: '#/components/schemas/Document'}}
|
||||
exception: {$ref: '#/components/schemas/ExceptionState'}
|
||||
exceptionHistory: {type: array, items: {$ref: '#/components/schemas/ExceptionHistory'}}
|
||||
timeline: {type: array, items: {$ref: '#/components/schemas/TimelineEntry'}}
|
||||
@@ -59,7 +65,7 @@ components:
|
||||
PdaSummary: {type: object, properties: {taskId: {type: integer, format: int64}, loadingPlanId: {type: integer, format: int64}, containerBookingId: {type: integer, format: int64}, taskStatus: {type: string, description: '持久化任务态:LOADING/COMPLETED'}, workflowStatus: {type: string, enum: [IN_PROGRESS, AWAITING_REVIEW, COMPLETED], description: 'PDA作业展示态'}, taskVersion: {type: integer}, itemCount: {type: integer}, completedItemCount: {type: integer}, unresolvedItemCount: {type: integer}, photoCounts: {type: array, items: {$ref: '#/components/schemas/PhotoCount'}}, photos: {type: array, items: {$ref: '#/components/schemas/Photo'}}, exceptionReasons: {type: array, items: {type: string}}}}
|
||||
PhotoCount: {type: object, properties: {evidenceType: {type: string}, count: {type: integer}}}
|
||||
Photo: {type: object, properties: {evidenceId: {type: integer, format: int64}, tenantId: {type: integer, format: int64}, inboundId: {type: integer, format: int64}, evidenceType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, status: {type: string, enum: [ACTIVE, VOID]}, downloadPath: {type: string, nullable: true, description: '仅 ACTIVE 照片提供鉴权下载路径;VOID 历史照片为空'}, uploadedAt: {type: string, format: date-time}}}
|
||||
Document: {type: object, properties: {id: {type: integer, format: int64}, documentType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, fileHash: {type: string}, uploadedBy: {type: integer, format: int64}, uploadedByName: {type: string}, uploadedAt: {type: string, format: date-time}, version: {type: integer}, completed: {type: boolean}}}
|
||||
Document: {type: object, properties: {id: {type: integer, format: int64}, documentType: {type: string, enum: [DESTINATION_CLEARANCE, CUSTOMS_DECLARATION, DELIVERY_ORDER, PICKUP_ORDER]}, fileId: {type: integer, format: int64}, fileName: {type: string}, fileHash: {type: string}, uploadedBy: {type: integer, format: int64}, uploadedByName: {type: string}, uploadedAt: {type: string, format: date-time}, version: {type: integer, description: '当前唯一记录的真实乐观锁版本;更新时必须原样作为 expectedVersion'}, completed: {type: boolean}}}
|
||||
ExceptionState: {type: object, properties: {active: {type: boolean}, reason: {type: string}, markedBy: {type: integer, format: int64}, markedByName: {type: string}, markedAt: {type: string, format: date-time}}}
|
||||
ExceptionHistory: {type: object, properties: {id: {type: integer, format: int64}, action: {type: string, enum: [MARK, CANCEL]}, reason: {type: string}, operatorId: {type: integer, format: int64}, operatorName: {type: string}, occurredAt: {type: string, format: date-time}}}
|
||||
TimelineEntry: {type: object, properties: {eventType: {type: string}, detail: {type: string}, operatorId: {type: integer, format: int64}, operatorName: {type: string}, occurredAt: {type: string, format: date-time}}}
|
||||
|
||||
@@ -1,85 +1,117 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Phase 3 Domestic Outbound Dispatch API
|
||||
version: 1.0.0
|
||||
title: Phase 3 Container-level Domestic Outbound Dispatch API
|
||||
version: 2.0.0
|
||||
paths:
|
||||
/api/platform/v1/first-leg/outbound-dispatches:
|
||||
get:
|
||||
summary: Query outbound aggregates by order, inbound or container
|
||||
summary: Query the outbound aggregate for one container booking
|
||||
description: Returns one container-level aggregate containing every real inbound binding. No per-inbound partial outbound aggregate is exposed.
|
||||
x-permission: platform:firstleg:outbound-dispatch:list
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/OrderId'}
|
||||
- {$ref: '#/components/parameters/InboundId'}
|
||||
- {$ref: '#/components/parameters/ContainerBookingId'}
|
||||
- {$ref: '#/components/parameters/ContainerBookingIdQuery'}
|
||||
responses:
|
||||
'200':
|
||||
description: Business envelope containing matching aggregates, each with stable cabinetNo and real containerNo
|
||||
description: Business envelope containing a single matching aggregate in a list
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data: {type: array, items: {$ref: '#/components/schemas/Aggregate'}}
|
||||
/api/platform/v1/first-leg/outbound-dispatches/detail:
|
||||
/api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}:
|
||||
get:
|
||||
summary: Query one aggregate and its unified timeline
|
||||
summary: Query one container-level aggregate and unified timeline
|
||||
x-permission: platform:firstleg:outbound-dispatch:view
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/OrderId'}
|
||||
- {$ref: '#/components/parameters/InboundId'}
|
||||
- {$ref: '#/components/parameters/ContainerBookingId'}
|
||||
- {$ref: '#/components/parameters/ContainerBookingIdPath'}
|
||||
responses:
|
||||
'200':
|
||||
description: Business envelope containing aggregate detail
|
||||
description: Business envelope containing all inbound bindings and one visible dispatch version
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
aggregate: {$ref: '#/components/schemas/Aggregate'}
|
||||
/api/platform/v1/first-leg/outbound-dispatches/check-gate:
|
||||
data: {$ref: '#/components/schemas/Detail'}
|
||||
/api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/gate:
|
||||
get:
|
||||
summary: Evaluate the fail-closed domestic outbound gate
|
||||
summary: Evaluate every inbound binding in the fail-closed container outbound gate
|
||||
description: Loading evidence is evaluated once for the physical container/loading plan and the same result applies to every inbound binding. The customs gate is resolved once from the current container CUSTOMS_DECLARATION whose completedAt must be non-null; upload alone remains blocked and fl_customs_case is not an alternative gate fact. A missing standalone sailing schedule is reference-only and never affects gate.passed.
|
||||
x-permission: platform:firstleg:outbound-dispatch:check
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/OrderId'}
|
||||
- {$ref: '#/components/parameters/InboundId'}
|
||||
- {$ref: '#/components/parameters/ContainerBookingId'}
|
||||
- {$ref: '#/components/parameters/ContainerBookingIdPath'}
|
||||
responses:
|
||||
'200': {description: Business envelope containing every gate item}
|
||||
/api/platform/v1/first-leg/outbound-dispatches/confirm-dispatch:
|
||||
'200': {description: Business envelope containing the container gate and per-inbound gate results}
|
||||
/api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/confirm:
|
||||
post:
|
||||
summary: Confirm domestic dispatch and enter WAITING_FOR_LOADING
|
||||
summary: Atomically confirm domestic departure for the entire container
|
||||
description: Locks every real inbound binding, requires every gate to pass, advances all technical rows in one transaction, records one visible history, and creates the CarrierScheduleQuery/DEPARTED_WAREHOUSE fact once. No partial success is possible.
|
||||
x-permission: platform:firstleg:outbound-dispatch:confirm
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/ContainerBookingIdPath'}
|
||||
- {$ref: '#/components/parameters/IdempotencyKey'}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}
|
||||
responses:
|
||||
'200': {description: Business envelope; gate or version failures use business error code}
|
||||
/api/platform/v1/first-leg/outbound-dispatches/reverse-dispatch:
|
||||
'200': {description: Same key and same payload replays the single container-level result; stale version or changed payload returns A0409}
|
||||
/api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/reverse:
|
||||
post:
|
||||
summary: Reverse WAITING_FOR_LOADING only before maritime tracking starts
|
||||
description: Once an actual DEPARTED_WAREHOUSE node exists, reversal is rejected with business code A0409 so READY_FOR_DISPATCH cannot coexist with maritime tracking.
|
||||
summary: Atomically reverse the entire container before any actual shipping node exists
|
||||
description: Once an actual DEPARTED_WAREHOUSE node exists, reversal is rejected with business code A0409 so an outbound fact can never coexist with READY_FOR_DISPATCH.
|
||||
x-permission: platform:firstleg:outbound-dispatch:reverse
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/ContainerBookingIdPath'}
|
||||
- {$ref: '#/components/parameters/IdempotencyKey'}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}
|
||||
responses:
|
||||
'200': {description: Business envelope containing reverted aggregate}
|
||||
'200': {description: Business envelope containing the reverted container aggregate}
|
||||
components:
|
||||
parameters:
|
||||
OrderId: {name: orderId, in: query, schema: {type: integer, format: int64, minimum: 1}}
|
||||
InboundId: {name: inboundId, in: query, schema: {type: integer, format: int64, minimum: 1}}
|
||||
ContainerBookingId: {name: containerBookingId, in: query, schema: {type: integer, format: int64, minimum: 1}}
|
||||
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
|
||||
ContainerBookingIdQuery:
|
||||
name: containerBookingId
|
||||
in: query
|
||||
required: true
|
||||
schema: {type: integer, format: int64, minimum: 1}
|
||||
ContainerBookingIdPath:
|
||||
name: containerBookingId
|
||||
in: path
|
||||
required: true
|
||||
schema: {type: integer, format: int64, minimum: 1}
|
||||
IdempotencyKey:
|
||||
name: Idempotency-Key
|
||||
in: header
|
||||
required: true
|
||||
schema: {type: string, maxLength: 128}
|
||||
schemas:
|
||||
Aggregate:
|
||||
type: object
|
||||
required: [orderId, orderNo, inboundId, inboundCode, tenantId, inboundStatus, containerBookingId, cabinetNo, loadingPlanId, loadingPlanStatus, dispatchStatus, version, gate]
|
||||
required: [containerBookingId, cabinetNo, loadingPlanId, loadingPlanStatus, dispatchStatus, version, inbounds, gate]
|
||||
properties:
|
||||
containerBookingId: {type: integer, format: int64}
|
||||
cabinetNo: {type: string, description: Stable cabinet number}
|
||||
containerNo: {type: string, nullable: true, description: Real container number}
|
||||
loadingPlanId: {type: integer, format: int64}
|
||||
loadingPlanStatus: {type: string}
|
||||
oceanBookingId: {type: integer, format: int64, nullable: true}
|
||||
oceanBookingStatus: {type: string, nullable: true, description: Booking reference retained independently from the non-blocking standalone sailing schedule}
|
||||
truckAppointmentId: {type: integer, format: int64, nullable: true, description: Reference-only truck appointment; absence never blocks manual outbound confirmation}
|
||||
truckAppointmentStatus: {type: string, nullable: true, description: Reference-only status; PENDING or any non-terminal state never affects gate.passed}
|
||||
dispatchStatus: {type: string, description: One visible container-level status}
|
||||
version: {type: integer, minimum: 0, description: One optimistic-lock version shared by all technical inbound rows}
|
||||
inbounds:
|
||||
type: array
|
||||
minItems: 1
|
||||
items: {$ref: '#/components/schemas/InboundBinding'}
|
||||
gate: {$ref: '#/components/schemas/ContainerGateResult'}
|
||||
InboundBinding:
|
||||
type: object
|
||||
required: [orderId, orderNo, inboundId, inboundCode, tenantId, inboundStatus]
|
||||
properties:
|
||||
orderId: {type: integer, format: int64}
|
||||
orderNo: {type: string}
|
||||
@@ -89,25 +121,35 @@ components:
|
||||
customerId: {type: integer, format: int64, nullable: true}
|
||||
customerName: {type: string, nullable: true}
|
||||
inboundStatus: {type: string}
|
||||
containerBookingId: {type: integer, format: int64}
|
||||
cabinetNo: {type: string, description: 稳定柜次号}
|
||||
containerNo: {type: string, nullable: true, description: 真实集装箱号}
|
||||
loadingPlanId: {type: integer, format: int64}
|
||||
loadingPlanStatus: {type: string}
|
||||
pickTaskId: {type: integer, format: int64, nullable: true}
|
||||
pickTaskStatus: {type: string, nullable: true}
|
||||
oceanBookingId: {type: integer, format: int64, nullable: true}
|
||||
oceanBookingStatus: {type: string, nullable: true}
|
||||
truckAppointmentId: {type: integer, format: int64, nullable: true}
|
||||
truckAppointmentStatus: {type: string, nullable: true}
|
||||
dispatchStatus: {type: string}
|
||||
version: {type: integer, minimum: 0}
|
||||
gate: {type: object}
|
||||
ContainerGateResult:
|
||||
type: object
|
||||
required: [passed, inbounds, checkedAt]
|
||||
properties:
|
||||
passed: {type: boolean}
|
||||
inbounds:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [inboundId, inboundCode, gate]
|
||||
properties:
|
||||
inboundId: {type: integer, format: int64}
|
||||
inboundCode: {type: string}
|
||||
gate: {type: object}
|
||||
checkedAt: {type: string, format: date-time}
|
||||
Detail:
|
||||
type: object
|
||||
required: [aggregate, timeline]
|
||||
properties:
|
||||
aggregate: {$ref: '#/components/schemas/Aggregate'}
|
||||
timeline:
|
||||
type: array
|
||||
description: Unified immutable facts; a container confirm or reverse contributes one visible history item.
|
||||
items: {type: object}
|
||||
ActionRequest:
|
||||
type: object
|
||||
required: [inboundId, containerBookingId, expectedVersion]
|
||||
required: [expectedVersion]
|
||||
properties:
|
||||
inboundId: {type: integer, format: int64, minimum: 1}
|
||||
containerBookingId: {type: integer, format: int64, minimum: 1}
|
||||
expectedVersion: {type: integer, minimum: 0}
|
||||
remark: {type: string, maxLength: 500}
|
||||
|
||||
@@ -21,6 +21,7 @@ class BaiyueFirstlegApplicationContractTest {
|
||||
.contains("com.baiyue.firstleg.portschedule.mapper")
|
||||
.contains("com.baiyue.firstleg.customs.mapper")
|
||||
.contains("com.baiyue.firstleg.customerstatistics.adapter.out.persistence")
|
||||
.contains("com.baiyue.firstleg.pricebookquery.adapter.out.persistence")
|
||||
.doesNotContain("com.baiyue.firstleg.customerstatistics.mapper");
|
||||
}
|
||||
|
||||
|
||||
+5
@@ -33,6 +33,7 @@ class ContainerControllerContractTest {
|
||||
assertPermission("logisticsDocument", "platform:firstleg:container:list");
|
||||
assertPermission("uploadLogisticsDocument", "platform:firstleg:container:edit");
|
||||
assertPermission("downloadDocument", "platform:firstleg:container:list");
|
||||
assertPermission("downloadClearance", "platform:firstleg:customs:manage");
|
||||
}
|
||||
|
||||
@Test void overviewOpenApiFreezesContainerIdBasedPdaContract() throws Exception {
|
||||
@@ -40,6 +41,8 @@ class ContainerControllerContractTest {
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(openapi).contains("/api/platform/v1/containers/{id}/overview:",
|
||||
"/documents/{documentType}/{fileId}/download:", "ExceptionRequest",
|
||||
"/clearance/document/{fileId}/download:",
|
||||
"DESTINATION_CLEARANCE、CUSTOMS_DECLARATION、DELIVERY_ORDER、PICKUP_ORDER",
|
||||
"enum: [DELIVERY_ORDER, PICKUP_ORDER]", "Idempotency-Key", "photoCounts:",
|
||||
"/pda/photos/{fileId}/download:", "downloadPath:", "workflowStatus:",
|
||||
"AWAITING_REVIEW", "version: {type: integer, minimum: 0}",
|
||||
@@ -77,6 +80,8 @@ class ContainerControllerContractTest {
|
||||
.doesNotContain("tenantId")
|
||||
.doesNotContain("accountId");
|
||||
assertThat(openapi)
|
||||
.contains("/api/platform/v1/containers/{id}/clearance/document/{fileId}/download:")
|
||||
.contains("Content-Disposition 与 Cache-Control no-store")
|
||||
.contains("required: [oceanBookingId, requirementId, containerNo, expectedVersion]")
|
||||
.contains("required: [id, bookingNo, cabinetNo, sourceType, recordStatus")
|
||||
.contains("sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING]")
|
||||
|
||||
+19
@@ -16,6 +16,8 @@ import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
@@ -41,6 +43,8 @@ class LoadingListControllerContractTest {
|
||||
ConfirmedQuery.class);
|
||||
authority("confirmedDetail", "platform:firstleg:loading-list:list", AuthenticatedUser.class, long.class, long.class);
|
||||
authority("declaration", "platform:firstleg:loading-list:list", AuthenticatedUser.class, long.class, long.class);
|
||||
authority("downloadDeclaration", "platform:firstleg:loading-list:list", AuthenticatedUser.class,
|
||||
long.class, long.class, long.class);
|
||||
authority("attachDeclaration", "platform:firstleg:customs:manage", AuthenticatedUser.class,
|
||||
long.class, long.class, String.class, DocumentAttachRequest.class);
|
||||
authority("uploadDeclaration", "platform:firstleg:customs:manage", AuthenticatedUser.class,
|
||||
@@ -88,6 +92,8 @@ class LoadingListControllerContractTest {
|
||||
.contains("oceanBookingStatus")
|
||||
.contains("pdaTaskId")
|
||||
.contains("cabinetNo")
|
||||
.contains("LOADING_CUSTOMS_DECLARATION")
|
||||
.contains("Cache-Control 为 no-store")
|
||||
.doesNotContain("internalContainerNo")
|
||||
.doesNotContain("rollback")
|
||||
.doesNotContain("delete:")
|
||||
@@ -118,6 +124,18 @@ class LoadingListControllerContractTest {
|
||||
assertThat(method.getParameters()[6].getAnnotation(RequestPart.class)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void customsDownloadUsesStablePlanPathAndBinaryResponse() throws Exception {
|
||||
Method method = PlatformLoadingListController.class.getDeclaredMethod("downloadDeclaration",
|
||||
AuthenticatedUser.class, long.class, long.class, long.class);
|
||||
assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly(
|
||||
"/loading-lists/{planId}/customs/document/{fileId}/download");
|
||||
assertThat(method.getReturnType()).isEqualTo(ResponseEntity.class);
|
||||
assertThat(method.getParameters()[1].getName()).isEqualTo("planId");
|
||||
assertThat(method.getParameters()[2].getName()).isEqualTo("fileId");
|
||||
assertThat(method.getParameters()[3].getName()).isEqualTo("warehouseId");
|
||||
}
|
||||
|
||||
@Test
|
||||
void yamlParses() throws Exception {
|
||||
String yaml = new ClassPathResource("openapi/loading-lists-v1.yaml")
|
||||
@@ -134,6 +152,7 @@ class LoadingListControllerContractTest {
|
||||
"/api/platform/v1/first-leg/loading-lists/{planId}",
|
||||
"/api/platform/v1/first-leg/loading-lists/{planId}/customs",
|
||||
"/api/platform/v1/first-leg/loading-lists/{planId}/customs/document",
|
||||
"/api/platform/v1/first-leg/loading-lists/{planId}/customs/document/{fileId}/download",
|
||||
"/api/platform/v1/first-leg/loading-lists/{planId}/customs/document/upload",
|
||||
"/api/platform/v1/first-leg/loading-lists/{planId}/customs/complete");
|
||||
Map<String, Object> components = stringMap(document.get("components"));
|
||||
|
||||
@@ -6,7 +6,7 @@ import com.baiyue.firstleg.domain.PriceModel;
|
||||
import com.baiyue.firstleg.domain.PricingTaxMode;
|
||||
import com.baiyue.firstleg.domain.PricingDestinationType;
|
||||
import com.baiyue.firstleg.dto.PriceBookDtos;
|
||||
import com.baiyue.firstleg.mapper.PriceBookQueryMapper;
|
||||
import com.baiyue.firstleg.pricebookquery.adapter.out.persistence.PriceBookQueryMapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
+31
-12
@@ -1,7 +1,7 @@
|
||||
package com.baiyue.firstleg.customs.service;
|
||||
|
||||
import com.baiyue.firstleg.customs.entity.CustomsCaseEntity;
|
||||
import com.baiyue.firstleg.customs.repository.CustomsRepository;
|
||||
import com.baiyue.firstleg.customs.mapper.CustomsCaseMapper;
|
||||
import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor.Context;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -22,17 +22,36 @@ class CustomsDispatchGateContributorTest {
|
||||
.isEqualTo("INVALID_CUSTOMER_SCOPE");
|
||||
}
|
||||
|
||||
@Test void passesOnlyCanonicalClearanceCompletedCase() {
|
||||
CustomsCaseEntity value = new CustomsCaseEntity();
|
||||
value.setStatus("RECEIPT_AVAILABLE");
|
||||
when(repository.gateCase(10001L, 21L, 31L, 81L)).thenReturn(value);
|
||||
assertThat(contributor.assess(context()).passed()).isFalse();
|
||||
value.setStatus("CLEARANCE_COMPLETED");
|
||||
assertThat(contributor.assess(context()))
|
||||
.satisfies(item -> {
|
||||
assertThat(item.passed()).isTrue();
|
||||
assertThat(item.requirement()).isEqualTo("CLEARANCE_COMPLETED");
|
||||
});
|
||||
@Test void uploadedButIncompleteDeclarationRemainsBlocked() {
|
||||
when(repository.loadingDeclarationCompleted(81L)).thenReturn(false);
|
||||
|
||||
assertThat(contributor.assess(context())).satisfies(item -> {
|
||||
assertThat(item.passed()).isFalse();
|
||||
assertThat(item.actual()).isEqualTo("MISSING");
|
||||
assertThat(item.requirement()).isEqualTo("COMPLETED");
|
||||
});
|
||||
}
|
||||
|
||||
@Test void completedLoadingDeclarationPassesWithoutReadingCustomsCase() {
|
||||
when(repository.loadingDeclarationCompleted(81L)).thenReturn(true);
|
||||
|
||||
assertThat(contributor.assess(context())).satisfies(item -> {
|
||||
assertThat(item.passed()).isTrue();
|
||||
assertThat(item.actual()).isEqualTo("COMPLETED");
|
||||
assertThat(item.requirement()).isEqualTo("COMPLETED");
|
||||
});
|
||||
verify(repository).loadingDeclarationCompleted(81L);
|
||||
verify(repository, never()).gateCase(anyLong(), anyLong(), anyLong(), anyLong());
|
||||
}
|
||||
|
||||
@Test void mapperUsesOnlyCompletedLoadingDeclarationDocumentFact() throws Exception {
|
||||
String sql = String.join(" ", CustomsCaseMapper.class
|
||||
.getMethod("completedLoadingDeclaration", long.class)
|
||||
.getAnnotation(org.apache.ibatis.annotations.Select.class).value());
|
||||
assertThat(sql)
|
||||
.contains("FROM fl_container_document", "container_booking_id=#{containerBookingId}")
|
||||
.contains("document_type='CUSTOMS_DECLARATION'", "completed_at IS NOT NULL")
|
||||
.doesNotContain("fl_customs_case", "CLEARANCE_COMPLETED");
|
||||
}
|
||||
|
||||
private static Context context() { return new Context(10001L, 10002L, 21L, 31L, 81L, 91L, null); }
|
||||
|
||||
+40
-1
@@ -20,6 +20,7 @@ class PlatformOceanBookingControllerContractTest {
|
||||
.containsExactly("/api/platform/v1/ocean-bookings");
|
||||
Map<String, String> permissions = Map.ofEntries(
|
||||
Map.entry("page", "platform:firstleg:ocean-booking:list"),
|
||||
Map.entry("linkCandidates", "platform:firstleg:ocean-booking:list"),
|
||||
Map.entry("channels", "platform:firstleg:ocean-booking:list"),
|
||||
Map.entry("detail", "platform:firstleg:ocean-booking:list"),
|
||||
Map.entry("create", "platform:firstleg:ocean-booking:create"),
|
||||
@@ -30,7 +31,8 @@ class PlatformOceanBookingControllerContractTest {
|
||||
Method method = method(name);
|
||||
assertThat(method.getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('" + permission + "')");
|
||||
if (!name.equals("page") && !name.equals("detail") && !name.equals("channels")) {
|
||||
if (!name.equals("page") && !name.equals("detail") && !name.equals("channels")
|
||||
&& !name.equals("linkCandidates")) {
|
||||
assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull();
|
||||
}
|
||||
});
|
||||
@@ -57,6 +59,43 @@ class PlatformOceanBookingControllerContractTest {
|
||||
.doesNotContain("change-request", "change-confirm", "/cancel:", "containers[].containerNo");
|
||||
}
|
||||
|
||||
@Test
|
||||
void linkCandidatesUsesDedicatedGetPathAndStablePageContract() throws Exception {
|
||||
Method method = method("linkCandidates");
|
||||
assertThat(method.getAnnotation(org.springframework.web.bind.annotation.GetMapping.class).value())
|
||||
.containsExactly("/link-candidates");
|
||||
String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml"));
|
||||
assertThat(yaml)
|
||||
.contains("/api/platform/v1/ocean-bookings/link-candidates:")
|
||||
.contains("x-permission: platform:firstleg:ocean-booking:list")
|
||||
.contains("warehouseId", "channelCode", "containerType")
|
||||
.contains("LinkCandidateResponse", "requiredQuantity", "allocatedQuantity", "remainingQuantity")
|
||||
.contains("Capacity and max weight are derived display snapshots and never eligibility fields")
|
||||
.contains("Returns only linkable requirement candidates")
|
||||
.contains("An empty result is a normal successful page")
|
||||
.contains("booking detail calls are not used")
|
||||
.doesNotContain("mismatchReasons", "disabledReason");
|
||||
}
|
||||
|
||||
@Test
|
||||
void openApiDefinesContainerCountAsRequirementQuantityTotal() throws Exception {
|
||||
String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml"));
|
||||
assertThat(yaml)
|
||||
.contains("containerCount is the sum of quantity across all container requirements")
|
||||
.contains("never represents linked or ACTIVE containers")
|
||||
.contains("Sum of all container requirement quantities; independent of allocation, linking or unlinking.");
|
||||
}
|
||||
|
||||
@Test
|
||||
void openApiFreezesStandardCarrierCodeOnBooking() throws Exception {
|
||||
String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml"));
|
||||
assertThat(yaml)
|
||||
.contains("carrierCode:")
|
||||
.contains("enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE, OTHER]")
|
||||
.contains("Required before RELEASE")
|
||||
.contains("OTHER means manual carrier-node tracking");
|
||||
}
|
||||
|
||||
private static Method method(String name) {
|
||||
return Arrays.stream(PlatformOceanBookingController.class.getDeclaredMethods())
|
||||
.filter(method -> method.getName().equals(name)).findFirst().orElseThrow();
|
||||
|
||||
+41
-1
@@ -1,6 +1,8 @@
|
||||
package com.baiyue.firstleg.oceanbooking.repository;
|
||||
|
||||
import com.baiyue.firstleg.oceanbooking.mapper.OceanBookingMapper;
|
||||
import com.baiyue.firstleg.oceanbooking.mapper.OceanBookingContainerAllocationMapper;
|
||||
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidateQuery;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -20,7 +22,10 @@ class OceanBookingMapperContractTest {
|
||||
.contains("booking.status=#{query.status}")
|
||||
.contains("booking.booking_no LIKE")
|
||||
.contains("party.party_type='SHIPPER'", "party.party_type='CONSIGNEE'")
|
||||
.contains("fl_ocean_booking_container_allocation", "container.record_status='ACTIVE'", "container_count")
|
||||
.contains("COALESCE((SELECT SUM(requirement.quantity)")
|
||||
.contains("FROM fl_ocean_booking_container_requirement requirement")
|
||||
.contains("requirement.ocean_booking_id=booking.id),0) container_count")
|
||||
.doesNotContain("fl_ocean_booking_container_allocation allocation\n JOIN fl_container_booking")
|
||||
.contains("ORDER BY booking.updated_at DESC,booking.id DESC");
|
||||
}
|
||||
|
||||
@@ -36,4 +41,39 @@ class OceanBookingMapperContractTest {
|
||||
.contains("status='DRAFT' AND version=#{version}")
|
||||
.doesNotContain("CONFIRMED", "CHANGE_PENDING", "CANCELLED", "SUBMITTED", "FAILED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void linkCandidatesUsesOneBoundedSetQueryAndExactRemainingSemantics() throws Exception {
|
||||
String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("selectLinkCandidates",
|
||||
com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, LinkCandidateQuery.class)
|
||||
.getAnnotation(Select.class).value());
|
||||
assertThat(sql)
|
||||
.contains("booking.status='RELEASED'")
|
||||
.contains("booking.warehouse_id=#{query.warehouseId}")
|
||||
.contains("booking.channel_code=#{query.channelCode}")
|
||||
.contains("requirement.container_type=#{query.containerType}")
|
||||
.contains("FROM fl_ocean_booking_container_allocation")
|
||||
.contains("container.record_status='ACTIVE'")
|
||||
.contains("container.ocean_booking_id=allocation.ocean_booking_id")
|
||||
.contains("container.ocean_booking_requirement_id=allocation.requirement_id")
|
||||
.contains("GROUP BY allocation.ocean_booking_id,allocation.requirement_id")
|
||||
.contains("requirement.quantity-COALESCE(allocated.allocated_quantity,0) remaining_quantity")
|
||||
.contains("requirement.quantity>COALESCE(allocated.allocated_quantity,0)")
|
||||
.doesNotContain("SELECT booking.*",
|
||||
"requirement.capacity_cbm=#{query.capacityCbm}",
|
||||
"requirement.max_weight_kg=#{query.maxWeightKg}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void finalLinkLimitCountsOnlyActiveContainersStillPointingAtTheSameRequirement() throws Exception {
|
||||
String sql = String.join("\n", OceanBookingContainerAllocationMapper.class
|
||||
.getDeclaredMethod("countActiveLinked", long.class, long.class)
|
||||
.getAnnotation(Select.class).value());
|
||||
assertThat(sql)
|
||||
.contains("container.record_status='ACTIVE'")
|
||||
.contains("container.ocean_booking_id=allocation.ocean_booking_id")
|
||||
.contains("container.ocean_booking_requirement_id=allocation.requirement_id")
|
||||
.contains("allocation.ocean_booking_id=#{oceanBookingId}")
|
||||
.contains("allocation.requirement_id=#{requirementId}");
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -24,6 +24,8 @@ class OceanBookingSqlContractTest {
|
||||
.contains("MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'")
|
||||
.contains("service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填'")
|
||||
.contains("freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填'")
|
||||
.contains("carrier_code VARCHAR(16) NULL COMMENT '船司标准编码")
|
||||
.contains("carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')")
|
||||
.contains("container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '明确引用现有柜次ID'")
|
||||
.contains("REFERENCES fl_container_booking(id)")
|
||||
.contains("REFERENCES fl_ocean_booking(id) ON DELETE CASCADE")
|
||||
|
||||
+61
-5
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.firstleg.oceanbooking.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
|
||||
import com.baiyue.firstleg.client.WarehouseSupplierClient;
|
||||
@@ -9,8 +10,8 @@ import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementE
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingOperationEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRepository;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase;
|
||||
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
|
||||
import com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -33,7 +34,7 @@ import static org.mockito.Mockito.*;
|
||||
|
||||
class OceanBookingServiceTest {
|
||||
@Mock private OceanBookingRepository repository;
|
||||
@Mock private PriceBookQueryRepository priceBooks;
|
||||
@Mock private PriceBookQueryUseCase priceBooks;
|
||||
@Mock private WarehouseSupplierSnapshotService suppliers;
|
||||
@Mock private WarehouseMasterDataClient warehouses;
|
||||
@Mock private FirstlegDocumentNumberGenerator documentNumbers;
|
||||
@@ -170,6 +171,60 @@ class OceanBookingServiceTest {
|
||||
verify(repository, times(2)).page(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pagePreservesRequirementTotalContainerCountsForSingleMultipleAndEmptyDrafts() {
|
||||
OceanBookingEntity one45Hc = booking("RELEASED", 1); one45Hc.setContainerCount(1L);
|
||||
OceanBookingEntity multipleTypes = booking("RELEASED", 1); multipleTypes.setId(2L);
|
||||
multipleTypes.setContainerCount(4L);
|
||||
OceanBookingEntity emptyDraft = booking("DRAFT", 0); emptyDraft.setId(3L);
|
||||
emptyDraft.setContainerCount(0L);
|
||||
when(repository.page(any())).thenReturn(new PageResult<>(
|
||||
List.of(one45Hc, multipleTypes, emptyDraft), 1, 20, 3, 1));
|
||||
|
||||
PageResult<OceanBookingEntity> result = service.page(new Query(null, null, null, 1, 20));
|
||||
|
||||
assertThat(result.records()).extracting(OceanBookingEntity::getContainerCount)
|
||||
.containsExactly(1L, 4L, 0L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void linkCandidatesDefaultsPaginationAndDelegatesOneNormalizedSetQuery() {
|
||||
LinkCandidate candidate = new LinkCandidate(101L, "OB-260818-0001", 201L, "40HC",
|
||||
new BigDecimal("76"), new BigDecimal("28000"), 3, 1, 2,
|
||||
"客户A", "深圳中转仓", "美西海运");
|
||||
when(repository.linkCandidates(any())).thenReturn(new PageResult<>(List.of(candidate), 1, 20, 1, 1));
|
||||
|
||||
PageResult<LinkCandidate> result = service.linkCandidates(new LinkCandidateQuery(701L,
|
||||
" SEA-US ", " 40HC ", null, null));
|
||||
|
||||
assertThat(result.records()).containsExactly(candidate);
|
||||
assertThat(result.records().get(0).remainingQuantity()).isEqualTo(2);
|
||||
ArgumentCaptor<LinkCandidateQuery> query = ArgumentCaptor.forClass(LinkCandidateQuery.class);
|
||||
verify(repository).linkCandidates(query.capture());
|
||||
assertThat(query.getValue().channelCode()).isEqualTo("SEA-US");
|
||||
assertThat(query.getValue().containerType()).isEqualTo("40HC");
|
||||
assertThat(query.getValue().page()).isEqualTo(1L);
|
||||
assertThat(query.getValue().size()).isEqualTo(20L);
|
||||
verify(repository, never()).find(anyLong());
|
||||
verify(repository, never()).requirements(anyLong());
|
||||
verify(repository, never()).containers(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void linkCandidatesReturnsNormalEmptyPageWithOneSetQueryAndNoDiagnosticFields() {
|
||||
when(repository.linkCandidates(any())).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
|
||||
|
||||
PageResult<LinkCandidate> result = service.linkCandidates(
|
||||
new LinkCandidateQuery(701L, "SEA-US", "45HC", null, null));
|
||||
|
||||
assertThat(result.records()).isEmpty();
|
||||
assertThat(result.total()).isZero();
|
||||
assertThat(result.pages()).isZero();
|
||||
verify(repository, times(1)).linkCandidates(any());
|
||||
assertThat(LinkCandidate.class.getRecordComponents()).extracting(java.lang.reflect.RecordComponent::getName)
|
||||
.doesNotContain("mismatchReasons", "disabledReason", "warehouseId", "channelCode");
|
||||
}
|
||||
|
||||
@Test
|
||||
void releaseRejectsInvalidScheduleWithoutTransition() {
|
||||
OceanBookingEntity draft = completeBooking("DRAFT", 3);
|
||||
@@ -204,12 +259,12 @@ class OceanBookingServiceTest {
|
||||
}
|
||||
|
||||
private static DraftData partialDraft(String remark) {
|
||||
return new DraftData(null, null, null, null, null, null, null, null, null,
|
||||
return new DraftData(null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null, null, null, remark, null, null);
|
||||
}
|
||||
|
||||
private static DraftData completeDraft() {
|
||||
return new DraftData(100L, "客户", "SEA-US", 701L, "BL-001", 71L, "船公司",
|
||||
return new DraftData(100L, "客户", "SEA-US", 701L, "BL-001", 71L, "船公司", "MATSON",
|
||||
81L, null, "VESSEL", "V001", "CNSZX", "深圳", "USLAX", "洛杉矶",
|
||||
"LAX", "CY-CY", "PREPAID", LocalDateTime.of(2026, 9, 1, 0, 0),
|
||||
LocalDateTime.of(2026, 9, 20, 0, 0), null,
|
||||
@@ -224,6 +279,7 @@ class OceanBookingServiceTest {
|
||||
OceanBookingEntity value = booking(status, version);
|
||||
value.setCustomerId(100L); value.setCustomerName("客户"); value.setChannelCode("SEA-US");
|
||||
value.setWarehouseId(701L); value.setBillOfLadingNo("BL-001"); value.setShippingCompanyId(71L);
|
||||
value.setCarrierCode("MATSON");
|
||||
value.setBookingAgentId(81L); value.setVesselName("VESSEL"); value.setVoyageNo("V001");
|
||||
value.setServiceTerm("CY-CY"); value.setFreightTerm("PREPAID");
|
||||
value.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0));
|
||||
|
||||
+43
-9
@@ -10,9 +10,25 @@ class OutboundDispatchReadMapperContractTest {
|
||||
@Test
|
||||
void bindingProjectsStableCabinetAndRealContainerNumbersSeparately() throws Exception {
|
||||
String sql = String.join("\n", OutboundDispatchReadMapper.class
|
||||
.getMethod("bindings", Long.class, Long.class, Long.class)
|
||||
.getMethod("bindings", long.class)
|
||||
.getAnnotation(Select.class).value());
|
||||
assertThat(sql).contains("booking.cabinet_no cabinetNo", "booking.container_no containerNo");
|
||||
assertThat(sql)
|
||||
.contains("booking.cabinet_no cabinetNo", "booking.container_no containerNo")
|
||||
.contains("booking.id=#{containerBookingId}")
|
||||
.doesNotContain("#{orderId}", "#{inboundId}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void actionQueryLocksEveryRealInboundBindingForTheContainer() throws Exception {
|
||||
String sql = String.join("\n", OutboundDispatchReadMapper.class
|
||||
.getMethod("bindingsForUpdate", long.class)
|
||||
.getAnnotation(Select.class).value());
|
||||
|
||||
assertThat(sql)
|
||||
.contains("booking.id=#{containerBookingId}")
|
||||
.contains("item.deleted=0", "inbound.deleted=0")
|
||||
.contains("FOR UPDATE")
|
||||
.doesNotContain("LIMIT 1");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -31,19 +47,37 @@ class OutboundDispatchReadMapperContractTest {
|
||||
.getAnnotation(Select.class).value()[0];
|
||||
|
||||
assertThat(sql)
|
||||
.contains("seal_no<>''")
|
||||
.contains("evidence.seal_no<>''")
|
||||
.doesNotContain("<", ">");
|
||||
}
|
||||
|
||||
@Test
|
||||
void oceanAndTruckQueriesUseTenantIsolationKey() throws Exception {
|
||||
String oceanSql = OutboundDispatchReadMapper.class.getMethod("ocean", long.class, long.class)
|
||||
.getAnnotation(Select.class).value()[0];
|
||||
String truckSql = OutboundDispatchReadMapper.class.getMethod("truck", long.class, long.class, long.class)
|
||||
void evidenceGateAggregatesOnePhysicalEvidenceSetAcrossTheWholeLoadingPlan() throws Exception {
|
||||
String sql = OutboundDispatchReadMapper.class.getMethod("evidence", long.class, long.class)
|
||||
.getAnnotation(Select.class).value()[0];
|
||||
|
||||
assertThat(oceanSql).contains("booking.customer_id=#{tenantId}").doesNotContain("#{customerId}");
|
||||
assertThat(truckSql).contains("customer_id=#{tenantId}").doesNotContain("#{customerId}");
|
||||
assertThat(sql)
|
||||
.contains("plan.id=#{loadingPlanId}", "plan.container_booking_id=#{containerBookingId}")
|
||||
.contains("item.loading_plan_id=plan.id", "item.deleted=0")
|
||||
.contains("evidence.inbound_id=item.inbound_id", "evidence.tenant_id=item.account_id")
|
||||
.contains("COUNT(DISTINCT CASE", "evidence.evidence_type")
|
||||
.doesNotContain("WHERE tenant_id=#{tenantId}", "inbound_id=#{inboundId}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void oceanAndTruckQueriesAreResolvedOnceFromTheContainerRoot() throws Exception {
|
||||
String oceanSql = OutboundDispatchReadMapper.class.getMethod("ocean", long.class)
|
||||
.getAnnotation(Select.class).value()[0];
|
||||
String truckSql = OutboundDispatchReadMapper.class.getMethod("truck", long.class, long.class)
|
||||
.getAnnotation(Select.class).value()[0];
|
||||
|
||||
assertThat(oceanSql)
|
||||
.contains("allocation.container_booking_id=#{containerBookingId}")
|
||||
.contains("booking.customer_id customerId")
|
||||
.doesNotContain("#{tenantId}");
|
||||
assertThat(truckSql)
|
||||
.contains("container_booking_id=#{containerBookingId}", "loading_plan_id=#{loadingPlanId}")
|
||||
.doesNotContain("#{tenantId}");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+208
-99
@@ -9,9 +9,12 @@ import com.baiyue.firstleg.outbound.dispatch.mapper.OutboundDispatchReadMapper;
|
||||
import com.baiyue.firstleg.outbound.dispatch.repository.OutboundDispatchRepository;
|
||||
import com.baiyue.firstleg.outbound.dispatch.service.OutboundDispatchService;
|
||||
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.stream.LongStream;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
@@ -19,159 +22,264 @@ import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class OutboundDispatchServiceTest {
|
||||
private final OutboundDispatchRepository repository = mock(OutboundDispatchRepository.class);
|
||||
private OutboundDispatchRepository repository;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
repository = mock(OutboundDispatchRepository.class);
|
||||
when(repository.timeline(any(), any(), any())).thenReturn(List.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
void gateFailsClosedWhenCustomsAndScheduleModulesAreUnavailable() {
|
||||
OutboundDispatchService service = new OutboundDispatchService(repository, List.of());
|
||||
stubReadyChain();
|
||||
void containerEvidenceFailureFailsEveryBindingConsistently() {
|
||||
OutboundDispatchService service = serviceWithoutCarrier();
|
||||
stubContainer();
|
||||
when(repository.evidence(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.Evidence(4, 1));
|
||||
|
||||
GateResult result = service.checkGate(new Query(null, 11L, 22L));
|
||||
ContainerGateResult result = service.checkGate(22L);
|
||||
|
||||
assertThat(result.passed()).isFalse();
|
||||
assertThat(result.items()).filteredOn(item -> !item.passed())
|
||||
.extracting(GateItem::code).containsExactlyInAnyOrder("CUSTOMS_CLEARANCE", "SAILING_SCHEDULE");
|
||||
assertThat(result.inbounds()).hasSize(2);
|
||||
assertThat(result.inbounds()).allSatisfy(inbound -> assertThat(inbound.gate().items())
|
||||
.filteredOn(item -> "LOADING_EVIDENCE_COMPLETE".equals(item.code()))
|
||||
.extracting(GateItem::passed).containsExactly(false));
|
||||
verify(repository, times(1)).evidence(22L, 44L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aggregateUsesTenantKeyWhenCustomerEntityIdDiffers() {
|
||||
OutboundDispatchService service = new OutboundDispatchService(repository, List.of());
|
||||
stubReadyChain();
|
||||
void tenInboundBindingsShareOneCompleteEvidenceSetAndOneContainerCustomsResult() {
|
||||
OutboundDispatchGateContributor customs = mock(OutboundDispatchGateContributor.class);
|
||||
when(customs.code()).thenReturn("CUSTOMS_CLEARANCE");
|
||||
when(customs.assess(any())).thenReturn(new GateItem(
|
||||
"CUSTOMS_CLEARANCE", "报关完成", true, "CLEARANCE_COMPLETED", "CLEARANCE_COMPLETED"));
|
||||
OutboundDispatchService service = new OutboundDispatchService(repository, List.of(customs), null);
|
||||
List<OutboundDispatchReadMapper.Binding> tenBindings = LongStream.rangeClosed(11L, 20L)
|
||||
.mapToObj(id -> binding(id, "I-" + id)).toList();
|
||||
stubContainer(tenBindings);
|
||||
|
||||
service.checkGate(new Query(null, 11L, 22L));
|
||||
ContainerGateResult result = service.checkGate(22L);
|
||||
|
||||
verify(repository).ocean(22L, 7L);
|
||||
verify(repository).truck(22L, 44L, 7L);
|
||||
verify(repository, never()).ocean(22L, 8L);
|
||||
verify(repository, never()).truck(22L, 44L, 8L);
|
||||
assertThat(result.passed()).isTrue();
|
||||
assertThat(result.inbounds()).hasSize(10).allSatisfy(value -> assertThat(value.gate().passed()).isTrue());
|
||||
verify(repository, times(1)).evidence(22L, 44L);
|
||||
verify(customs, times(1)).assess(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmAdvancesDispatchWithoutMutatingLoadedInbound() {
|
||||
OutboundDispatchService service = new OutboundDispatchService(repository,
|
||||
List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")));
|
||||
stubReadyChain();
|
||||
OutboundDispatchEntity state = new OutboundDispatchEntity();
|
||||
state.setId(33L); state.setTenantId(7L); state.setOrderId(10L); state.setInboundId(11L);
|
||||
state.setContainerBookingId(22L); state.setStatus("READY_FOR_DISPATCH"); state.setVersion(0);
|
||||
when(repository.dispatch(7L, 11L, 22L)).thenReturn(state);
|
||||
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-1"), anyString())).thenReturn(true);
|
||||
when(repository.confirm(33L, 0, 9L, "admin", "ok")).thenReturn(1);
|
||||
when(repository.completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-1")).thenReturn(1);
|
||||
when(repository.timeline(any(), any(), any())).thenReturn(List.of());
|
||||
void confirmAllowsMissingTruckAppointmentWhenEveryAuthoritativeGatePasses() {
|
||||
OutboundDispatchService service = serviceWithoutCarrier();
|
||||
stubContainer();
|
||||
when(repository.truck(22L, 44L)).thenReturn(null);
|
||||
stubConfirmMutation("no-truck");
|
||||
|
||||
service.confirm(9L, "admin", "confirm-1", new ActionRequest(11L, 22L, 0, "ok"));
|
||||
Detail result = service.confirm(22L, 9L, "admin", "no-truck", new ActionRequest(0, null));
|
||||
|
||||
verify(repository).confirm(33L, 0, 9L, "admin", "ok");
|
||||
verify(repository).history(argThat(history -> "CONFIRM_DISPATCH".equals(history.getAction())
|
||||
&& "WAITING_FOR_LOADING".equals(history.getToStatus())));
|
||||
verify(repository).completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-1");
|
||||
assertThat(result.aggregate().truckAppointmentId()).isNull();
|
||||
assertThat(result.aggregate().gate().passed()).isTrue();
|
||||
verify(repository, times(2)).confirm(anyLong(), eq(0), eq(9L), eq("admin"), isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmCreatesCanonicalCarrierScheduleTaskInTheSameTransaction() {
|
||||
void confirmAllowsPendingTruckAppointmentWhenEveryAuthoritativeGatePasses() {
|
||||
OutboundDispatchService service = serviceWithoutCarrier();
|
||||
stubContainer();
|
||||
when(repository.truck(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "PENDING"));
|
||||
stubConfirmMutation("pending-truck");
|
||||
|
||||
Detail result = service.confirm(22L, 9L, "admin", "pending-truck", new ActionRequest(0, null));
|
||||
|
||||
assertThat(result.aggregate().truckAppointmentStatus()).isEqualTo("PENDING");
|
||||
assertThat(result.aggregate().gate().passed()).isTrue();
|
||||
verify(repository, times(2)).confirm(anyLong(), eq(0), eq(9L), eq("admin"), isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmAllowsMissingSailingScheduleContributor() {
|
||||
OutboundDispatchService service = serviceWithoutCarrier();
|
||||
stubContainer();
|
||||
stubConfirmMutation("no-schedule");
|
||||
|
||||
Detail result = service.confirm(22L, 9L, "admin", "no-schedule", new ActionRequest(0, null));
|
||||
|
||||
assertThat(result.aggregate().gate().passed()).isTrue();
|
||||
assertThat(result.aggregate().gate().inbounds()).allSatisfy(inbound -> assertThat(inbound.gate().items())
|
||||
.extracting(GateItem::code).doesNotContain("SAILING_SCHEDULE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailReturnsOneVersionAndAllInboundBindings() {
|
||||
OutboundDispatchService service = serviceWithoutCarrier();
|
||||
stubContainer();
|
||||
OutboundDispatchEntity first = state(31L, 11L, "WAITING_FOR_LOADING", 3);
|
||||
OutboundDispatchEntity second = state(32L, 12L, "WAITING_FOR_LOADING", 3);
|
||||
when(repository.dispatch(7L, 11L, 22L)).thenReturn(first);
|
||||
when(repository.dispatch(7L, 12L, 22L)).thenReturn(second);
|
||||
|
||||
Detail detail = service.detail(22L);
|
||||
|
||||
assertThat(detail.aggregate().inbounds()).extracting(InboundBinding::inboundId).containsExactly(11L, 12L);
|
||||
assertThat(detail.aggregate().dispatchStatus()).isEqualTo("WAITING_FOR_LOADING");
|
||||
assertThat(detail.aggregate().version()).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmLocksAndAdvancesAllBindingsButWritesOneHistoryAndOneShippingFact() {
|
||||
CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class);
|
||||
OutboundDispatchService service = new OutboundDispatchService(repository,
|
||||
List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")), carrierSchedules);
|
||||
stubReadyChain();
|
||||
OutboundDispatchEntity state = new OutboundDispatchEntity();
|
||||
state.setId(33L); state.setTenantId(7L); state.setInboundId(11L);
|
||||
state.setContainerBookingId(22L); state.setStatus("READY_FOR_DISPATCH"); state.setVersion(0);
|
||||
when(repository.dispatch(7L, 11L, 22L)).thenReturn(state);
|
||||
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-carrier"), anyString())).thenReturn(true);
|
||||
when(repository.confirm(33L, 0, 9L, "admin", null)).thenReturn(1);
|
||||
when(repository.completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-carrier")).thenReturn(1);
|
||||
when(repository.timeline(any(), any(), any())).thenReturn(List.of());
|
||||
OutboundDispatchService service = service(carrierSchedules);
|
||||
stubContainer();
|
||||
when(repository.lockBindings(22L)).thenReturn(bindings());
|
||||
OutboundDispatchEntity first = state(31L, 11L, "READY_FOR_DISPATCH", 0);
|
||||
OutboundDispatchEntity second = state(32L, 12L, "READY_FOR_DISPATCH", 0);
|
||||
when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(first);
|
||||
when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(second);
|
||||
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-container"), anyString())).thenReturn(true);
|
||||
when(repository.confirm(anyLong(), eq(0), eq(9L), eq("admin"), eq("ok"))).thenReturn(1);
|
||||
when(repository.completeOperation(31L, 9L, "CONFIRM_DISPATCH", "confirm-container")).thenReturn(1);
|
||||
|
||||
service.confirm(9L, "admin", "confirm-carrier", new ActionRequest(11L, 22L, 0, null));
|
||||
service.confirm(22L, 9L, "admin", "confirm-container", new ActionRequest(0, "ok"));
|
||||
|
||||
verify(carrierSchedules).ensureFromOutboundConfirm(7L, 66L, 22L, 9L, "admin");
|
||||
verify(repository).lockBindings(22L);
|
||||
verify(repository).confirm(31L, 0, 9L, "admin", "ok");
|
||||
verify(repository).confirm(32L, 0, 9L, "admin", "ok");
|
||||
verify(repository, times(1)).history(argThat(value -> "CONFIRM_DISPATCH".equals(value.getAction())
|
||||
&& value.getContainerBookingId().equals(22L)));
|
||||
verify(carrierSchedules, times(1)).ensureFromOutboundConfirm(7L, 66L, 22L, 9L, "admin");
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmNeverWritesStateWhenAnyRealGateFails() {
|
||||
OutboundDispatchService service = new OutboundDispatchService(repository,
|
||||
List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")));
|
||||
stubReadyChain();
|
||||
when(repository.pick(44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(55L, "LOADING"));
|
||||
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-2"), anyString())).thenReturn(true);
|
||||
void confirmDoesNotWriteAnyRowWhenOneInboundGateFails() {
|
||||
OutboundDispatchService service = serviceWithoutCarrier();
|
||||
stubContainer();
|
||||
when(repository.lockBindings(22L)).thenReturn(bindings());
|
||||
when(repository.evidence(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.Evidence(4, 1));
|
||||
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("blocked"), anyString())).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.confirm(9L, "admin", "confirm-2",
|
||||
new ActionRequest(11L, 22L, 0, null))).isInstanceOf(BizException.class).hasMessageContaining("门禁");
|
||||
assertThatThrownBy(() -> service.confirm(22L, 9L, "admin", "blocked", new ActionRequest(0, null)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("全部入库绑定");
|
||||
|
||||
verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any());
|
||||
verify(repository, never()).history(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void differingTechnicalRowVersionsAreRejectedAsContainerConflict() {
|
||||
OutboundDispatchService service = serviceWithoutCarrier();
|
||||
stubContainer();
|
||||
when(repository.lockBindings(22L)).thenReturn(bindings());
|
||||
when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(state(31L, 11L, "READY_FOR_DISPATCH", 0));
|
||||
when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(state(32L, 12L, "READY_FOR_DISPATCH", 1));
|
||||
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("mixed"), anyString())).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.confirm(22L, 9L, "admin", "mixed", new ActionRequest(0, null)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("版本不一致");
|
||||
|
||||
verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reverseIsRejectedAfterActualDepartedWarehouseFactExists() {
|
||||
void actualDepartedWarehouseFactBlocksContainerReverseBeforeAnyRowChanges() {
|
||||
CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class);
|
||||
OutboundDispatchService service = new OutboundDispatchService(repository, List.of(), carrierSchedules);
|
||||
stubReadyChain();
|
||||
OutboundDispatchEntity state = new OutboundDispatchEntity();
|
||||
state.setId(33L); state.setTenantId(7L); state.setInboundId(11L); state.setContainerBookingId(22L);
|
||||
state.setStatus("WAITING_FOR_LOADING"); state.setVersion(1);
|
||||
when(repository.dispatch(7L, 11L, 22L)).thenReturn(state);
|
||||
OutboundDispatchService service = service(carrierSchedules);
|
||||
stubContainer();
|
||||
when(repository.lockBindings(22L)).thenReturn(bindings());
|
||||
when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(state(31L, 11L, "WAITING_FOR_LOADING", 1));
|
||||
when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(state(32L, 12L, "WAITING_FOR_LOADING", 1));
|
||||
when(repository.claim(eq(9L), eq("REVERSE_DISPATCH"), eq("reverse-blocked"), anyString())).thenReturn(true);
|
||||
when(carrierSchedules.hasOutboundDepartureFact(7L, 66L, 22L)).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.reverse(9L, "admin", "reverse-blocked",
|
||||
new ActionRequest(11L, 22L, 1, "误操作")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("DEPARTED_WAREHOUSE");
|
||||
assertThatThrownBy(() -> service.reverse(22L, 9L, "admin", "reverse-blocked",
|
||||
new ActionRequest(1, "误操作"))).isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("DEPARTED_WAREHOUSE");
|
||||
|
||||
verify(repository, never()).reverse(anyLong(), anyInt(), anyLong(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectorsThatMatchMultipleContainerBindingsRequireDisambiguation() {
|
||||
OutboundDispatchService service = new OutboundDispatchService(repository, List.of());
|
||||
when(repository.bindings(any())).thenReturn(List.of(binding(), binding()));
|
||||
assertThatThrownBy(() -> service.detail(new Query(10L, null, null)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("多个出仓聚合");
|
||||
void sameIdempotencyKeyAndPayloadReplaysContainerResultWithoutMutation() {
|
||||
OutboundDispatchService service = serviceWithoutCarrier();
|
||||
stubContainer();
|
||||
AtomicReference<String> requestHash = new AtomicReference<>();
|
||||
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("replay"), anyString())).thenAnswer(invocation -> {
|
||||
requestHash.set(invocation.getArgument(3));
|
||||
return false;
|
||||
});
|
||||
OutboundDispatchOperationEntity existing = new OutboundDispatchOperationEntity();
|
||||
existing.setStatus("SUCCESS");
|
||||
when(repository.operation(9L, "CONFIRM_DISPATCH", "replay")).thenAnswer(invocation -> {
|
||||
existing.setRequestHash(requestHash.get());
|
||||
return existing;
|
||||
});
|
||||
|
||||
Detail replay = service.confirm(22L, 9L, "admin", "replay", new ActionRequest(0, null));
|
||||
|
||||
assertThat(replay.aggregate().containerBookingId()).isEqualTo(22L);
|
||||
verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleDispatchVersionIsRejectedWithoutPartialInboundAdvance() {
|
||||
OutboundDispatchService service = new OutboundDispatchService(repository,
|
||||
List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")));
|
||||
stubReadyChain();
|
||||
OutboundDispatchEntity state = new OutboundDispatchEntity();
|
||||
state.setId(33L); state.setTenantId(7L); state.setInboundId(11L); state.setContainerBookingId(22L);
|
||||
state.setStatus("READY_FOR_DISPATCH"); state.setVersion(2);
|
||||
when(repository.dispatch(7L, 11L, 22L)).thenReturn(state);
|
||||
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("stale"), anyString())).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.confirm(9L, "admin", "stale",
|
||||
new ActionRequest(11L, 22L, 1, null))).isInstanceOf(BizException.class).hasMessageContaining("刷新");
|
||||
}
|
||||
|
||||
@Test
|
||||
void reusedIdempotencyKeyWithDifferentHashIsRejected() {
|
||||
OutboundDispatchService service = new OutboundDispatchService(repository,
|
||||
List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")));
|
||||
stubReadyChain();
|
||||
void reusedIdempotencyKeyWithDifferentPayloadIsRejected() {
|
||||
OutboundDispatchService service = serviceWithoutCarrier();
|
||||
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("reused"), anyString())).thenReturn(false);
|
||||
OutboundDispatchOperationEntity existing = new OutboundDispatchOperationEntity();
|
||||
existing.setRequestHash("different"); existing.setStatus("SUCCESS");
|
||||
existing.setRequestHash("different");
|
||||
existing.setStatus("SUCCESS");
|
||||
when(repository.operation(9L, "CONFIRM_DISPATCH", "reused")).thenReturn(existing);
|
||||
|
||||
assertThatThrownBy(() -> service.confirm(9L, "admin", "reused",
|
||||
new ActionRequest(11L, 22L, 0, null))).isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("用于不同请求");
|
||||
assertThatThrownBy(() -> service.confirm(22L, 9L, "admin", "reused", new ActionRequest(0, null)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("用于不同请求");
|
||||
}
|
||||
|
||||
private void stubReadyChain() {
|
||||
when(repository.bindings(any())).thenReturn(List.of(binding()));
|
||||
private OutboundDispatchService serviceWithoutCarrier() {
|
||||
return service(null);
|
||||
}
|
||||
|
||||
private OutboundDispatchService service(CarrierScheduleQueryService carrierSchedules) {
|
||||
return new OutboundDispatchService(repository,
|
||||
List.of(passing("CUSTOMS_CLEARANCE")), carrierSchedules);
|
||||
}
|
||||
|
||||
private void stubContainer() {
|
||||
stubContainer(bindings());
|
||||
}
|
||||
|
||||
private void stubContainer(List<OutboundDispatchReadMapper.Binding> values) {
|
||||
when(repository.bindings(22L)).thenReturn(values);
|
||||
when(repository.pick(44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(55L, "COMPLETED"));
|
||||
when(repository.ocean(22L, 7L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(66L, "RELEASED"));
|
||||
when(repository.truck(22L, 44L, 7L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "DEPARTED"));
|
||||
when(repository.evidence(7L, 11L)).thenReturn(new OutboundDispatchReadMapper.Evidence(5, 1));
|
||||
when(repository.ocean(22L)).thenReturn(new OutboundDispatchReadMapper.OceanBinding(66L, "RELEASED", 7L));
|
||||
when(repository.truck(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "DEPARTED"));
|
||||
when(repository.evidence(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.Evidence(5, 1));
|
||||
}
|
||||
|
||||
private static OutboundDispatchReadMapper.Binding binding() {
|
||||
return new OutboundDispatchReadMapper.Binding(10L, "O-10", 11L, "I-11", 7L, 8L, "客户A",
|
||||
private void stubConfirmMutation(String key) {
|
||||
when(repository.lockBindings(22L)).thenReturn(bindings());
|
||||
when(repository.dispatchLocked(7L, 11L, 22L)).thenReturn(state(31L, 11L, "READY_FOR_DISPATCH", 0));
|
||||
when(repository.dispatchLocked(7L, 12L, 22L)).thenReturn(state(32L, 12L, "READY_FOR_DISPATCH", 0));
|
||||
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq(key), anyString())).thenReturn(true);
|
||||
when(repository.confirm(anyLong(), eq(0), eq(9L), eq("admin"), isNull())).thenReturn(1);
|
||||
when(repository.completeOperation(31L, 9L, "CONFIRM_DISPATCH", key)).thenReturn(1);
|
||||
}
|
||||
|
||||
private static List<OutboundDispatchReadMapper.Binding> bindings() {
|
||||
return List.of(binding(11L, "I-11"), binding(12L, "I-12"));
|
||||
}
|
||||
|
||||
private static OutboundDispatchReadMapper.Binding binding(long inboundId, String inboundCode) {
|
||||
return new OutboundDispatchReadMapper.Binding(10L, "O-10", inboundId, inboundCode, 7L, 8L, "客户A",
|
||||
"LOADED", 22L, "CAB-22", "C-22", 44L, "COMPLETED");
|
||||
}
|
||||
|
||||
private static OutboundDispatchEntity state(long id, long inboundId, String status, int version) {
|
||||
OutboundDispatchEntity value = new OutboundDispatchEntity();
|
||||
value.setId(id);
|
||||
value.setTenantId(7L);
|
||||
value.setOrderId(10L);
|
||||
value.setInboundId(inboundId);
|
||||
value.setContainerBookingId(22L);
|
||||
value.setStatus(status);
|
||||
value.setVersion(version);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static OutboundDispatchGateContributor passing(String code) {
|
||||
return new OutboundDispatchGateContributor() {
|
||||
@Override public String code() { return code; }
|
||||
@@ -180,4 +288,5 @@ class OutboundDispatchServiceTest {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+14
-6
@@ -38,17 +38,25 @@ class OutboundDispatchSqlContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void openApiPublishesSelectorsGateAndBothVersionedActions() throws Exception {
|
||||
void openApiPublishesOnlyContainerLevelGateDetailAndVersionedActions() throws Exception {
|
||||
String openApi = resource("openapi/phase3-outbound-dispatch-v1.yaml");
|
||||
assertThat(openApi)
|
||||
.contains("outbound-dispatches:")
|
||||
.contains("outbound-dispatches/detail:")
|
||||
.contains("outbound-dispatches/check-gate:")
|
||||
.contains("outbound-dispatches/confirm-dispatch:")
|
||||
.contains("outbound-dispatches/reverse-dispatch:")
|
||||
.contains("outbound-dispatches/{containerBookingId}:")
|
||||
.contains("outbound-dispatches/{containerBookingId}/gate:")
|
||||
.contains("outbound-dispatches/{containerBookingId}/confirm:")
|
||||
.contains("outbound-dispatches/{containerBookingId}/reverse:")
|
||||
.contains("every real inbound binding", "No partial success is possible")
|
||||
.contains("Once an actual DEPARTED_WAREHOUSE node exists")
|
||||
.contains("absence never blocks manual outbound confirmation")
|
||||
.contains("PENDING or any non-terminal state never affects gate.passed")
|
||||
.contains("Loading evidence is evaluated once for the physical container/loading plan")
|
||||
.contains("CUSTOMS_DECLARATION whose completedAt must be non-null")
|
||||
.contains("fl_customs_case is not an alternative gate fact")
|
||||
.contains("missing standalone sailing schedule is reference-only")
|
||||
.contains("expectedVersion")
|
||||
.contains("Idempotency-Key");
|
||||
.contains("Idempotency-Key")
|
||||
.doesNotContain("confirm-dispatch", "reverse-dispatch", "check-gate", "inboundId, containerBookingId");
|
||||
}
|
||||
|
||||
private static String migration() throws Exception { return resource("db/phase3-outbound-dispatch-schema.sql"); }
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package com.baiyue.firstleg.outbound.dispatch;
|
||||
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baiyue.firstleg.outbound.dispatch.controller.PlatformOutboundDispatchController;
|
||||
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.ActionRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PlatformOutboundDispatchControllerContractTest {
|
||||
@Test
|
||||
void exposesOnlyContainerLevelResourcePathsAndExistingPermissions() {
|
||||
assertThat(PlatformOutboundDispatchController.class.getAnnotation(RequestMapping.class).value())
|
||||
.containsExactly("/api/platform/v1/first-leg/outbound-dispatches");
|
||||
Map<String, String> permissions = Map.of(
|
||||
"list", "platform:firstleg:outbound-dispatch:list",
|
||||
"detail", "platform:firstleg:outbound-dispatch:view",
|
||||
"checkGate", "platform:firstleg:outbound-dispatch:check",
|
||||
"confirm", "platform:firstleg:outbound-dispatch:confirm",
|
||||
"reverse", "platform:firstleg:outbound-dispatch:reverse");
|
||||
permissions.forEach((name, permission) -> assertThat(method(name).getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('" + permission + "')"));
|
||||
|
||||
assertThat(method("detail").getAnnotation(GetMapping.class).value()).containsExactly("/{containerBookingId}");
|
||||
assertThat(method("checkGate").getAnnotation(GetMapping.class).value())
|
||||
.containsExactly("/{containerBookingId}/gate");
|
||||
assertThat(method("confirm").getAnnotation(PostMapping.class).value())
|
||||
.containsExactly("/{containerBookingId}/confirm");
|
||||
assertThat(method("reverse").getAnnotation(PostMapping.class).value())
|
||||
.containsExactly("/{containerBookingId}/reverse");
|
||||
}
|
||||
|
||||
@Test
|
||||
void actionBodyContainsOnlyContainerVersionAndRemarkAndAuditsContainerRoot() {
|
||||
assertThat(Arrays.stream(ActionRequest.class.getRecordComponents()).map(value -> value.getName()))
|
||||
.containsExactly("expectedVersion", "remark")
|
||||
.doesNotContain("inboundId", "containerBookingId");
|
||||
for (String name : List.of("confirm", "reverse")) {
|
||||
BusinessAudit audit = method(name).getAnnotation(BusinessAudit.class);
|
||||
assertThat(audit.resourceType()).isEqualTo("CONTAINER_BOOKING");
|
||||
assertThat(audit.resourceId()).isEqualTo("#containerBookingId");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyNonListOperationUsesOnlyContainerBookingIdPathTarget() {
|
||||
for (String name : List.of("detail", "checkGate", "confirm", "reverse")) {
|
||||
assertThat(Arrays.stream(method(name).getParameters())
|
||||
.filter(value -> value.isAnnotationPresent(PathVariable.class))
|
||||
.map(value -> value.getName())).containsExactly("containerBookingId");
|
||||
}
|
||||
}
|
||||
|
||||
private static Method method(String name) {
|
||||
return Arrays.stream(PlatformOutboundDispatchController.class.getDeclaredMethods())
|
||||
.filter(value -> value.getName().equals(name)).findFirst().orElseThrow();
|
||||
}
|
||||
}
|
||||
+2
@@ -24,6 +24,8 @@ class CarrierScheduleQueryOpenApiContractTest {
|
||||
.contains("enum: [READY, MANUAL_REQUIRED, STOPPED]")
|
||||
.contains("latestActualNode:")
|
||||
.contains("latestActualAt:")
|
||||
.contains("manual actual node is equal to or earlier than latestActualNode")
|
||||
.contains("strictly greater sequence than latestActualNode")
|
||||
.contains("internal execution fields are intentionally absent")
|
||||
.doesNotContain("enum: [READY, MANUAL_REQUIRED, ARRIVED")
|
||||
.doesNotContain("/{queryId}/attempts:")
|
||||
|
||||
+5
@@ -63,6 +63,8 @@ class CarrierScheduleQuerySqlContractTest {
|
||||
.contains("'EXCEPTION_OBSERVED','PROVIDER_QUERY','STOP'")
|
||||
.contains("provider_event_id")
|
||||
.contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'")
|
||||
.contains("'MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER'")
|
||||
.contains("'OFFICIAL_API','MANUAL'")
|
||||
.contains("lease_owner")
|
||||
.contains("consecutive_failures")
|
||||
.doesNotContain("status IN ('READY','MANUAL_REQUIRED','ARRIVED'");
|
||||
@@ -102,11 +104,14 @@ class CarrierScheduleQuerySqlContractTest {
|
||||
.contains("ADD COLUMN next_allowed_at DATETIME(3) NULL")
|
||||
.contains("ADD KEY idx_carrier_schedule_query_due (status,next_allowed_at,id)")
|
||||
.contains("DROP CHECK chk_carrier_schedule_query_access")
|
||||
.contains("DROP CHECK chk_carrier_schedule_query_carrier")
|
||||
.contains("DROP CHECK chk_carrier_schedule_query_status")
|
||||
.contains("DROP INDEX uk_carrier_schedule_query_scope")
|
||||
.contains("ADD UNIQUE KEY uk_carrier_schedule_query_scope")
|
||||
.contains("(customer_id,ocean_booking_id,container_scope_id)")
|
||||
.contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'")
|
||||
.contains("'MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER'")
|
||||
.contains("'OFFICIAL_API','MANUAL'")
|
||||
.contains("status IN ('READY','MANUAL_REQUIRED','STOPPED')")
|
||||
.contains("ARRIVED_DESTINATION_PORT")
|
||||
.contains("EMPTY_RETURNED")
|
||||
|
||||
+12
@@ -39,4 +39,16 @@ class CarrierScheduleAdapterRegistryTest {
|
||||
assertThatThrownBy(() -> registry.prepare("UNKNOWN", "BOOKING_NO", "X"))
|
||||
.hasMessageContaining("仅支持MATSON");
|
||||
}
|
||||
|
||||
@Test
|
||||
void outboundUnknownCarrierFallsBackToManualWithoutPretendingToBeAnotherProvider() {
|
||||
var result = registry.prepareOutbound("OTHER", "CONTAINER_NO", "CSNU1234567");
|
||||
|
||||
assertThat(result.carrierCode()).isEqualTo("OTHER");
|
||||
assertThat(result.accessMode()).isEqualTo("MANUAL");
|
||||
assertThat(result.status()).isEqualTo("MANUAL_REQUIRED");
|
||||
assertThat(result.coverageGapCode()).isEqualTo("UNSUPPORTED_CARRIER");
|
||||
assertThat(result.officialUrl()).isEmpty();
|
||||
assertThat(registry.prepareOutbound(null, "BOOKING_NO", "OB-1").carrierCode()).isEqualTo("OTHER");
|
||||
}
|
||||
}
|
||||
|
||||
+19
@@ -61,6 +61,25 @@ class CarrierScheduleNodeOutboxServiceTest {
|
||||
verify(mapper, never()).insert(org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void initialOutboundDepartureUsesVersionZeroWithoutEnvelopeFailure() throws Exception {
|
||||
CarrierScheduleQueryEntity query = query();
|
||||
query.setVersion(0);
|
||||
CarrierScheduleNodeFactEntity fact = fact();
|
||||
fact.setStandardNode("DEPARTED_WAREHOUSE"); fact.setNodeSequence(10);
|
||||
fact.setProviderCode("FIRSTLEG"); fact.setProviderEventId("OUTBOUND_CONFIRM");
|
||||
fact.setProviderNodeCode("OUTBOUND_CONFIRM");
|
||||
|
||||
service.publish(query, fact, "CSNU1234567");
|
||||
|
||||
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
|
||||
verify(mapper).insert(captor.capture());
|
||||
EventEnvelope envelope = objectMapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class);
|
||||
assertThat(envelope.aggregateVersion()).isZero();
|
||||
assertThat(envelope.handoverVersion()).isEqualTo(1L);
|
||||
assertThat(envelope.payload().path("standardNode").asText()).isEqualTo("DEPARTED_WAREHOUSE");
|
||||
}
|
||||
|
||||
private static CarrierScheduleQueryEntity query() {
|
||||
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity();
|
||||
value.setId(2088000000000000001L); value.setCustomerId(2088000000000000002L);
|
||||
|
||||
+115
-2
@@ -23,6 +23,7 @@ import org.mockito.Mock;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
@@ -47,12 +48,14 @@ class CarrierScheduleQueryServiceTest {
|
||||
@Mock private PortScheduleRepository portSchedules;
|
||||
@Mock private CarrierScheduleNodeOutboxService nodeOutbox;
|
||||
@InjectMocks private CarrierScheduleQueryService service;
|
||||
private CarrierScheduleAdapterRegistry adapters;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
set("objectMapper", new ObjectMapper().findAndRegisterModules());
|
||||
set("adapters", new CarrierScheduleAdapterRegistry());
|
||||
adapters = new CarrierScheduleAdapterRegistry();
|
||||
set("adapters", adapters);
|
||||
set("clock", Clock.fixed(Instant.parse("2026-08-10T04:00:00Z"), ZoneOffset.UTC));
|
||||
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
|
||||
when(repository.completeOperation(anyLong(), anyLong(), anyString(), anyString())).thenReturn(1);
|
||||
@@ -85,7 +88,7 @@ class CarrierScheduleQueryServiceTest {
|
||||
@Test
|
||||
void outboundConfirmationCreatesTaskWithContainerTrackingAndDepartureNode() {
|
||||
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
|
||||
booking.setShippingCompanyName("中远海运"); booking.setBookingNo("OB-260810-0001");
|
||||
booking.setShippingCompanyName("中远海运"); booking.setCarrierCode("COSCO"); booking.setBookingNo("OB-260810-0001");
|
||||
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
|
||||
container.setContainerNo("CSNU1234567");
|
||||
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
|
||||
@@ -96,12 +99,94 @@ class CarrierScheduleQueryServiceTest {
|
||||
|
||||
assertThat(result.getTriggerSource()).isEqualTo("OUTBOUND_CONFIRM");
|
||||
assertThat(result.getCarrierCode()).isEqualTo("COSCO");
|
||||
assertThat(result.getStatus()).isEqualTo("MANUAL_REQUIRED");
|
||||
assertThat(result.getAccessMode()).isEqualTo("OFFICIAL_API");
|
||||
assertThat(result.getManualReason()).contains("Cargo Tracking API");
|
||||
assertThat(result.getTrackingType()).isEqualTo("CONTAINER_NO");
|
||||
assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE");
|
||||
assertThat(result.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30));
|
||||
verify(repository).insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void outboundUnknownCarrierCreatesManualTaskAndPreservesBookingCarrierIdentity() {
|
||||
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
|
||||
booking.setShippingCompanyId(9988L); booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER");
|
||||
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
|
||||
container.setContainerNo("CSNU1234567");
|
||||
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
|
||||
when(portSchedules.containerBooking(20L)).thenReturn(container);
|
||||
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
|
||||
|
||||
CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin");
|
||||
|
||||
assertThat(result.getCarrierCode()).isEqualTo("OTHER");
|
||||
assertThat(result.getAccessMode()).isEqualTo("MANUAL");
|
||||
assertThat(result.getStatus()).isEqualTo("MANUAL_REQUIRED");
|
||||
assertThat(result.getManualReason()).contains("carrierCode=OTHER", "shippingCompanyId=9988", "shippingCompanyName=示例船公司");
|
||||
assertThat(result.getNextAllowedAt()).isNull();
|
||||
assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void outboundSupportedCarrierCreatesReadyTask() {
|
||||
ReflectionTestUtils.setField(adapters, "matsonAutomated", true);
|
||||
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
|
||||
booking.setShippingCompanyName("美森"); booking.setCarrierCode("MATSON");
|
||||
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
|
||||
container.setContainerNo("MATU4646247");
|
||||
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
|
||||
when(portSchedules.containerBooking(20L)).thenReturn(container);
|
||||
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
|
||||
|
||||
CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin");
|
||||
|
||||
assertThat(result.getCarrierCode()).isEqualTo("MATSON");
|
||||
assertThat(result.getStatus()).isEqualTo("READY");
|
||||
assertThat(result.getAccessMode()).isEqualTo("PUBLIC_WEB_PROXY");
|
||||
assertThat(result.getManualReason()).isNull();
|
||||
assertThat(result.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30));
|
||||
}
|
||||
|
||||
@Test
|
||||
void outboundReplayKeepsSingleTaskAndSingleDepartureFact() {
|
||||
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
|
||||
booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER");
|
||||
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
|
||||
container.setContainerNo("CSNU1234567");
|
||||
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
|
||||
when(portSchedules.containerBooking(20L)).thenReturn(container);
|
||||
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
|
||||
|
||||
CarrierScheduleQueryEntity first = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin");
|
||||
when(repository.findByBusinessScope(10001L, 10L, 20L)).thenReturn(first);
|
||||
CarrierScheduleQueryEntity replay = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin");
|
||||
|
||||
assertThat(replay).isSameAs(first);
|
||||
verify(repository, times(1)).insert(any(CarrierScheduleQueryEntity.class));
|
||||
verify(repository, times(1)).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void outboundPersistenceContractMismatchReturnsExplicitBusinessConflict() {
|
||||
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
|
||||
booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER");
|
||||
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
|
||||
container.setContainerNo("CSNU1234567");
|
||||
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
|
||||
when(portSchedules.containerBooking(20L)).thenReturn(container);
|
||||
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
|
||||
org.mockito.Mockito.doThrow(new DataIntegrityViolationException("check constraint rejected"))
|
||||
.when(repository).insert(any(CarrierScheduleQueryEntity.class));
|
||||
|
||||
assertThatThrownBy(() -> service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("船节点持久化契约与当前服务不一致")
|
||||
.hasMessageContaining("QUERY_TASK")
|
||||
.hasMessageContaining("OTHER/MANUAL");
|
||||
verify(repository, never()).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void providerActualObservationAdvancesBusinessNodeWithoutChangingTaskStatus() {
|
||||
CarrierScheduleQueryEntity task = task(0, 0);
|
||||
@@ -207,6 +292,34 @@ class CarrierScheduleQueryServiceTest {
|
||||
verify(repository).completeOperation(eq(1L), eq(7L), eq("manual-empty-1"), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void manualActualRejectsSameAndEarlierNodesIncludingSameKeyRetryWithoutDrift() {
|
||||
CarrierScheduleQueryEntity task = task(0, 3);
|
||||
task.setStatus("READY"); task.setLatestStandardNode("PICKED_UP");
|
||||
task.setLatestNodeObservedAt(NOW.minusDays(1));
|
||||
when(repository.lock(1L)).thenReturn(task);
|
||||
|
||||
ManualNodeRequest sameNode = new ManualNodeRequest(3, "PICKED_UP", NOW, null,
|
||||
NOW.plusDays(1), null, "Los Angeles", "重复确认提柜");
|
||||
assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-picked-up-again", sameNode))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("人工实际节点必须严格晚于当前最新实际节点");
|
||||
assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-picked-up-again", sameNode))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("人工实际节点必须严格晚于当前最新实际节点");
|
||||
assertThatThrownBy(() -> service.addManualNode(1L, 7L, "admin", "manual-pickup-ready-late",
|
||||
new ManualNodeRequest(3, "PICKUP_READY", NOW, null, NOW.plusDays(2), null,
|
||||
"Los Angeles", "倒序补录可提柜")))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("人工实际节点必须严格晚于当前最新实际节点");
|
||||
|
||||
verify(repository, never()).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class));
|
||||
verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString());
|
||||
verify(repository, never()).insert(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity.class));
|
||||
verify(repository, never()).completeOperation(anyLong(), anyLong(), anyString(), anyString());
|
||||
verify(nodeOutbox, never()).publish(any(), any(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void manualEstimatedFactAdvancesVersionWithoutAdvancingActualProgress() {
|
||||
CarrierScheduleQueryEntity before = task(0, 3);
|
||||
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package com.baiyue.firstleg.pricebookquery;
|
||||
|
||||
import com.baiyue.firstleg.pricebookquery.application.port.out.PriceBookQueryReader;
|
||||
import com.tngtech.archunit.core.domain.JavaClasses;
|
||||
import com.tngtech.archunit.core.importer.ClassFileImporter;
|
||||
import com.tngtech.archunit.core.importer.ImportOption;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage;
|
||||
import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage;
|
||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
|
||||
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
|
||||
|
||||
class PriceBookQueryArchitectureTest {
|
||||
private static final String FIRSTLEG = "com.baiyue.firstleg..";
|
||||
private static final String SLICE = "com.baiyue.firstleg.pricebookquery..";
|
||||
private static final String APPLICATION = "com.baiyue.firstleg.pricebookquery.application..";
|
||||
private static final String INPUT_PORT = "com.baiyue.firstleg.pricebookquery.application.port.in..";
|
||||
private static final String OUTPUT_PORT = "com.baiyue.firstleg.pricebookquery.application.port.out..";
|
||||
private static final String MODEL = "com.baiyue.firstleg.pricebookquery.application.model..";
|
||||
private static final String PERSISTENCE = "com.baiyue.firstleg.pricebookquery.adapter.out.persistence..";
|
||||
|
||||
private static final JavaClasses PRODUCTION_CLASSES = new ClassFileImporter()
|
||||
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
|
||||
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS)
|
||||
.importPackages("com.baiyue.firstleg");
|
||||
|
||||
@Test
|
||||
void applicationStaysFreeOfWebAndPersistenceFrameworks() {
|
||||
noClasses().that().resideInAPackage(APPLICATION)
|
||||
.should().dependOnClassesThat().resideInAnyPackage(
|
||||
"org.springframework.web..",
|
||||
"org.apache.ibatis..",
|
||||
"com.baomidou.mybatisplus..")
|
||||
.check(PRODUCTION_CLASSES);
|
||||
}
|
||||
|
||||
@Test
|
||||
void applicationOnlyDependsOnItsOwnSliceWithinFirstleg() {
|
||||
classes().that().resideInAPackage(APPLICATION)
|
||||
.should().onlyDependOnClassesThat(
|
||||
resideOutsideOfPackage(FIRSTLEG).or(resideInAPackage(APPLICATION)))
|
||||
.check(PRODUCTION_CLASSES);
|
||||
}
|
||||
|
||||
@Test
|
||||
void persistenceAdapterCollaboratesThroughTheOutputPortAndModel() {
|
||||
classes().that().resideInAPackage(PERSISTENCE)
|
||||
.should().onlyDependOnClassesThat(
|
||||
resideOutsideOfPackage(SLICE)
|
||||
.or(resideInAPackage(PERSISTENCE))
|
||||
.or(resideInAPackage(OUTPUT_PORT))
|
||||
.or(resideInAPackage(MODEL)))
|
||||
.andShould().beAssignableTo(PriceBookQueryReader.class)
|
||||
.check(PRODUCTION_CLASSES);
|
||||
}
|
||||
|
||||
@Test
|
||||
void consumersOnlySeeTheInputPortAndReadModel() {
|
||||
noClasses().that().resideOutsideOfPackage(SLICE)
|
||||
.should().dependOnClassesThat(
|
||||
resideInAPackage(SLICE)
|
||||
.and(resideOutsideOfPackage(INPUT_PORT))
|
||||
.and(resideOutsideOfPackage(MODEL)))
|
||||
.check(PRODUCTION_CLASSES);
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.baiyue.firstleg.pricebookquery;
|
||||
|
||||
import com.baiyue.firstleg.pricebookquery.application.PriceBookQueryService;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelPrefixRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.port.out.PriceBookQueryReader;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PriceBookQueryServiceTest {
|
||||
@Mock private PriceBookQueryReader reader;
|
||||
@InjectMocks private PriceBookQueryService service;
|
||||
|
||||
@Test
|
||||
void delegatesStableReadOperationsToTheOutputPort() {
|
||||
ChannelPrefixRow prefix = new ChannelPrefixRow("SEA", "IN", "OUT");
|
||||
when(reader.findActiveVersionId()).thenReturn(9L);
|
||||
when(reader.findChannelPrefix("SEA")).thenReturn(prefix);
|
||||
|
||||
assertThat(service.activeVersionId()).isEqualTo(9L);
|
||||
assertThat(service.channelPrefix("SEA")).isSameAs(prefix);
|
||||
verify(reader).findActiveVersionId();
|
||||
verify(reader).findChannelPrefix("SEA");
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsChannelAvailabilityAsApplicationLogic() {
|
||||
ChannelRow channel = new ChannelRow();
|
||||
channel.setChannelCode("SEA");
|
||||
when(reader.findChannels(9L, 3L)).thenReturn(List.of(channel));
|
||||
|
||||
assertThat(service.channelAvailable(9L, 3L, "SEA")).isTrue();
|
||||
assertThat(service.channelAvailable(9L, 3L, "AIR")).isFalse();
|
||||
verify(reader, org.mockito.Mockito.times(2)).findChannels(9L, 3L);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
package com.baiyue.firstleg.pricing;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
package com.baiyue.firstleg.pricing;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
-9
@@ -53,15 +53,6 @@ class ContainerBookingMapperContractTest {
|
||||
.contains("customer_item.account_id = #{query.customerId}");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("首次关联客户隔离查询使用原生不等号")
|
||||
void customerMismatchQueryUsesNativeSqlOperator() throws Exception {
|
||||
String sql = ContainerBookingMapper.class
|
||||
.getDeclaredMethod("hasMismatchedCustomerItems", long.class, long.class)
|
||||
.getAnnotation(Select.class).value()[0];
|
||||
assertThat(sql).contains("i.account_id<>#{customerId}").doesNotContain("<>");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("关联订舱 SQL 的预计到港参数与 Mapper 参数名一致")
|
||||
void linkReleasedBookingUsesDeclaredEstimatedArrivalParameter() {
|
||||
|
||||
+8
@@ -12,6 +12,14 @@ import java.nio.file.Path;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ContainerOverviewSqlContractTest {
|
||||
@Test void overviewIncludesExactlyTheFourCurrentBusinessDocumentTypes() throws Exception {
|
||||
String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod("documents", long.class)
|
||||
.getAnnotation(Select.class).value());
|
||||
assertThat(sql)
|
||||
.contains("'DESTINATION_CLEARANCE'", "'CUSTOMS_DECLARATION'", "'DELIVERY_ORDER'", "'PICKUP_ORDER'")
|
||||
.contains("ORDER BY document_type");
|
||||
}
|
||||
|
||||
@Test void pdaSummarySelectsRecordFieldsExplicitlyIncludingTaskVersion() throws Exception {
|
||||
String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod("pickTask", long.class)
|
||||
.getAnnotation(Select.class).value());
|
||||
|
||||
+15
@@ -116,6 +116,21 @@ class LoadingListQueryMapperContractTest {
|
||||
.contains("operator_id");
|
||||
}
|
||||
|
||||
@Test
|
||||
void deliveryAndPickupUseTheCanonicalDocumentInsertAndPlanHistoryMapper() throws Exception {
|
||||
String documentInsert = sql("insertDocument", Insert.class);
|
||||
String historyInsert = sql("insertHistory", Insert.class);
|
||||
assertThat(documentInsert)
|
||||
.contains("INSERT INTO fl_container_document")
|
||||
.contains("#{documentType}", "#{fileId}", "#{fileHash}")
|
||||
.contains("CURRENT_TIMESTAMP(3)", "version")
|
||||
.doesNotContain("CUSTOMS_DECLARATION", "DESTINATION_CLEARANCE");
|
||||
assertThat(historyInsert)
|
||||
.contains("INSERT INTO fl_loading_plan_history")
|
||||
.contains("#{planId}", "#{operationId}", "#{actionType}")
|
||||
.contains("#{beforeSnapshot}", "#{afterSnapshot}");
|
||||
}
|
||||
|
||||
private static String sql(String methodName, Class<?> annotationType) throws Exception {
|
||||
Method method = java.util.Arrays.stream(LoadingListQueryMapper.class.getDeclaredMethods())
|
||||
.filter(candidate -> candidate.getName().equals(methodName))
|
||||
|
||||
+4
-4
@@ -151,8 +151,8 @@ class SmartLoadingSqlContractTest {
|
||||
.contains("CREATE TRIGGER trg_container_booking_before_update")
|
||||
.contains("current_plan_status IN ('LOADING','COMPLETED')")
|
||||
.contains("装柜作业开始后仅允许修改集装箱号与放舱运输数据")
|
||||
.contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')")
|
||||
.contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')");
|
||||
.contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')")
|
||||
.contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')");
|
||||
}
|
||||
|
||||
private static void assertSimpleCustomsDocumentContract(String sql) {
|
||||
@@ -161,8 +161,8 @@ class SmartLoadingSqlContractTest {
|
||||
.contains("file_hash VARCHAR(64) NULL")
|
||||
.contains("completed_by BIGINT UNSIGNED NULL")
|
||||
.contains("completed_at DATETIME(3) NULL")
|
||||
.contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')")
|
||||
.contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')")
|
||||
.contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')")
|
||||
.contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')")
|
||||
.doesNotContain("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN\n SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读");
|
||||
}
|
||||
|
||||
|
||||
+16
-1
@@ -47,7 +47,13 @@ class ContainerOverviewServiceTest {
|
||||
71L, 81L, 91L, "LOADING_DOOR_OPEN", 101L, "door.jpg", "ACTIVE", LocalDateTime.now()),
|
||||
new ContainerOverviewMapper.PhotoRow(
|
||||
72L, 81L, 91L, "LOADING_DOOR_OPEN", 102L, "void.jpg", "VOID", LocalDateTime.now())));
|
||||
when(mapper.documents(41L)).thenReturn(java.util.List.of());
|
||||
com.baiyue.firstleg.entity.ContainerDocumentEntity customs = new com.baiyue.firstleg.entity.ContainerDocumentEntity();
|
||||
customs.setId(103L); customs.setDocumentType("CUSTOMS_DECLARATION"); customs.setFileId(104L);
|
||||
customs.setFileName("customs.pdf"); customs.setVersion(3);
|
||||
com.baiyue.firstleg.entity.ContainerDocumentEntity clearance = new com.baiyue.firstleg.entity.ContainerDocumentEntity();
|
||||
clearance.setId(105L); clearance.setDocumentType("DESTINATION_CLEARANCE"); clearance.setFileId(106L);
|
||||
clearance.setFileName("clearance.pdf"); clearance.setVersion(1);
|
||||
when(mapper.documents(41L)).thenReturn(java.util.List.of(customs, clearance));
|
||||
when(mapper.exceptions(41L)).thenReturn(java.util.List.of());
|
||||
when(mapper.transport(41L)).thenReturn(java.util.List.of());
|
||||
when(mapper.pickTimeline(61L)).thenReturn(java.util.List.of());
|
||||
@@ -66,6 +72,15 @@ class ContainerOverviewServiceTest {
|
||||
assertThat(count.evidenceType()).isEqualTo("LOADING_DOOR_OPEN");
|
||||
assertThat(count.count()).isEqualTo(1);
|
||||
});
|
||||
assertThat(overview.documents()).extracting(document -> document.documentType())
|
||||
.containsExactly("CUSTOMS_DECLARATION", "DESTINATION_CLEARANCE");
|
||||
assertThat(overview.documents()).filteredOn(document -> "CUSTOMS_DECLARATION".equals(document.documentType()))
|
||||
.singleElement().satisfies(document -> assertThat(document.version()).isEqualTo(3));
|
||||
assertThat(overview.documents()).filteredOn(document -> "DESTINATION_CLEARANCE".equals(document.documentType()))
|
||||
.singleElement().satisfies(document -> {
|
||||
assertThat(document.fileId()).isEqualTo(106L);
|
||||
assertThat(document.version()).isEqualTo(1);
|
||||
});
|
||||
}
|
||||
|
||||
@Test void downloadsOnlyActivePhotoOwnedByContainerLoadingPlan() {
|
||||
|
||||
@@ -17,8 +17,8 @@ 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.pricebookquery.application.model.PriceBookQueryRows.ChannelRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase;
|
||||
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
|
||||
@@ -51,7 +51,7 @@ class ContainerServiceTest {
|
||||
@MockBean private SmartLoadingRepository smartLoadingRepository;
|
||||
@MockBean private LoadingPickRepository loadingPickRepository;
|
||||
@MockBean private LoadingPickService loadingPickService;
|
||||
@MockBean private PriceBookQueryRepository priceBooks;
|
||||
@MockBean private PriceBookQueryUseCase priceBooks;
|
||||
@MockBean private OceanBookingRepository oceanBookings;
|
||||
@MockBean private WarehouseSupplierSnapshotService suppliers;
|
||||
@MockBean private FirstlegDocumentNumberGenerator documentNumbers;
|
||||
@@ -288,7 +288,7 @@ class ContainerServiceTest {
|
||||
void linksReleasedBookingAfterPreloading() {
|
||||
ContainerBookingEntity container = booking("EDITING", 2);
|
||||
container.setWarehouseId(101L); container.setChannelCode("SEA-US"); container.setContainerType("40HC");
|
||||
container.setCapacityCbm(new BigDecimal("76.0000")); container.setMaxWeightKg(new BigDecimal("28000.000"));
|
||||
container.setCapacityCbm(new BigDecimal("85.5000")); container.setMaxWeightKg(new BigDecimal("27500.000"));
|
||||
OceanBookingEntity booking = new OceanBookingEntity();
|
||||
booking.setId(21L); booking.setStatus("RELEASED"); booking.setWarehouseId(101L); booking.setChannelCode("SEA-US");
|
||||
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
|
||||
@@ -296,7 +296,7 @@ class ContainerServiceTest {
|
||||
when(repository.find(11L)).thenReturn(container, container);
|
||||
when(oceanBookings.find(21L)).thenReturn(booking);
|
||||
when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
|
||||
when(oceanBookings.allocations(21L)).thenReturn(List.of());
|
||||
when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(0L);
|
||||
when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU7654321"),
|
||||
same(booking), eq(2), eq(9L), eq("admin"))).thenReturn(1);
|
||||
|
||||
@@ -311,6 +311,24 @@ class ContainerServiceTest {
|
||||
verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("首次关联拒绝装柜仓库不一致")
|
||||
void rejectsLinkWhenWarehouseDiffers() {
|
||||
assertThreeFieldMismatch(102L, "SEA-US", "40HC");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("首次关联拒绝物流渠道不一致")
|
||||
void rejectsLinkWhenChannelDiffers() {
|
||||
assertThreeFieldMismatch(101L, "SEA-EU", "40HC");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("首次关联拒绝柜型不一致")
|
||||
void rejectsLinkWhenContainerTypeDiffers() {
|
||||
assertThreeFieldMismatch(101L, "SEA-US", "45HC");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("未发布订舱不可关联")
|
||||
void rejectsDraftBooking() {
|
||||
@@ -333,12 +351,10 @@ class ContainerServiceTest {
|
||||
booking.setId(21L); booking.setStatus("RELEASED");
|
||||
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
|
||||
requirement.setId(31L); requirement.setQuantity(1);
|
||||
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity();
|
||||
allocation.setRequirementId(31L);
|
||||
when(repository.find(11L)).thenReturn(container);
|
||||
when(oceanBookings.find(21L)).thenReturn(booking);
|
||||
when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
|
||||
when(oceanBookings.allocations(21L)).thenReturn(List.of(allocation));
|
||||
when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(1L);
|
||||
|
||||
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
|
||||
new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2)))
|
||||
@@ -347,6 +363,28 @@ class ContainerServiceTest {
|
||||
any(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
private void assertThreeFieldMismatch(long bookingWarehouseId, String bookingChannel,
|
||||
String requirementType) {
|
||||
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
|
||||
container.setWarehouseId(101L); container.setChannelCode("SEA-US");
|
||||
container.setContainerType("40HC");
|
||||
OceanBookingEntity oceanBooking = new OceanBookingEntity();
|
||||
oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED");
|
||||
oceanBooking.setWarehouseId(bookingWarehouseId); oceanBooking.setChannelCode(bookingChannel);
|
||||
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
|
||||
requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType(requirementType);
|
||||
when(repository.find(11L)).thenReturn(container);
|
||||
when(oceanBookings.find(21L)).thenReturn(oceanBooking);
|
||||
when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
|
||||
when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(0L);
|
||||
|
||||
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
|
||||
new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("仓库、渠道或柜型不一致");
|
||||
verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(),
|
||||
any(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("完全空的模拟柜直接解绑并删除当前allocation,不依赖MERGED来源柜")
|
||||
void unlinksEmptySimulationDirectly() {
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import com.baiyue.firstleg.domain.CustomerDimensionBoxType;
|
||||
import com.baiyue.firstleg.entity.CustomerDimensionConfigEntity;
|
||||
import com.baiyue.firstleg.pricing.FbaWarehouseRateEstimator;
|
||||
import com.baiyue.firstleg.repository.CustomerDimensionConfigRepository;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@@ -11,7 +11,7 @@ import com.baiyue.firstleg.entity.InboundPricingEntity;
|
||||
import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity;
|
||||
import com.baiyue.firstleg.pricing.ActivePriceBookEstimator;
|
||||
import com.baiyue.firstleg.repository.FbaPricingRepository;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
+21
-2
@@ -138,11 +138,30 @@ class LoadingCompletedOutboxServiceTest {
|
||||
}
|
||||
|
||||
@Test void snapshotOnlyPublishesFinalizedLoadedInbounds() throws Exception {
|
||||
String sql = String.join(" ", LoadingCompletedEventMapper.class.getMethod("rows", long.class)
|
||||
String rowsSql = String.join(" ", LoadingCompletedEventMapper.class.getMethod("rows", long.class)
|
||||
.getAnnotation(Select.class).value()).replaceAll("\\s+", " ");
|
||||
String palletsSql = String.join(" ", LoadingCompletedEventMapper.class.getMethod("palletRows", long.class)
|
||||
.getAnnotation(Select.class).value()).replaceAll("\\s+", " ");
|
||||
|
||||
assertThat(sql).contains("p.plan_status = 'COMPLETED'")
|
||||
assertThat(rowsSql).contains("p.plan_status = 'COMPLETED'")
|
||||
.contains("o.process_status = 'LOADED'");
|
||||
for (String sql : List.of(rowsSql, palletsSql)) {
|
||||
assertThat(sql)
|
||||
.contains("actual_pick.loading_plan_item_id = i.id")
|
||||
.contains("actual_pick.item_status IN ('PICKED','PARTIAL')")
|
||||
.contains("actual_pick.picked_package_count > 0")
|
||||
.contains("actual_pick.picked_package_count = i.package_count")
|
||||
.contains("actual_task.id = actual_pick.pick_task_id")
|
||||
.contains("actual_task.loading_plan_id =")
|
||||
.doesNotContain("'SKIPPED'")
|
||||
.doesNotContain("'PENDING'")
|
||||
.doesNotContain("'PICKING'");
|
||||
}
|
||||
assertThat(palletsSql)
|
||||
.contains("label.tenant_id = i.account_id")
|
||||
.contains("label.inbound_detail_id = i.inbound_detail_id")
|
||||
.contains("label.batch_status = 'ACTIVE'")
|
||||
.doesNotContain("label.inbound_id = i.inbound_id");
|
||||
}
|
||||
|
||||
@Test void publishesDropshipPalletSkuAndCartonSnapshots() throws Exception {
|
||||
|
||||
@@ -36,7 +36,9 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -450,16 +452,16 @@ class LoadingListServiceTest {
|
||||
String contentHash = sha256Bytes(content);
|
||||
var file = new MockMultipartFile("file", "customs.pdf", "application/pdf", content);
|
||||
var metadata = new LoadingDocumentFileClient.Metadata(101L, 0L,
|
||||
"LOADING_CUSTOMS_DOCUMENT", "10", "customs.pdf", contentHash, "application/pdf",
|
||||
"LOADING_CUSTOMS_DECLARATION", "10", "customs.pdf", contentHash, "application/pdf",
|
||||
(long) content.length);
|
||||
ContainerDocumentEntity saved = document(41L, LoadingListService.CUSTOMS_DECLARATION, 0, false);
|
||||
saved.setFileId(101L); saved.setFileName("customs.pdf"); saved.setFileHash(contentHash);
|
||||
when(repository.aggregate(10L)).thenReturn(plan);
|
||||
when(repository.history(anyString())).thenReturn(null);
|
||||
when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)).thenReturn(null, saved);
|
||||
when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("LOADING_CUSTOMS_DOCUMENT"),
|
||||
when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"),
|
||||
eq("10"), eq(file))).thenReturn(ApiResponse.success(metadata));
|
||||
when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DOCUMENT"),
|
||||
when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"),
|
||||
eq("10"), eq(101L))).thenReturn(ApiResponse.success(metadata));
|
||||
when(repository.createDocument(org.mockito.ArgumentMatchers.any())).thenReturn(1);
|
||||
|
||||
@@ -467,10 +469,108 @@ class LoadingListServiceTest {
|
||||
10L, 100L, 9L, "operator", "customs-upload", 0, null, file);
|
||||
|
||||
assertThat(result.documentType()).isEqualTo(LoadingListService.CUSTOMS_DECLARATION);
|
||||
verify(documentFiles).upload(anyString(), eq(9L), eq(0L), eq("LOADING_CUSTOMS_DOCUMENT"),
|
||||
verify(documentFiles).upload(anyString(), eq(9L), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"),
|
||||
eq("10"), eq(file));
|
||||
}
|
||||
|
||||
@Test
|
||||
void downloadsCompletedLoadingDeclarationWithExactLongFileOwnership() {
|
||||
long planId = 9_007_199_254_740_993L;
|
||||
long fileId = 9_007_199_254_740_995L;
|
||||
AggregateRow plan = row("COMPLETED", 4);
|
||||
plan.setPlanId(planId);
|
||||
ContainerDocumentEntity document = document(41L, LoadingListService.CUSTOMS_DECLARATION, 4, true);
|
||||
document.setFileId(fileId);
|
||||
document.setFileName("出口报关单.pdf");
|
||||
byte[] content = new byte[]{1, 2, 3};
|
||||
var metadata = new LoadingDocumentFileClient.Metadata(fileId, 0L,
|
||||
"LOADING_CUSTOMS_DECLARATION", Long.toString(planId), "出口报关单.pdf", hash(),
|
||||
"application/pdf", (long) content.length);
|
||||
when(repository.aggregate(planId)).thenReturn(plan);
|
||||
when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)).thenReturn(document);
|
||||
when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"),
|
||||
eq(Long.toString(planId)), eq(fileId))).thenReturn(ApiResponse.success(metadata));
|
||||
when(documentFiles.download(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"),
|
||||
eq(Long.toString(planId)), eq(fileId))).thenReturn(org.springframework.http.ResponseEntity.ok(content));
|
||||
|
||||
var result = service.downloadLoadingDeclaration(planId, 100L, fileId);
|
||||
|
||||
assertThat(result.fileName()).isEqualTo("出口报关单.pdf");
|
||||
assertThat(result.content()).containsExactly(1, 2, 3);
|
||||
verify(documentFiles).download(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"),
|
||||
eq("9007199254740993"), eq(fileId));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNonCurrentLoadingDeclarationFileIdBeforeFileService() {
|
||||
AggregateRow plan = row("COMPLETED", 4);
|
||||
when(repository.aggregate(10L)).thenReturn(plan);
|
||||
when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION))
|
||||
.thenReturn(document(41L, LoadingListService.CUSTOMS_DECLARATION, 4, true));
|
||||
|
||||
assertThatThrownBy(() -> service.downloadLoadingDeclaration(10L, 100L, 100L))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.NOT_FOUND);
|
||||
verify(documentFiles, never()).metadata(anyString(), anyLong(), anyString(), anyString(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsLoadingDeclarationFileWithMismatchedBusinessOwnership() {
|
||||
AggregateRow plan = row("COMPLETED", 4);
|
||||
ContainerDocumentEntity document = document(41L, LoadingListService.CUSTOMS_DECLARATION, 4, true);
|
||||
when(repository.aggregate(10L)).thenReturn(plan);
|
||||
when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)).thenReturn(document);
|
||||
var metadata = new LoadingDocumentFileClient.Metadata(99L, 0L,
|
||||
"LOADING_CUSTOMS_DECLARATION", "other-plan", "customs.pdf", hash(),
|
||||
"application/pdf", 3L);
|
||||
when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"),
|
||||
eq("10"), eq(99L))).thenReturn(ApiResponse.success(metadata));
|
||||
|
||||
assertThatThrownBy(() -> service.downloadLoadingDeclaration(10L, 100L, 99L))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.NOT_FOUND);
|
||||
verify(documentFiles, never()).download(anyString(), anyLong(), anyString(), anyString(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void downloadsCurrentDestinationClearanceWithExactContainerOwnership() {
|
||||
long bookingId = 9_007_199_254_740_993L;
|
||||
long fileId = 9_007_199_254_740_995L;
|
||||
AggregateRow plan = row("COMPLETED", 4);
|
||||
ContainerDocumentEntity document = document(41L, LoadingListService.DESTINATION_CLEARANCE, 1, true);
|
||||
document.setContainerBookingId(bookingId);
|
||||
document.setFileId(fileId);
|
||||
document.setFileName("destination-clearance.pdf");
|
||||
byte[] content = new byte[]{4, 5, 6};
|
||||
var metadata = new LoadingDocumentFileClient.Metadata(fileId, 0L,
|
||||
"CONTAINER_CLEARANCE_DOCUMENT", Long.toString(bookingId), "destination-clearance.pdf",
|
||||
hash(), "application/pdf", (long) content.length);
|
||||
when(repository.aggregateByBooking(bookingId)).thenReturn(plan);
|
||||
when(repository.document(bookingId, LoadingListService.DESTINATION_CLEARANCE)).thenReturn(document);
|
||||
when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"),
|
||||
eq(Long.toString(bookingId)), eq(fileId))).thenReturn(ApiResponse.success(metadata));
|
||||
when(documentFiles.download(anyString(), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"),
|
||||
eq(Long.toString(bookingId)), eq(fileId))).thenReturn(org.springframework.http.ResponseEntity.ok(content));
|
||||
|
||||
var result = service.downloadContainerClearance(bookingId, fileId);
|
||||
|
||||
assertThat(result.fileName()).isEqualTo("destination-clearance.pdf");
|
||||
assertThat(result.content()).containsExactly(4, 5, 6);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNonCurrentDestinationClearanceFileIdAsNotFound() {
|
||||
AggregateRow plan = row("COMPLETED", 4);
|
||||
when(repository.aggregateByBooking(30L)).thenReturn(plan);
|
||||
when(repository.document(30L, LoadingListService.DESTINATION_CLEARANCE))
|
||||
.thenReturn(document(41L, LoadingListService.DESTINATION_CLEARANCE, 1, true));
|
||||
|
||||
assertThatThrownBy(() -> service.downloadContainerClearance(30L, 100L))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.NOT_FOUND);
|
||||
verify(documentFiles, never()).metadata(anyString(), anyLong(), anyString(), anyString(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsReusedIdempotencyKeyWithDifferentAttachRequest() {
|
||||
AggregateRow plan = row("LOADING", 2);
|
||||
@@ -499,6 +599,125 @@ class LoadingListServiceTest {
|
||||
verify(repository, never()).aggregateByBooking(30L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void firstDeliveryOrderUploadPersistsDocumentAndImmutablePlanHistory() {
|
||||
AggregateRow plan = row("COMPLETED", 4);
|
||||
byte[] content = "delivery-order".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
var file = new MockMultipartFile("file", "do.pdf", "application/pdf", content);
|
||||
String contentHash = sha256Bytes(content);
|
||||
var metadata = new LoadingDocumentFileClient.Metadata(101L, 0L,
|
||||
"CONTAINER_DELIVERY_ORDER", "30", "do.pdf", contentHash, "application/pdf", (long) content.length);
|
||||
ContainerDocumentEntity saved = document(51L, LoadingListService.DELIVERY_ORDER, 0, false);
|
||||
saved.setFileId(101L); saved.setFileName("do.pdf"); saved.setFileHash(contentHash);
|
||||
when(repository.aggregateByBooking(30L)).thenReturn(plan);
|
||||
when(repository.history(anyString())).thenReturn(null);
|
||||
when(repository.document(30L, LoadingListService.DELIVERY_ORDER)).thenReturn(null, saved);
|
||||
when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_DELIVERY_ORDER"),
|
||||
eq("30"), eq(file))).thenReturn(ApiResponse.success(metadata));
|
||||
when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_DELIVERY_ORDER"),
|
||||
eq("30"), eq(101L))).thenReturn(ApiResponse.success(metadata));
|
||||
when(repository.createDocument(any())).thenReturn(1);
|
||||
|
||||
var result = service.uploadContainerLogisticsDocument(30L, LoadingListService.DELIVERY_ORDER,
|
||||
9L, "operator", "do-first", 0, null, file);
|
||||
|
||||
assertThat(result).extracting(value -> value.documentType(), value -> value.fileId(), value -> value.version())
|
||||
.containsExactly(LoadingListService.DELIVERY_ORDER, 101L, 0);
|
||||
verify(repository).appendHistory(eq(10L), anyString(), eq("UPLOAD_DELIVERY_ORDER"),
|
||||
eq("COMPLETED"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"), isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pickupOrderUpdateUsesCurrentVersionAndReturnsUpdatedDocument() {
|
||||
AggregateRow plan = row("COMPLETED", 4);
|
||||
byte[] content = "pickup-order".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
var file = new MockMultipartFile("file", "pu.pdf", "application/pdf", content);
|
||||
String contentHash = sha256Bytes(content);
|
||||
ContainerDocumentEntity current = document(52L, LoadingListService.PICKUP_ORDER, 2, false);
|
||||
ContainerDocumentEntity saved = document(52L, LoadingListService.PICKUP_ORDER, 3, false);
|
||||
saved.setFileId(102L); saved.setFileName("pu.pdf"); saved.setFileHash(contentHash);
|
||||
var metadata = new LoadingDocumentFileClient.Metadata(102L, 0L,
|
||||
"CONTAINER_PICKUP_ORDER", "30", "pu.pdf", contentHash, "application/pdf", (long) content.length);
|
||||
when(repository.aggregateByBooking(30L)).thenReturn(plan);
|
||||
when(repository.history(anyString())).thenReturn(null);
|
||||
when(repository.document(30L, LoadingListService.PICKUP_ORDER)).thenReturn(current, saved);
|
||||
when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_PICKUP_ORDER"),
|
||||
eq("30"), eq(file))).thenReturn(ApiResponse.success(metadata));
|
||||
when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_PICKUP_ORDER"),
|
||||
eq("30"), eq(102L))).thenReturn(ApiResponse.success(metadata));
|
||||
when(repository.replaceDocument(any())).thenReturn(1);
|
||||
|
||||
var result = service.uploadContainerLogisticsDocument(30L, LoadingListService.PICKUP_ORDER,
|
||||
9L, "operator", "pu-update", 2, null, file);
|
||||
|
||||
assertThat(result.fileId()).isEqualTo(102L);
|
||||
assertThat(result.version()).isEqualTo(3);
|
||||
verify(repository).replaceDocument(any());
|
||||
verify(repository).appendHistory(eq(10L), anyString(), eq("UPLOAD_PICKUP_ORDER"),
|
||||
eq("COMPLETED"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"), isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleDeliveryOrderVersionReturnsConflictBeforeUploadingAnotherFile() {
|
||||
AggregateRow plan = row("COMPLETED", 4);
|
||||
when(repository.aggregateByBooking(30L)).thenReturn(plan);
|
||||
when(repository.history(anyString())).thenReturn(null);
|
||||
when(repository.document(30L, LoadingListService.DELIVERY_ORDER))
|
||||
.thenReturn(document(51L, LoadingListService.DELIVERY_ORDER, 2, false));
|
||||
var file = new MockMultipartFile("file", "do.pdf", "application/pdf", "do".getBytes());
|
||||
|
||||
assertThatThrownBy(() -> service.uploadContainerLogisticsDocument(30L,
|
||||
LoadingListService.DELIVERY_ORDER, 9L, "operator", "do-stale", 1, null, file))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.CONFLICT);
|
||||
verify(documentFiles, never()).upload(anyString(), anyLong(), anyLong(), anyString(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameDeliveryOrderUploadKeyReplaysWithoutUploadingAgain() {
|
||||
AggregateRow plan = row("COMPLETED", 4);
|
||||
byte[] content = "delivery-order".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
var file = new MockMultipartFile("file", "do.pdf", "application/pdf", content);
|
||||
String requestHash = sha256("UPLOAD|DELIVERY_ORDER|30|0|null|" + sha256Bytes(content));
|
||||
HistoryRow replay = new HistoryRow();
|
||||
replay.setAfterSnapshot("{\"requestHash\":\"" + requestHash + "\"}");
|
||||
when(repository.aggregateByBooking(30L)).thenReturn(plan);
|
||||
when(repository.history(anyString())).thenReturn(replay);
|
||||
when(repository.document(30L, LoadingListService.DELIVERY_ORDER))
|
||||
.thenReturn(document(51L, LoadingListService.DELIVERY_ORDER, 0, false));
|
||||
|
||||
var result = service.uploadContainerLogisticsDocument(30L, LoadingListService.DELIVERY_ORDER,
|
||||
9L, "operator", "do-replay", 0, null, file);
|
||||
|
||||
assertThat(result.documentType()).isEqualTo(LoadingListService.DELIVERY_ORDER);
|
||||
verify(documentFiles, never()).upload(anyString(), anyLong(), anyLong(), anyString(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deliveryOrderDatabaseConstraintFailureReturnsBusinessConflictInsteadOfUnknownError() {
|
||||
AggregateRow plan = row("COMPLETED", 4);
|
||||
byte[] content = "delivery-order".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
var file = new MockMultipartFile("file", "do.pdf", "application/pdf", content);
|
||||
String contentHash = sha256Bytes(content);
|
||||
var metadata = new LoadingDocumentFileClient.Metadata(103L, 0L,
|
||||
"CONTAINER_DELIVERY_ORDER", "30", "do.pdf", contentHash, "application/pdf", (long) content.length);
|
||||
when(repository.aggregateByBooking(30L)).thenReturn(plan);
|
||||
when(repository.history(anyString())).thenReturn(null);
|
||||
when(repository.document(30L, LoadingListService.DELIVERY_ORDER)).thenReturn(null);
|
||||
when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_DELIVERY_ORDER"),
|
||||
eq("30"), eq(file))).thenReturn(ApiResponse.success(metadata));
|
||||
when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_DELIVERY_ORDER"),
|
||||
eq("30"), eq(103L))).thenReturn(ApiResponse.success(metadata));
|
||||
when(repository.createDocument(any())).thenThrow(
|
||||
new org.springframework.dao.DataIntegrityViolationException("trigger rejected"));
|
||||
|
||||
assertThatThrownBy(() -> service.uploadContainerLogisticsDocument(30L,
|
||||
LoadingListService.DELIVERY_ORDER, 9L, "operator", "do-db-fail", 0, null, file))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.CONFLICT);
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void confirmsEveryLinkedPreloadAndCreatesSinglePdaTaskAtomically() {
|
||||
AggregateRow before = row("EDITING", 3);
|
||||
|
||||
@@ -591,6 +591,28 @@ class LoadingPickServiceTest {
|
||||
verify(loadingListService, never()).publishCompletedEvent(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void translatesUnexpectedSettlementFailureIntoActionableStageMessage() {
|
||||
LoadingPickTaskEntity task = task();
|
||||
when(repository.taskForUpdate(20L)).thenReturn(task);
|
||||
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
|
||||
var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L);
|
||||
when(repository.inboundOwners(20L)).thenReturn(List.of(owner));
|
||||
when(repository.inboundForUpdate(101L, 50L)).thenReturn(
|
||||
new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7));
|
||||
doThrow(new IllegalStateException("unexpected database failure"))
|
||||
.when(settlementService).settle(20L, 9L, "operator");
|
||||
|
||||
assertThatThrownBy(() -> service.complete(20L,
|
||||
new CompleteLoadingRequest(0, "complete-stage-error", "checked"), 9L, "operator"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("拆单与库位结算")
|
||||
.hasMessageContaining("请求编号");
|
||||
|
||||
verify(repository, never()).approve(anyLong(), anyLong(), anyString(), anyString());
|
||||
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void atomicallySubmitsCompleteBatchAndReplaysSameResult() {
|
||||
LoadingPickTaskEntity task = task();
|
||||
|
||||
+6
-6
@@ -13,11 +13,11 @@ import com.baiyue.firstleg.pricing.FbaWarehouseRateEstimator;
|
||||
import com.baiyue.firstleg.pricing.PriceBookWarehouseRegionResolver;
|
||||
import com.baiyue.firstleg.pricing.PriceRegionResolver;
|
||||
import com.baiyue.firstleg.pricing.RegionWeightTierEstimator;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRepository;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelPrefixRow;
|
||||
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelPrefixRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow;
|
||||
import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
@@ -38,7 +38,7 @@ import static org.mockito.Mockito.when;
|
||||
PriceBookWarehouseRegionResolver.class})
|
||||
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
|
||||
class MerchantPriceBookServiceTest {
|
||||
@MockBean private PriceBookQueryRepository repository;
|
||||
@MockBean private PriceBookQueryUseCase repository;
|
||||
@MockBean private PriceBookWarehouseClient warehouses;
|
||||
@Resource private MerchantPriceBookService service;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"booking-customs-trucking->inbound",
|
||||
"booking-customs-trucking->measurement-pricing",
|
||||
"booking-customs-trucking->outbound",
|
||||
"booking-customs-trucking->pricebook-query",
|
||||
"booking-customs-trucking->shared-technical",
|
||||
"booking-customs-trucking->split-loading",
|
||||
"customer-statistics->shared-technical",
|
||||
@@ -19,6 +20,7 @@
|
||||
"measurement-pricing->booking-customs-trucking",
|
||||
"measurement-pricing->finance-integration",
|
||||
"measurement-pricing->inbound",
|
||||
"measurement-pricing->pricebook-query",
|
||||
"measurement-pricing->shared-technical",
|
||||
"outbound->booking-customs-trucking",
|
||||
"outbound->shared-technical",
|
||||
@@ -35,8 +37,8 @@
|
||||
"shipping-nodes->split-loading",
|
||||
"split-loading->booking-customs-trucking",
|
||||
"split-loading->inbound",
|
||||
"split-loading->measurement-pricing",
|
||||
"split-loading->outbound",
|
||||
"split-loading->pricebook-query",
|
||||
"split-loading->shared-technical"
|
||||
],
|
||||
"requiredClassLocations": {
|
||||
@@ -44,21 +46,50 @@
|
||||
"com.baiyue.firstleg.customerstatistics.adapter.out.persistence.CustomerTransitStatisticsMapper": "src/main/java/com/baiyue/firstleg/customerstatistics/adapter/out/persistence/CustomerTransitStatisticsMapper.java",
|
||||
"com.baiyue.firstleg.customerstatistics.application.CustomerTransitStatisticsService": "src/main/java/com/baiyue/firstleg/customerstatistics/application/CustomerTransitStatisticsService.java",
|
||||
"com.baiyue.firstleg.customerstatistics.application.model.CustomerTransitStatisticsDtos": "src/main/java/com/baiyue/firstleg/customerstatistics/application/model/CustomerTransitStatisticsDtos.java",
|
||||
"com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader": "src/main/java/com/baiyue/firstleg/customerstatistics/application/port/out/CustomerTransitStatisticsReader.java"
|
||||
},
|
||||
"applicationDependencyRule": {
|
||||
"packagePrefix": "com.baiyue.firstleg.customerstatistics.application",
|
||||
"allowedInternalPrefix": "com.baiyue.firstleg.customerstatistics.application",
|
||||
"forbiddenImportPrefixes": [
|
||||
"org.springframework.web",
|
||||
"org.apache.ibatis",
|
||||
"com.baomidou.mybatisplus"
|
||||
]
|
||||
"com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader": "src/main/java/com/baiyue/firstleg/customerstatistics/application/port/out/CustomerTransitStatisticsReader.java",
|
||||
"com.baiyue.firstleg.pricebookquery.adapter.out.persistence.PriceBookQueryMapper": "src/main/java/com/baiyue/firstleg/pricebookquery/adapter/out/persistence/PriceBookQueryMapper.java",
|
||||
"com.baiyue.firstleg.pricebookquery.application.PriceBookQueryService": "src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java",
|
||||
"com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows": "src/main/java/com/baiyue/firstleg/pricebookquery/application/model/PriceBookQueryRows.java",
|
||||
"com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase": "src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java",
|
||||
"com.baiyue.firstleg.pricebookquery.application.port.out.PriceBookQueryReader": "src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java"
|
||||
},
|
||||
"applicationDependencyRules": [
|
||||
{
|
||||
"packagePrefix": "com.baiyue.firstleg.customerstatistics.application",
|
||||
"allowedInternalPrefix": "com.baiyue.firstleg.customerstatistics.application",
|
||||
"forbiddenImportPrefixes": [
|
||||
"org.springframework.web",
|
||||
"org.apache.ibatis",
|
||||
"com.baomidou.mybatisplus"
|
||||
]
|
||||
},
|
||||
{
|
||||
"packagePrefix": "com.baiyue.firstleg.pricebookquery.application",
|
||||
"allowedInternalPrefix": "com.baiyue.firstleg.pricebookquery.application",
|
||||
"forbiddenImportPrefixes": [
|
||||
"org.springframework.web",
|
||||
"org.apache.ibatis",
|
||||
"com.baomidou.mybatisplus"
|
||||
]
|
||||
}
|
||||
],
|
||||
"sliceExposureRules": [
|
||||
{
|
||||
"slicePrefix": "com.baiyue.firstleg.pricebookquery.",
|
||||
"allowedTargetPrefixes": [
|
||||
"com.baiyue.firstleg.pricebookquery.application.model.",
|
||||
"com.baiyue.firstleg.pricebookquery.application.port.in."
|
||||
]
|
||||
}
|
||||
],
|
||||
"sliceSpecificRules": {
|
||||
"customer-statistics": {
|
||||
"allowedOutgoing": ["shared-technical"],
|
||||
"allowedIncoming": []
|
||||
},
|
||||
"pricebook-query": {
|
||||
"allowedOutgoing": [],
|
||||
"allowedIncoming": ["booking-customs-trucking", "measurement-pricing", "split-loading"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user