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:
2026-08-26 16:05:24 +08:00
357 changed files with 7739 additions and 3505 deletions
@@ -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
@@ -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 = "上传或替换货柜目的港清关资料")
@@ -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));
@@ -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,
@@ -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;
@@ -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);
}
@@ -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},
@@ -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()));
@@ -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()));
@@ -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));
}
}
@@ -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) {}
@@ -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) "
@@ -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) {}
}
@@ -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);
@@ -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) {}
}
@@ -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,
@@ -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) {
@@ -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) {
@@ -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
@@ -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,4 +1,4 @@
package com.baiyue.firstleg.repository;
package com.baiyue.firstleg.pricebookquery.application.model;
import lombok.Getter;
import lombok.Setter;
@@ -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);
}
@@ -0,0 +1,21 @@
package com.baiyue.firstleg.pricebookquery.application.port.out;
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelPrefixRow;
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow;
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow;
import java.util.List;
public interface PriceBookQueryReader {
Long findActiveVersionId();
List<ChannelRow> findChannels(long versionId, long originWarehouseId);
List<ChannelTaxModeRow> findChannelTaxModes(long versionId, long originWarehouseId);
List<RateRow> findFbaRates(long versionId, long originWarehouseId, String channelCode,
String destinationCode, String pricingTaxMode);
List<RateRow> findOverseasRates(long versionId, long originWarehouseId, String channelCode,
long destinationWarehouseId, String pricingTaxMode);
List<RateRow> findRegionTiers(long versionId, long originWarehouseId, String channelCode,
String regionCode, String pricingTaxMode);
ChannelPrefixRow findChannelPrefix(String channelCode);
}
@@ -7,9 +7,9 @@ import com.baiyue.firstleg.domain.PriceRegion;
import com.baiyue.firstleg.domain.PricingDestinationType;
import com.baiyue.firstleg.domain.PricingTaxMode;
import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest;
import com.baiyue.firstleg.repository.PriceBookQueryRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow;
import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
@@ -21,7 +21,7 @@ import java.util.Locale;
/** Single active-price-book lookup used by merchant estimates and inbound automatic billing. */
@Component
public class ActivePriceBookEstimator {
@Resource private PriceBookQueryRepository repository;
@Resource private PriceBookQueryUseCase repository;
@Resource private FbaWarehouseRateEstimator fbaEstimator;
@Resource private RegionWeightTierEstimator regionEstimator;
@Resource private PriceBookWarehouseRegionResolver warehouseRegionResolver;
@@ -2,7 +2,7 @@ package com.baiyue.firstleg.pricing;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
@@ -2,7 +2,7 @@ package com.baiyue.firstleg.pricing;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
@@ -46,7 +46,6 @@ public class ContainerRepository {
public boolean hasPickTask(long id) { return mapper.hasPickTask(id) > 0; }
public boolean hasEvidence(long id) { return mapper.hasEvidence(id) > 0; }
public boolean hasDocuments(long id) { return mapper.hasDocuments(id) > 0; }
public boolean hasMismatchedCustomerItems(long id, long customerId) { return mapper.hasMismatchedCustomerItems(id, customerId) > 0; }
public int updateTransport(long id, String containerNo, String voyageNo, int version,
long operatorId, String operatorName) {
return mapper.updateTransport(id, containerNo, voyageNo, version, operatorId, operatorName);
@@ -106,9 +106,13 @@ public class LoadingListRepository {
String fromStatus, String toStatus, String beforeSnapshot,
String afterSnapshot, long operatorId, String operatorName,
String operationReason) {
mapper.insertHistory(new HistoryWrite(IdWorker.getId(), planId, operationId, actionType,
int changed = mapper.insertHistory(new HistoryWrite(IdWorker.getId(), planId, operationId, actionType,
fromStatus, toStatus, beforeSnapshot, afterSnapshot, operatorId, operatorName,
operationReason));
if (changed != 1) {
throw new com.baiyue.common.core.exception.BizException(
com.baiyue.common.core.api.ResultCode.CONFLICT, "装柜历史已变化,请刷新后重试");
}
}
private Map<Long, List<Customer>> customerMap(List<AggregateRow> rows) {
@@ -1,53 +0,0 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.mapper.PriceBookQueryMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public class PriceBookQueryRepository {
@Resource
private PriceBookQueryMapper mapper;
public Long activeVersionId() {
return mapper.findActiveVersionId();
}
public List<PriceBookQueryRows.ChannelRow> channels(long versionId, long originWarehouseId) {
return mapper.findChannels(versionId, originWarehouseId);
}
public List<PriceBookQueryRows.ChannelTaxModeRow> channelTaxModes(long versionId, long originWarehouseId) {
return mapper.findChannelTaxModes(versionId, originWarehouseId);
}
public List<PriceBookQueryRows.RateRow> fbaRates(long versionId, long originWarehouseId,
String channelCode, String destinationCode,
String pricingTaxMode) {
return mapper.findFbaRates(versionId, originWarehouseId, channelCode, destinationCode, pricingTaxMode);
}
public List<PriceBookQueryRows.RateRow> overseasRates(long versionId, long originWarehouseId,
String channelCode, long destinationWarehouseId,
String pricingTaxMode) {
return mapper.findOverseasRates(versionId, originWarehouseId, channelCode, destinationWarehouseId,
pricingTaxMode);
}
public List<PriceBookQueryRows.RateRow> regionTiers(long versionId, long originWarehouseId,
String channelCode, String regionCode,
String pricingTaxMode) {
return mapper.findRegionTiers(versionId, originWarehouseId, channelCode, regionCode, pricingTaxMode);
}
public boolean channelAvailable(long versionId, long originWarehouseId, String channelCode) {
return channels(versionId, originWarehouseId).stream()
.anyMatch(channel -> channelCode.equals(channel.getChannelCode()));
}
public PriceBookQueryRows.ChannelPrefixRow channelPrefix(String channelCode) {
return mapper.findChannelPrefix(channelCode);
}
}
@@ -22,8 +22,8 @@ import com.baiyue.firstleg.mapper.ContainerTransportHistoryMapper;
import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
@@ -44,7 +44,7 @@ public class ContainerService {
@Resource private SmartLoadingRepository smartLoadingRepository;
@Resource private LoadingPickRepository loadingPickRepository;
@Resource private LoadingPickService loadingPickService;
@Resource private PriceBookQueryRepository priceBooks;
@Resource private PriceBookQueryUseCase priceBooks;
@Resource private OceanBookingRepository oceanBookings;
@Resource private WarehouseSupplierSnapshotService suppliers;
@Resource private FirstlegDocumentNumberGenerator documentNumbers;
@@ -85,17 +85,14 @@ public class ContainerService {
if (booking == null || !"RELEASED".equals(booking.getStatus())) throw conflict("只能关联已发布订舱");
OceanBookingContainerRequirementEntity requirement = oceanBookings.requirement(request.oceanBookingId(), request.requirementId());
if (requirement == null) throw new BizException(ResultCode.NOT_FOUND, "订舱箱型需求不存在");
long allocated = oceanBookings.allocations(request.oceanBookingId()).stream().filter(a -> request.requirementId().equals(a.getRequirementId())).count();
long allocated = oceanBookings.activeLinkedCount(request.oceanBookingId(), request.requirementId());
if (allocated >= requirement.getQuantity()) throw conflict("该订舱箱型需求已达到数量上限");
if (repository.containerNoExists(request.containerNo().trim(), id)) throw conflict("集装箱号已存在");
if (!java.util.Objects.equals(container.getWarehouseId(), booking.getWarehouseId())
|| !java.util.Objects.equals(container.getChannelCode(), booking.getChannelCode())
|| !java.util.Objects.equals(container.getContainerType(), requirement.getContainerType())
|| container.getCapacityCbm() == null || requirement.getCapacityCbm() == null
|| container.getMaxWeightKg() == null || requirement.getMaxWeightKg() == null
|| container.getCapacityCbm().compareTo(requirement.getCapacityCbm()) != 0
|| container.getMaxWeightKg().compareTo(requirement.getMaxWeightKg()) != 0) throw conflict("模拟柜与真实订舱柜仓库、渠道、箱型或容量不一致");
if (booking.getCustomerId() != null && repository.hasMismatchedCustomerItems(id, booking.getCustomerId())) throw conflict("模拟柜已排货客户与真实订舱客户范围不一致");
|| !java.util.Objects.equals(container.getContainerType(), requirement.getContainerType())) {
throw conflict("模拟柜与真实订舱柜仓库、渠道或柜型不一致");
}
if (repository.linkReleasedBooking(id, request.oceanBookingId(), request.requirementId(), request.containerNo().trim(), booking,
request.expectedVersion(), operatorId, operatorName) != 1) {
throw conflict("真实订舱柜已被消费或货柜版本发生变化,请刷新后重试");
@@ -14,7 +14,7 @@ import com.baiyue.firstleg.entity.InboundFeeItemEntity;
import com.baiyue.firstleg.entity.InboundPricingEntity;
import com.baiyue.firstleg.entity.InboundPricingIdempotencyEntity;
import com.baiyue.firstleg.pricing.ActivePriceBookEstimator;
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.RateRow;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -31,6 +31,7 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import feign.FeignException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.context.annotation.Lazy;
import org.springframework.transaction.annotation.Transactional;
@@ -55,7 +56,7 @@ public class LoadingListService {
public static final String DELIVERY_ORDER = "DELIVERY_ORDER";
public static final String PICKUP_ORDER = "PICKUP_ORDER";
private static final String CLEARANCE_FILE_TYPE = "CONTAINER_CLEARANCE_DOCUMENT";
private static final String DECLARATION_FILE_TYPE = "LOADING_CUSTOMS_DOCUMENT";
private static final String DECLARATION_FILE_TYPE = "LOADING_CUSTOMS_DECLARATION";
private static final String DO_FILE_TYPE = "CONTAINER_DELIVERY_ORDER";
private static final String PU_FILE_TYPE = "CONTAINER_PICKUP_ORDER";
@Resource
@@ -128,6 +129,41 @@ public class LoadingListService {
}
catch(FeignException e){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}
}
public DocumentDownload downloadContainerClearance(long bookingId, long fileId) {
requireBookingPlan(bookingId);
ContainerDocumentEntity document = repository.document(bookingId, DESTINATION_CLEARANCE);
if (document == null || !Long.valueOf(fileId).equals(document.getFileId())) {
throw notFound("目的港清关附件不存在或不属于当前货柜");
}
String businessId = Long.toString(bookingId);
try {
var metadata = documentFiles.metadata(internalToken, 0, CLEARANCE_FILE_TYPE, businessId, fileId);
if (metadata == null || !metadata.successful() || metadata.data() == null
|| !Long.valueOf(fileId).equals(metadata.data().fileId())
|| !Long.valueOf(0).equals(metadata.data().tenantId())
|| !CLEARANCE_FILE_TYPE.equals(metadata.data().businessType())
|| !businessId.equals(metadata.data().businessId())) {
throw notFound("目的港清关附件不存在或归属不匹配");
}
var response = documentFiles.download(internalToken, 0, CLEARANCE_FILE_TYPE, businessId, fileId);
if (response == null || !response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
throw notFound("目的港清关附件不存在");
}
MediaType contentType;
try {
contentType = MediaType.parseMediaType(metadata.data().contentType());
} catch (RuntimeException exception) {
contentType = MediaType.APPLICATION_OCTET_STREAM;
}
String fileName = StringUtils.hasText(document.getFileName())
? document.getFileName() : metadata.data().fileName();
return new DocumentDownload(fileName, contentType, response.getBody());
} catch (FeignException.NotFound exception) {
throw notFound("目的港清关附件不存在或归属不匹配");
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用");
}
}
public record DocumentDownload(String fileName,MediaType contentType,byte[] content) {}
private static void requireLogisticsType(String type){ if(!DELIVERY_ORDER.equals(type)&&!PICKUP_ORDER.equals(type)) throw badRequest("documentType只能是DELIVERY_ORDER或PICKUP_ORDER"); }
private static String fileType(String type){ return switch(type){case DELIVERY_ORDER->DO_FILE_TYPE; case PICKUP_ORDER->PU_FILE_TYPE; default->throw badRequest("documentType只能是DELIVERY_ORDER或PICKUP_ORDER");}; }
@@ -169,6 +205,44 @@ public class LoadingListService {
return document(repository.document(plan.getContainerBookingId(), CUSTOMS_DECLARATION));
}
public DocumentDownload downloadLoadingDeclaration(long planId, long warehouseId, long fileId) {
AggregateRow plan = requirePlan(planId);
requireWarehouse(plan, warehouseId);
requireCompletedPlan(plan);
ContainerDocumentEntity document = repository.document(plan.getContainerBookingId(), CUSTOMS_DECLARATION);
if (document == null || !Long.valueOf(fileId).equals(document.getFileId())) {
throw notFound("出口报关附件不存在或不属于当前装柜计划");
}
String businessId = Long.toString(planId);
try {
var metadata = documentFiles.metadata(internalToken, 0, DECLARATION_FILE_TYPE, businessId, fileId);
if (metadata == null || !metadata.successful() || metadata.data() == null
|| !Long.valueOf(fileId).equals(metadata.data().fileId())
|| !Long.valueOf(0).equals(metadata.data().tenantId())
|| !DECLARATION_FILE_TYPE.equals(metadata.data().businessType())
|| !businessId.equals(metadata.data().businessId())) {
throw notFound("出口报关附件不存在或归属不匹配");
}
var response = documentFiles.download(internalToken, 0, DECLARATION_FILE_TYPE, businessId, fileId);
if (response == null || !response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
throw notFound("出口报关附件不存在");
}
MediaType contentType;
try {
contentType = MediaType.parseMediaType(metadata.data().contentType());
} catch (RuntimeException exception) {
contentType = MediaType.APPLICATION_OCTET_STREAM;
}
String fileName = StringUtils.hasText(document.getFileName())
? document.getFileName() : metadata.data().fileName();
return new DocumentDownload(fileName, contentType, response.getBody());
} catch (FeignException.NotFound exception) {
throw notFound("出口报关附件不存在或归属不匹配");
} catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用");
}
}
@Transactional
@BusinessAudit(event = "LOADING_CUSTOMS_DOCUMENT_ATTACH", resourceType = "LOADING_PLAN",
resourceId = "#planId", description = "上传出口报关文档")
@@ -264,12 +338,18 @@ public class LoadingListService {
if (changed != 1) throw conflict("资料版本已变化,请刷新后重试");
} catch (DuplicateKeyException exception) {
throw conflict("资料已被其他操作上传,请刷新后重试");
} catch (DataIntegrityViolationException exception) {
throw conflict("资料状态或数据库约束已变化,请刷新后重试");
}
ContainerDocumentEntity saved = requireDocumentEntity(plan.getContainerBookingId(), documentType);
repository.appendHistory(plan.getPlanId(), operationId, actionType,
plan.getPlanStatus(), plan.getPlanStatus(), json(beforeSnapshot),
json(values("requestHash", requestHash, "document", documentSnapshot(saved))),
operatorId, trim(operatorName), trim(operationReason));
try {
repository.appendHistory(plan.getPlanId(), operationId, actionType,
plan.getPlanStatus(), plan.getPlanStatus(), json(beforeSnapshot),
json(values("requestHash", requestHash, "document", documentSnapshot(saved))),
operatorId, trim(operatorName), trim(operationReason));
} catch (DataIntegrityViolationException exception) {
throw conflict("资料历史约束已变化,请刷新后重试");
}
return document(saved);
}
@@ -462,10 +462,12 @@ public class LoadingPickService {
for (LoadingPickItemMapper.InboundOwner owner : owners) {
LoadingInboundMapper.Snapshot snapshot = repository.inboundForUpdate(owner.tenantId(), owner.inboundId());
if (snapshot == null) throw conflict("PDA任务关联入库单不存在");
loadingDeliveryService.requirePdaCompletionReady(owner.tenantId(), owner.inboundId(),
evidenceOwner.inboundId());
completionStage(taskId, "完成前业务门禁", () ->
loadingDeliveryService.requirePdaCompletionReady(owner.tenantId(), owner.inboundId(),
evidenceOwner.inboundId()));
}
settlementService.settle(taskId, operatorId, operatorName);
completionStage(taskId, "拆单与库位结算", () ->
settlementService.settle(taskId, operatorId, operatorName));
if (!repository.allPlanItemsPicked(task.getLoadingPlanId())) {
throw conflict("柜次仍有未完成PDA拣货的箱数");
}
@@ -473,18 +475,33 @@ public class LoadingPickService {
throw conflict("PDA任务已经复核");
}
insertOperation(taskId, operationId, requestHash, "COMPLETE_LOADING", operatorId, operatorName);
loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName);
completionStage(taskId, "装柜计划完成", () ->
loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName));
repository.syncInboundProjections(taskId, operatorId, operatorName);
for (LoadingPickItemMapper.InboundOwner owner : owners) {
loadingDeliveryService.completeAfterPdaSettlement(owner.tenantId(), owner.inboundId(),
evidenceOwner.inboundId(), operatorId, operatorName,
"PDA-" + operationId + '-' + owner.inboundId(), remark);
completionStage(taskId, "入库单状态完成", () ->
loadingDeliveryService.completeAfterPdaSettlement(owner.tenantId(), owner.inboundId(),
evidenceOwner.inboundId(), operatorId, operatorName,
"PDA-" + operationId + '-' + owner.inboundId(), remark));
}
loadingListService.publishCompletedEvent(task.getLoadingPlanId());
completionStage(taskId, "完成事件发布", () ->
loadingListService.publishCompletedEvent(task.getLoadingPlanId()));
completeTruckAfterCommit(task.getContainerBookingId(), taskId, operatorId, operatorName);
return view(requiredTask(taskId), null);
}
private void completionStage(long taskId, String stage, Runnable action) {
try {
action.run();
} catch (BizException exception) {
throw exception;
} catch (RuntimeException exception) {
log.error("Unexpected PDA loading completion failure taskId={} stage={}", taskId, stage, exception);
throw new BizException(ResultCode.INTERNAL_ERROR,
"装柜完成在【" + stage + "】阶段处理异常,请联系管理员并提供请求编号");
}
}
private void completeTruckAfterCommit(long containerBookingId, long taskId,
long operatorId, String operatorName) {
Runnable completion = () -> {
@@ -35,6 +35,8 @@ public class MeasurementBatchService {
/** 已接收第三方测量数据,待平台提交审核。 */
static final String STATUS_RECEIVED = "RECEIVED";
static final BigDecimal VOLUME_WEIGHT_FACTOR = new BigDecimal("167");
private static final BigDecimal MAX_RAW_VOLUME_CBM = new BigDecimal("999999999999.999999");
private static final BigDecimal MAX_TOTAL_VOLUME_CBM = new BigDecimal("99999999999999.9999");
@Resource
private InboundMeasurementRepository repository;
@Resource
@@ -140,7 +142,16 @@ public class MeasurementBatchService {
BigDecimal unitVolume = weighted.unitVolumeCbm();
BigDecimal packageCount = BigDecimal.valueOf(source.packageCount());
BigDecimal totalVolumeRaw = unitVolumeRaw.multiply(packageCount).setScale(6, RoundingMode.HALF_UP);
BigDecimal totalVolume = unitVolume.multiply(packageCount).setScale(4, RoundingMode.UNNECESSARY);
BigDecimal totalVolume;
try {
totalVolume = unitVolume.multiply(packageCount).setScale(4, RoundingMode.UNNECESSARY);
} catch (ArithmeticException exception) {
throw measurementRangeExceeded(source);
}
if (totalVolumeRaw.compareTo(MAX_RAW_VOLUME_CBM) > 0
|| totalVolume.compareTo(MAX_TOTAL_VOLUME_CBM) > 0) {
throw measurementRangeExceeded(source);
}
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setInboundDetailId(source.inboundDetailId());
@@ -169,6 +180,13 @@ public class MeasurementBatchService {
}).toList();
}
private static BizException measurementRangeExceeded(MeasurementBatchCommand.Item source) {
return new BizException(ResultCode.VALIDATION_FAILED,
"箱规尺寸或箱数超出系统允许范围,明细 " + source.itemNo() + ":"
+ source.lengthCm() + " × " + source.widthCm() + " × " + source.heightCm()
+ " cm,箱数 " + source.packageCount());
}
private void applyTotals(InboundMeasurementEntity measurement,
List<InboundMeasurementItemEntity> items) {
long packages = items.stream().mapToLong(InboundMeasurementItemEntity::getPackageCount).sum();
@@ -9,9 +9,9 @@ import com.baiyue.firstleg.dto.PriceBookDtos.EstimateRequest;
import com.baiyue.firstleg.dto.PriceBookDtos.EstimateResponse;
import com.baiyue.firstleg.dto.PriceBookChannelValidation.Response;
import com.baiyue.firstleg.pricing.ActivePriceBookEstimator;
import com.baiyue.firstleg.repository.PriceBookQueryRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow;
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelTaxModeRow;
import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@@ -26,7 +26,7 @@ import java.util.Map;
public class MerchantPriceBookService {
public static final String DISCLAIMER = "仅供参考,不含附加费,最终以实际结算为准";
@Resource private PriceBookQueryRepository repository;
@Resource private PriceBookQueryUseCase repository;
@Resource private ActivePriceBookEstimator estimator;
public List<CurrentChannel> channels(long merchantTenantId, long originWarehouseId) {
@@ -8,11 +8,11 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query (
ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '关联海运订舱ID',
container_booking_id BIGINT UNSIGNED NULL COMMENT '指定查询的柜次ID;整票订舱查询时为空',
container_scope_id BIGINT UNSIGNED GENERATED ALWAYS AS (COALESCE(container_booking_id,0)) STORED COMMENT '柜次唯一作用域计算值;整票订舱查询固定为0',
carrier_code VARCHAR(16) NOT NULL COMMENT '船公司编码:MATSON美森、SML森罗、ZIM以星、COSCO中远海运、EMC长荣、HEDE合德',
carrier_code VARCHAR(16) NOT NULL COMMENT '船公司标准编码;OTHER表示人工跟踪',
tracking_type VARCHAR(32) NOT NULL COMMENT '官网跟踪类型:BOOKING_NO订舱号、BILL_OF_LADING提单号、CONTAINER_NO箱号',
tracking_no VARCHAR(128) NOT NULL COMMENT '提交至船公司官网的跟踪号码',
official_url VARCHAR(500) NOT NULL COMMENT '船公司官网查询入口地址快照',
access_mode VARCHAR(32) NOT NULL COMMENT '船司访问方式:PUBLIC_FORM、PUBLIC_WEB_PROXY、PUBLIC_HTML、HCAPTCHA、OFFICIAL_API',
access_mode VARCHAR(32) NOT NULL COMMENT '船司访问方式;MANUAL表示仅人工补录节点',
status VARCHAR(24) NOT NULL COMMENT '任务操作状态:READY、MANUAL_REQUIRED、STOPPED',
trigger_source VARCHAR(32) NOT NULL DEFAULT 'MANUAL' COMMENT '任务触发来源:OUTBOUND_CONFIRM或MANUAL',
latest_standard_node VARCHAR(32) NULL COMMENT '最近实际标准船节点;预计事实不推进',
@@ -54,11 +54,11 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query (
CONSTRAINT fk_carrier_schedule_query_result FOREIGN KEY (result_port_schedule_id) REFERENCES fl_port_schedule(id),
CONSTRAINT chk_carrier_schedule_query_customer CHECK (customer_id > 0),
CONSTRAINT chk_carrier_schedule_query_carrier CHECK
(carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE')),
(carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')),
CONSTRAINT chk_carrier_schedule_query_tracking CHECK
(tracking_type IN ('BOOKING_NO','BILL_OF_LADING','CONTAINER_NO')),
CONSTRAINT chk_carrier_schedule_query_access CHECK
(access_mode IN ('PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API')),
(access_mode IN ('PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API','MANUAL')),
CONSTRAINT chk_carrier_schedule_query_status CHECK
(status IN ('READY','MANUAL_REQUIRED','STOPPED')),
CONSTRAINT chk_carrier_schedule_query_node CHECK
@@ -14,6 +14,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号',
shipping_company_id BIGINT UNSIGNED NULL COMMENT '船公司逻辑ID快照',
shipping_company_name VARCHAR(128) NULL COMMENT '船公司名称快照',
carrier_code VARCHAR(16) NULL COMMENT '船司标准编码:MATSON/SML/ZIM/COSCO/EMC/HEDE/OTHER;发布前必填',
booking_agent_id BIGINT UNSIGNED NULL COMMENT '订舱代理逻辑ID快照',
booking_agent_name VARCHAR(128) NULL COMMENT '订舱代理名称快照',
vessel_name VARCHAR(128) NULL COMMENT '当前确认船名快照',
@@ -42,6 +43,8 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
CONSTRAINT chk_ocean_booking_customer CHECK (customer_id > 0),
CONSTRAINT chk_ocean_booking_status CHECK (status IN
('DRAFT','RELEASED')),
CONSTRAINT chk_ocean_booking_carrier_code CHECK
(carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')),
CONSTRAINT chk_ocean_booking_schedule CHECK
(estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at)
) ENGINE=InnoDB COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载';
@@ -70,7 +70,7 @@ BEGIN
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
WHERE container_booking_id = NEW.container_booking_id;
IF current_plan_status = 'COMPLETED'
AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN
AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜物流文件不可新增';
END IF;
END$$
@@ -84,7 +84,7 @@ BEGIN
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
WHERE container_booking_id = OLD.container_booking_id;
IF current_plan_status = 'COMPLETED'
AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN
AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜物流文件不可修改';
END IF;
END$$
@@ -1122,7 +1122,7 @@ BEGIN
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
WHERE container_booking_id = NEW.container_booking_id;
IF current_plan_status = 'COMPLETED'
AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN
AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增';
END IF;
END$$
@@ -1138,7 +1138,7 @@ BEGIN
SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan
WHERE container_booking_id = OLD.container_booking_id;
IF current_plan_status = 'COMPLETED'
AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN
AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可修改';
END IF;
END$$
@@ -18,7 +18,7 @@ paths:
- {name: customerId, in: query, schema: {type: integer, format: int64, minimum: 1}}
- {name: oceanBookingId, in: query, schema: {type: integer, format: int64, minimum: 1}}
- {name: containerBookingId, in: query, schema: {type: integer, format: int64, minimum: 1}}
- {name: carrierCode, in: query, schema: {$ref: '#/components/schemas/CarrierCode'}}
- {name: carrierCode, in: query, schema: {$ref: '#/components/schemas/TaskCarrierCode'}}
- {name: status, in: query, schema: {$ref: '#/components/schemas/QueryStatus'}}
- {name: expired, in: query, schema: {type: boolean}}
- {name: keyword, in: query, schema: {type: string, maxLength: 128}}
@@ -82,7 +82,9 @@ paths:
schema: {$ref: '#/components/schemas/ManualNodeRequest'}
responses:
'200':
description: Idempotent fact append; only actualAt advances and actual EMPTY_RETURNED stops
description: Idempotent fact append; estimated facts never advance actual progress, while a manual actual fact must have a strictly greater standard-node sequence than latestActualNode and actual EMPTY_RETURNED stops
'409':
description: A0409 when expectedVersion is stale or a manual actual node is equal to or earlier than latestActualNode
content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}
components:
parameters:
@@ -90,6 +92,10 @@ components:
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
schemas:
CarrierCode: {type: string, enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE]}
TaskCarrierCode:
type: string
enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE, OTHER]
description: OTHER is frozen on the ocean booking and creates a manual-only task after outbound confirmation.
TrackingType: {type: string, enum: [BOOKING_NO, BILL_OF_LADING, CONTAINER_NO]}
QueryStatus: {type: string, enum: [READY, MANUAL_REQUIRED, STOPPED]}
StandardNode:
@@ -118,7 +124,7 @@ components:
ManualNodeRequest:
type: object
required: [expectedVersion, standardNode, observedAt, reason]
description: estimatedAt and actualAt cannot both be null; estimatedAt never advances the latest actual node.
description: estimatedAt and actualAt cannot both be null; estimatedAt never advances the latest actual node; when actualAt is present, standardNode must have a strictly greater sequence than latestActualNode.
properties:
expectedVersion: {type: integer, minimum: 0}
standardNode: {$ref: '#/components/schemas/StandardNode'}
@@ -137,11 +143,15 @@ components:
customerId: {type: integer, format: int64}
oceanBookingId: {type: integer, format: int64}
containerBookingId: {type: integer, format: int64, nullable: true}
carrierCode: {$ref: '#/components/schemas/CarrierCode'}
carrierCode: {$ref: '#/components/schemas/TaskCarrierCode'}
trackingType: {$ref: '#/components/schemas/TrackingType'}
trackingNo: {type: string}
officialUrl: {type: string, nullable: true}
accessMode: {type: string, nullable: true}
accessMode:
type: string
enum: [PUBLIC_FORM, PUBLIC_WEB_PROXY, PUBLIC_HTML, HCAPTCHA, OFFICIAL_API, MANUAL]
nullable: true
description: MANUAL tasks preserve physical outbound confirmation and accept platform manual node facts without scheduler/provider calls.
status: {$ref: '#/components/schemas/QueryStatus'}
triggerSource: {type: string}
latestActualNode: {allOf: [{$ref: '#/components/schemas/StandardNode'}], nullable: true}
@@ -150,7 +160,10 @@ components:
sourceUpdatedAt: {type: string, format: date-time, nullable: true}
expiresAt: {type: string, format: date-time, nullable: true}
resultPortScheduleId: {type: integer, format: int64, nullable: true}
manualReason: {type: string, nullable: true}
manualReason:
type: string
nullable: true
description: Explicit provider-disabled or unsupported-carrier reason; unsupported outbound carriers retain the booking shippingCompanyId/name here and through oceanBookingId.
lastErrorCode: {type: string, nullable: true}
lastErrorMessage: {type: string, nullable: true}
stoppedAt: {type: string, format: date-time, nullable: true}
@@ -356,6 +356,16 @@ paths:
required: true
content: {application/json: {schema: {$ref: '#/components/schemas/ContainerDocumentAttachRequest'}}}
responses: {'200': {description: 成功、文件归属不匹配或版本冲突}}
/api/platform/v1/containers/{id}/clearance/document/{fileId}/download:
get:
summary: 下载货柜目的港清关附件
description: 需要 platform:firstleg:customs:manage 权限;服务端校验当前 DESTINATION_CLEARANCE fileId 及 File 业务归属,完成态仍可下载,响应禁止缓存
parameters:
- {$ref: '#/components/parameters/ContainerBookingId'}
- {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses:
'200': {description: 附件二进制内容,包含 Content-Disposition 与 Cache-Control no-store}
'404': {description: 当前附件不存在或归属不匹配}
/api/platform/v1/containers/{id}/clearance/document/upload:
post:
summary: 直接选择文件上传或替换货柜目的港清关资料
@@ -99,6 +99,29 @@ paths:
content: {application/json: {schema: {$ref: '#/components/schemas/DocumentAttachRequest'}}}
responses:
'200': {$ref: '#/components/responses/BusinessDocumentResponse'}
/api/platform/v1/first-leg/loading-lists/{planId}/customs/document/{fileId}/download:
get:
summary: 下载装柜清单出口报关附件
description: 完成态仍可下载。服务端校验 planId、warehouseId、当前 CUSTOMS_DECLARATION 的 fileId,以及 File 元数据 tenantId=0、businessType=LOADING_CUSTOMS_DECLARATION、businessId=planId;错误 fileId 或归属返回 A0404。响应使用 attachment 文件名并禁止缓存。
x-permission: platform:firstleg:loading-list:list
parameters:
- {$ref: '#/components/parameters/PlanId'}
- {$ref: '#/components/parameters/WarehouseId'}
- name: fileId
in: path
required: true
description: File 服务长整型文件 ID
schema: {$ref: '#/components/schemas/LongId'}
responses:
'200':
description: 报关附件二进制内容;包含 Content-Disposition,Cache-Control 为 no-store
headers:
Content-Disposition: {schema: {type: string}}
Cache-Control: {schema: {type: string, example: no-store}}
content:
application/octet-stream:
schema: {type: string, format: binary}
'404': {description: 装柜计划、仓库、当前附件或 File 归属不匹配}
/api/platform/v1/first-leg/loading-lists/{planId}/customs/document/upload:
post:
summary: 直接选择文件上传或替换装柜清单出口报关文档
@@ -2,7 +2,12 @@ openapi: 3.0.3
info: {title: Ocean Booking API, version: 2.0.0}
paths:
/api/platform/v1/ocean-bookings:
get: {summary: List DRAFT and RELEASED bookings, responses: {'200': {description: Booking page}}}
get:
summary: List DRAFT and RELEASED bookings
description: containerCount is the sum of quantity across all container requirements for the booking. It is zero when no requirement exists and never represents linked or ACTIVE containers.
responses:
'200':
description: Booking page; each row uses the frozen requirement-total containerCount semantic
post:
summary: Create an editable DRAFT
description: Creates only the booking aggregate and container requirements; no containerNo, container row, or allocation is created.
@@ -28,6 +33,23 @@ paths:
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ReleaseRequest'}}}}
responses: {'200': {description: RELEASED detail}}
/api/platform/v1/ocean-bookings/link-candidates:
get:
summary: Page released booking requirement candidates for one container
description: Requires platform:firstleg:ocean-booking:list. Returns only linkable requirement candidates: RELEASED, remainingQuantity greater than zero, and exact warehouseId, channelCode and containerType. Capacity and max weight are derived display snapshots and never eligibility fields. Allocated quantity counts only ACTIVE containers still pointing to the same booking requirement and is calculated by one bounded set query; booking detail calls are not used. An empty result is a normal successful page. Final target-container mutability is revalidated by the link command.
x-permission: platform:firstleg:ocean-booking:list
parameters:
- {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
- {name: channelCode, in: query, required: true, schema: {type: string, maxLength: 64}}
- {name: containerType, in: query, required: true, schema: {type: string, maxLength: 32}}
- {name: page, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, default: 1}}
- {name: size, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20}}
responses:
'200':
description: Business envelope containing a bounded candidate page
content:
application/json:
schema: {$ref: '#/components/schemas/LinkCandidateResponse'}
/api/platform/v1/ocean-bookings/channels:
get:
summary: List ACTIVE channels for an origin warehouse
@@ -58,6 +80,11 @@ components:
billOfLadingNo: {type: string, nullable: true}
shippingCompanyId: {type: integer, format: int64, nullable: true}
shippingCompanyName: {type: string, nullable: true}
carrierCode:
type: string
enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE, OTHER]
nullable: true
description: Frozen standard carrier code. Required before RELEASE; OTHER means manual carrier-node tracking.
bookingAgentId:
type: integer
format: int64
@@ -94,6 +121,46 @@ components:
channelName: {type: string}
priceModel: {type: string}
pricingTaxModes: {type: array, items: {type: string}}
LinkCandidate:
type: object
required: [oceanBookingId, bookingNo, requirementId, containerType, capacityCbm, maxWeightKg, requiredQuantity, allocatedQuantity, remainingQuantity, customerName, warehouseName, channelName]
properties:
oceanBookingId: {type: string, pattern: '^[0-9]+$'}
bookingNo: {type: string}
requirementId: {type: string, pattern: '^[0-9]+$'}
containerType: {type: string}
capacityCbm: {type: number}
maxWeightKg: {type: number}
requiredQuantity: {type: integer, minimum: 1}
allocatedQuantity: {type: integer, format: int64, minimum: 0}
remainingQuantity: {type: integer, format: int64, minimum: 1}
customerName: {type: string}
warehouseName: {type: string}
channelName: {type: string}
LinkCandidatePage:
type: object
required: [records, page, size, total, pages]
properties:
records: {type: array, items: {$ref: '#/components/schemas/LinkCandidate'}}
page: {type: integer, format: int64, minimum: 1}
size: {type: integer, format: int64, minimum: 1}
total: {type: integer, format: int64, minimum: 0}
pages: {type: integer, format: int64, minimum: 0}
LinkCandidateResponse:
type: object
required: [code, data]
properties:
code: {type: string, example: '200'}
message: {type: string, nullable: true}
data: {$ref: '#/components/schemas/LinkCandidatePage'}
OceanBookingListRow:
type: object
properties:
containerCount:
type: integer
format: int64
minimum: 0
description: Sum of all container requirement quantities; independent of allocation, linking or unlinking.
Detail:
type: object
required: [oceanBooking, parties, containerRequirements, containers, histories]
@@ -36,6 +36,12 @@ paths:
description: '权限:platform:firstleg:container:list;Firstleg代理File内部下载,不向调用方暴露内部令牌'
parameters: [{$ref: '#/components/parameters/ContainerId'}, {$ref: '#/components/parameters/DocumentType'}, {name: fileId, in: path, required: true, schema: {type: integer, format: int64}}]
responses: {'200': {description: file bytes, content: {application/octet-stream: {schema: {type: string, format: binary}}}}}
/api/platform/v1/containers/{id}/clearance/document/{fileId}/download:
get:
operationId: downloadContainerDestinationClearance
description: '权限:platform:firstleg:customs:manage;校验当前 DESTINATION_CLEARANCE fileId 以及 File tenantId=0、businessType=CONTAINER_CLEARANCE_DOCUMENT、businessId=id;返回 attachment 与 no-store,完成态仍可下载'
parameters: [{$ref: '#/components/parameters/ContainerId'}, {name: fileId, in: path, required: true, schema: {type: integer, format: int64}}]
responses: {'200': {description: clearance file bytes, content: {application/octet-stream: {schema: {type: string, format: binary}}}}, '404': {description: current file or ownership mismatch}}
components:
parameters:
ContainerId: {name: id, in: path, required: true, schema: {type: integer, format: int64}}
@@ -51,7 +57,7 @@ components:
container: {$ref: '#/components/schemas/Container'}
loadingPlanId: {type: integer, format: int64}
pda: {$ref: '#/components/schemas/PdaSummary'}
documents: {type: array, items: {$ref: '#/components/schemas/Document'}}
documents: {type: array, description: '按 documentType 稳定排序返回 DESTINATION_CLEARANCE、CUSTOMS_DECLARATION、DELIVERY_ORDER、PICKUP_ORDER 的当前唯一记录和真实 version', items: {$ref: '#/components/schemas/Document'}}
exception: {$ref: '#/components/schemas/ExceptionState'}
exceptionHistory: {type: array, items: {$ref: '#/components/schemas/ExceptionHistory'}}
timeline: {type: array, items: {$ref: '#/components/schemas/TimelineEntry'}}
@@ -59,7 +65,7 @@ components:
PdaSummary: {type: object, properties: {taskId: {type: integer, format: int64}, loadingPlanId: {type: integer, format: int64}, containerBookingId: {type: integer, format: int64}, taskStatus: {type: string, description: '持久化任务态:LOADING/COMPLETED'}, workflowStatus: {type: string, enum: [IN_PROGRESS, AWAITING_REVIEW, COMPLETED], description: 'PDA作业展示态'}, taskVersion: {type: integer}, itemCount: {type: integer}, completedItemCount: {type: integer}, unresolvedItemCount: {type: integer}, photoCounts: {type: array, items: {$ref: '#/components/schemas/PhotoCount'}}, photos: {type: array, items: {$ref: '#/components/schemas/Photo'}}, exceptionReasons: {type: array, items: {type: string}}}}
PhotoCount: {type: object, properties: {evidenceType: {type: string}, count: {type: integer}}}
Photo: {type: object, properties: {evidenceId: {type: integer, format: int64}, tenantId: {type: integer, format: int64}, inboundId: {type: integer, format: int64}, evidenceType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, status: {type: string, enum: [ACTIVE, VOID]}, downloadPath: {type: string, nullable: true, description: '仅 ACTIVE 照片提供鉴权下载路径;VOID 历史照片为空'}, uploadedAt: {type: string, format: date-time}}}
Document: {type: object, properties: {id: {type: integer, format: int64}, documentType: {type: string}, fileId: {type: integer, format: int64}, fileName: {type: string}, fileHash: {type: string}, uploadedBy: {type: integer, format: int64}, uploadedByName: {type: string}, uploadedAt: {type: string, format: date-time}, version: {type: integer}, completed: {type: boolean}}}
Document: {type: object, properties: {id: {type: integer, format: int64}, documentType: {type: string, enum: [DESTINATION_CLEARANCE, CUSTOMS_DECLARATION, DELIVERY_ORDER, PICKUP_ORDER]}, fileId: {type: integer, format: int64}, fileName: {type: string}, fileHash: {type: string}, uploadedBy: {type: integer, format: int64}, uploadedByName: {type: string}, uploadedAt: {type: string, format: date-time}, version: {type: integer, description: '当前唯一记录的真实乐观锁版本;更新时必须原样作为 expectedVersion'}, completed: {type: boolean}}}
ExceptionState: {type: object, properties: {active: {type: boolean}, reason: {type: string}, markedBy: {type: integer, format: int64}, markedByName: {type: string}, markedAt: {type: string, format: date-time}}}
ExceptionHistory: {type: object, properties: {id: {type: integer, format: int64}, action: {type: string, enum: [MARK, CANCEL]}, reason: {type: string}, operatorId: {type: integer, format: int64}, operatorName: {type: string}, occurredAt: {type: string, format: date-time}}}
TimelineEntry: {type: object, properties: {eventType: {type: string}, detail: {type: string}, operatorId: {type: integer, format: int64}, operatorName: {type: string}, occurredAt: {type: string, format: date-time}}}
@@ -1,85 +1,117 @@
openapi: 3.0.3
info:
title: Phase 3 Domestic Outbound Dispatch API
version: 1.0.0
title: Phase 3 Container-level Domestic Outbound Dispatch API
version: 2.0.0
paths:
/api/platform/v1/first-leg/outbound-dispatches:
get:
summary: Query outbound aggregates by order, inbound or container
summary: Query the outbound aggregate for one container booking
description: Returns one container-level aggregate containing every real inbound binding. No per-inbound partial outbound aggregate is exposed.
x-permission: platform:firstleg:outbound-dispatch:list
parameters:
- {$ref: '#/components/parameters/OrderId'}
- {$ref: '#/components/parameters/InboundId'}
- {$ref: '#/components/parameters/ContainerBookingId'}
- {$ref: '#/components/parameters/ContainerBookingIdQuery'}
responses:
'200':
description: Business envelope containing matching aggregates, each with stable cabinetNo and real containerNo
description: Business envelope containing a single matching aggregate in a list
content:
application/json:
schema:
type: object
properties:
data: {type: array, items: {$ref: '#/components/schemas/Aggregate'}}
/api/platform/v1/first-leg/outbound-dispatches/detail:
/api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}:
get:
summary: Query one aggregate and its unified timeline
summary: Query one container-level aggregate and unified timeline
x-permission: platform:firstleg:outbound-dispatch:view
parameters:
- {$ref: '#/components/parameters/OrderId'}
- {$ref: '#/components/parameters/InboundId'}
- {$ref: '#/components/parameters/ContainerBookingId'}
- {$ref: '#/components/parameters/ContainerBookingIdPath'}
responses:
'200':
description: Business envelope containing aggregate detail
description: Business envelope containing all inbound bindings and one visible dispatch version
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
aggregate: {$ref: '#/components/schemas/Aggregate'}
/api/platform/v1/first-leg/outbound-dispatches/check-gate:
data: {$ref: '#/components/schemas/Detail'}
/api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/gate:
get:
summary: Evaluate the fail-closed domestic outbound gate
summary: Evaluate every inbound binding in the fail-closed container outbound gate
description: Loading evidence is evaluated once for the physical container/loading plan and the same result applies to every inbound binding. The customs gate is resolved once from the current container CUSTOMS_DECLARATION whose completedAt must be non-null; upload alone remains blocked and fl_customs_case is not an alternative gate fact. A missing standalone sailing schedule is reference-only and never affects gate.passed.
x-permission: platform:firstleg:outbound-dispatch:check
parameters:
- {$ref: '#/components/parameters/OrderId'}
- {$ref: '#/components/parameters/InboundId'}
- {$ref: '#/components/parameters/ContainerBookingId'}
- {$ref: '#/components/parameters/ContainerBookingIdPath'}
responses:
'200': {description: Business envelope containing every gate item}
/api/platform/v1/first-leg/outbound-dispatches/confirm-dispatch:
'200': {description: Business envelope containing the container gate and per-inbound gate results}
/api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/confirm:
post:
summary: Confirm domestic dispatch and enter WAITING_FOR_LOADING
summary: Atomically confirm domestic departure for the entire container
description: Locks every real inbound binding, requires every gate to pass, advances all technical rows in one transaction, records one visible history, and creates the CarrierScheduleQuery/DEPARTED_WAREHOUSE fact once. No partial success is possible.
x-permission: platform:firstleg:outbound-dispatch:confirm
parameters:
- {$ref: '#/components/parameters/ContainerBookingIdPath'}
- {$ref: '#/components/parameters/IdempotencyKey'}
requestBody:
required: true
content:
application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}
responses:
'200': {description: Business envelope; gate or version failures use business error code}
/api/platform/v1/first-leg/outbound-dispatches/reverse-dispatch:
'200': {description: Same key and same payload replays the single container-level result; stale version or changed payload returns A0409}
/api/platform/v1/first-leg/outbound-dispatches/{containerBookingId}/reverse:
post:
summary: Reverse WAITING_FOR_LOADING only before maritime tracking starts
description: Once an actual DEPARTED_WAREHOUSE node exists, reversal is rejected with business code A0409 so READY_FOR_DISPATCH cannot coexist with maritime tracking.
summary: Atomically reverse the entire container before any actual shipping node exists
description: Once an actual DEPARTED_WAREHOUSE node exists, reversal is rejected with business code A0409 so an outbound fact can never coexist with READY_FOR_DISPATCH.
x-permission: platform:firstleg:outbound-dispatch:reverse
parameters:
- {$ref: '#/components/parameters/ContainerBookingIdPath'}
- {$ref: '#/components/parameters/IdempotencyKey'}
requestBody:
required: true
content:
application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}
responses:
'200': {description: Business envelope containing reverted aggregate}
'200': {description: Business envelope containing the reverted container aggregate}
components:
parameters:
OrderId: {name: orderId, in: query, schema: {type: integer, format: int64, minimum: 1}}
InboundId: {name: inboundId, in: query, schema: {type: integer, format: int64, minimum: 1}}
ContainerBookingId: {name: containerBookingId, in: query, schema: {type: integer, format: int64, minimum: 1}}
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
ContainerBookingIdQuery:
name: containerBookingId
in: query
required: true
schema: {type: integer, format: int64, minimum: 1}
ContainerBookingIdPath:
name: containerBookingId
in: path
required: true
schema: {type: integer, format: int64, minimum: 1}
IdempotencyKey:
name: Idempotency-Key
in: header
required: true
schema: {type: string, maxLength: 128}
schemas:
Aggregate:
type: object
required: [orderId, orderNo, inboundId, inboundCode, tenantId, inboundStatus, containerBookingId, cabinetNo, loadingPlanId, loadingPlanStatus, dispatchStatus, version, gate]
required: [containerBookingId, cabinetNo, loadingPlanId, loadingPlanStatus, dispatchStatus, version, inbounds, gate]
properties:
containerBookingId: {type: integer, format: int64}
cabinetNo: {type: string, description: Stable cabinet number}
containerNo: {type: string, nullable: true, description: Real container number}
loadingPlanId: {type: integer, format: int64}
loadingPlanStatus: {type: string}
oceanBookingId: {type: integer, format: int64, nullable: true}
oceanBookingStatus: {type: string, nullable: true, description: Booking reference retained independently from the non-blocking standalone sailing schedule}
truckAppointmentId: {type: integer, format: int64, nullable: true, description: Reference-only truck appointment; absence never blocks manual outbound confirmation}
truckAppointmentStatus: {type: string, nullable: true, description: Reference-only status; PENDING or any non-terminal state never affects gate.passed}
dispatchStatus: {type: string, description: One visible container-level status}
version: {type: integer, minimum: 0, description: One optimistic-lock version shared by all technical inbound rows}
inbounds:
type: array
minItems: 1
items: {$ref: '#/components/schemas/InboundBinding'}
gate: {$ref: '#/components/schemas/ContainerGateResult'}
InboundBinding:
type: object
required: [orderId, orderNo, inboundId, inboundCode, tenantId, inboundStatus]
properties:
orderId: {type: integer, format: int64}
orderNo: {type: string}
@@ -89,25 +121,35 @@ components:
customerId: {type: integer, format: int64, nullable: true}
customerName: {type: string, nullable: true}
inboundStatus: {type: string}
containerBookingId: {type: integer, format: int64}
cabinetNo: {type: string, description: 稳定柜次号}
containerNo: {type: string, nullable: true, description: 真实集装箱号}
loadingPlanId: {type: integer, format: int64}
loadingPlanStatus: {type: string}
pickTaskId: {type: integer, format: int64, nullable: true}
pickTaskStatus: {type: string, nullable: true}
oceanBookingId: {type: integer, format: int64, nullable: true}
oceanBookingStatus: {type: string, nullable: true}
truckAppointmentId: {type: integer, format: int64, nullable: true}
truckAppointmentStatus: {type: string, nullable: true}
dispatchStatus: {type: string}
version: {type: integer, minimum: 0}
gate: {type: object}
ContainerGateResult:
type: object
required: [passed, inbounds, checkedAt]
properties:
passed: {type: boolean}
inbounds:
type: array
items:
type: object
required: [inboundId, inboundCode, gate]
properties:
inboundId: {type: integer, format: int64}
inboundCode: {type: string}
gate: {type: object}
checkedAt: {type: string, format: date-time}
Detail:
type: object
required: [aggregate, timeline]
properties:
aggregate: {$ref: '#/components/schemas/Aggregate'}
timeline:
type: array
description: Unified immutable facts; a container confirm or reverse contributes one visible history item.
items: {type: object}
ActionRequest:
type: object
required: [inboundId, containerBookingId, expectedVersion]
required: [expectedVersion]
properties:
inboundId: {type: integer, format: int64, minimum: 1}
containerBookingId: {type: integer, format: int64, minimum: 1}
expectedVersion: {type: integer, minimum: 0}
remark: {type: string, maxLength: 500}