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) {
|
||||
|
||||
Reference in New Issue
Block a user