Merge branch 'dev' into dev-lyhfive

# Conflicts:
#	firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java
#	firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java
#	lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java
#	lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java
#	lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java
This commit is contained in:
2026-08-25 15:06:11 +08:00
568 changed files with 26662 additions and 2081 deletions
@@ -18,7 +18,7 @@ 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.mapper"
"com.baiyue.firstleg.customerstatistics.adapter.out.persistence"
})
@EnableFeignClients(basePackages = "com.baiyue.firstleg")
@EnableScheduling
@@ -59,6 +59,9 @@ public class InternalInboundOrderController {
|| command.warehouseId() <= 0 || command.customerName() == null || command.customerName().isBlank()
|| command.inboundCode() == null || command.inboundCode().isBlank()
|| command.inboundCode().length() > 40
|| command.fulfillmentType() == null
|| (!"STANDARD".equals(command.fulfillmentType())
&& !"ONE_CLICK_DROPSHIP".equals(command.fulfillmentType()))
|| command.totalPackageCount() != null && command.totalPackageCount() < 1) {
throw new BizException(ResultCode.BAD_REQUEST, "订单入库快照不完整");
}
@@ -142,7 +145,10 @@ public class InternalInboundOrderController {
|| command.customerName() == null || command.customerName().isBlank()
|| command.warehouseId() == null || command.warehouseId() <= 0
|| command.warehouseName() == null || command.warehouseName().isBlank()
|| command.businessType() == null || command.businessType().isBlank()) {
|| command.businessType() == null || command.businessType().isBlank()
|| command.fulfillmentType() == null
|| (!"STANDARD".equals(command.fulfillmentType())
&& !"ONE_CLICK_DROPSHIP".equals(command.fulfillmentType()))) {
throw new BizException(ResultCode.BAD_REQUEST, "草稿预入库快照不完整");
}
}
@@ -1,9 +1,9 @@
package com.baiyue.firstleg.customerstatistics.controller;
package com.baiyue.firstleg.customerstatistics.adapter.in.web;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.customerstatistics.dto.CustomerTransitStatisticsDtos.TransitSummary;
import com.baiyue.firstleg.customerstatistics.service.CustomerTransitStatisticsService;
import com.baiyue.firstleg.customerstatistics.application.CustomerTransitStatisticsService;
import com.baiyue.firstleg.customerstatistics.application.model.CustomerTransitStatisticsDtos.TransitSummary;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import jakarta.annotation.Resource;
import jakarta.validation.constraints.Positive;
@@ -1,14 +1,13 @@
package com.baiyue.firstleg.customerstatistics.mapper;
package com.baiyue.firstleg.customerstatistics.adapter.out.persistence;
import com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Mapper
public interface CustomerTransitStatisticsMapper {
public interface CustomerTransitStatisticsMapper extends CustomerTransitStatisticsReader {
@Override
@Select("""
SELECT #{customerId} AS customerId,
COUNT(*) AS containerCount,
@@ -49,8 +48,5 @@ public interface CustomerTransitStatisticsMapper {
WHERE x.customer_id = #{customerId}
) scoped
""")
SummaryRow summarize(@Param("customerId") long customerId);
record SummaryRow(Long customerId, Long containerCount, BigDecimal totalTeu,
Long unknownContainerCount, LocalDateTime asOf) {}
SummaryProjection summarize(@Param("customerId") long customerId);
}
@@ -1,9 +1,9 @@
package com.baiyue.firstleg.customerstatistics.service;
package com.baiyue.firstleg.customerstatistics.application;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.customerstatistics.dto.CustomerTransitStatisticsDtos.TransitSummary;
import com.baiyue.firstleg.customerstatistics.mapper.CustomerTransitStatisticsMapper;
import com.baiyue.firstleg.customerstatistics.application.model.CustomerTransitStatisticsDtos.TransitSummary;
import com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
@@ -12,11 +12,11 @@ import java.math.BigDecimal;
@Service
public class CustomerTransitStatisticsService {
private static final String DEFINITION = "actual_sailing_at IS NOT NULL 且 actual_warehouse_arrival_at IS NULL;排除取消/失败订舱;客户+containerBookingId去重;拼柜按active LoadingPlanItem.accountId归属,整柜按OceanBooking.customerId归属。TEU:20*=1,40*=2,45*=2.25,未知箱型不计totalTeu。";
@Resource private CustomerTransitStatisticsMapper mapper;
@Resource private CustomerTransitStatisticsReader reader;
public TransitSummary summarize(long customerId) {
if (customerId <= 0) throw new BizException(ResultCode.BAD_REQUEST, "customerId必须为正数");
var row = mapper.summarize(customerId);
var row = reader.summarize(customerId);
return new TransitSummary(customerId,
value(row == null ? null : row.containerCount()),
row == null || row.totalTeu() == null ? BigDecimal.ZERO : row.totalTeu(),
@@ -1,4 +1,4 @@
package com.baiyue.firstleg.customerstatistics.dto;
package com.baiyue.firstleg.customerstatistics.application.model;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@@ -0,0 +1,11 @@
package com.baiyue.firstleg.customerstatistics.application.port.out;
import java.math.BigDecimal;
import java.time.LocalDateTime;
public interface CustomerTransitStatisticsReader {
SummaryProjection summarize(long customerId);
record SummaryProjection(Long customerId, Long containerCount, BigDecimal totalTeu,
Long unknownContainerCount, LocalDateTime asOf) {}
}
@@ -1,7 +1,7 @@
package com.baiyue.firstleg.customs.service;
import com.baiyue.firstleg.entity.ContainerDocumentEntity;
import com.baiyue.firstleg.repository.LoadingListRepository;
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;
import jakarta.annotation.Resource;
@@ -9,7 +9,7 @@ import org.springframework.stereotype.Component;
@Component
public class CustomsDispatchGateContributor implements OutboundDispatchGateContributor {
@Resource private LoadingListRepository repository;
@Resource private CustomsRepository repository;
@Override public String code() { return "CUSTOMS_CLEARANCE"; }
@@ -18,12 +18,13 @@ public class CustomsDispatchGateContributor implements OutboundDispatchGateContr
if (context.tenantId() <= 0 || context.customerId() <= 0) {
return gate(false, "INVALID_CUSTOMER_SCOPE");
}
ContainerDocumentEntity value = repository.document(context.containerBookingId(), "CUSTOMS_DECLARATION");
CustomsCaseEntity value = repository.gateCase(context.tenantId(), context.orderId(),
context.inboundId(), context.containerBookingId());
if (value == null) return gate(false, "MISSING");
return gate(value.getCompletedAt() != null, value.getCompletedAt() == null ? "UPLOADED" : "COMPLETED");
return gate("CLEARANCE_COMPLETED".equals(value.getStatus()), value.getStatus());
}
private static GateItem gate(boolean passed, String actual) {
return new GateItem("CUSTOMS_CLEARANCE", "报关完成", passed, actual, "COMPLETED");
return new GateItem("CUSTOMS_CLEARANCE", "报关完成", passed, actual, "CLEARANCE_COMPLETED");
}
}
@@ -17,7 +17,7 @@ public record InboundOrderSummary(
Long customerId, String customerName, Long companyId, String companyName,
Long warehouseId, String warehouseName,
Long deliveryWarehouseId, String deliveryWarehouseName,
String businessType, String channelCode, String channelName,
String businessType, String fulfillmentType, String channelCode, String channelName,
String taxMode, String pricingTaxMode, String containerMode, Boolean customsInspection,
String destinationType, Long destinationWarehouseId, String destinationCode, String destinationName,
Boolean holdFlag, String currentHoldReason,
@@ -56,6 +56,7 @@ public record InboundOrderSummary(
order.getCustomerId(), order.getCustomerName(),
order.getCompanyId(), order.getCompanyName(), order.getWarehouseId(), order.getWarehouseName(),
order.getDeliveryWarehouseId(), order.getDeliveryWarehouseName(), order.getBusinessType(),
order.getFulfillmentType(),
order.getChannelCode(), order.getChannelName(), order.getTaxMode(), order.getPricingTaxMode(),
order.getContainerMode(),
order.getCustomsInspection(), order.getDestinationType(), order.getDestinationWarehouseId(),
@@ -33,7 +33,8 @@ public record OrderApprovedInboundCommand(
String expressTrackingNo,
Integer totalPackageCount,
String pickupMethod,
LocalDateTime requestedPickupAt
LocalDateTime requestedPickupAt,
String fulfillmentType
) {
public OrderApprovedInboundCommand(
long tenantId, long orderId, String orderNo, String inboundCode, Long customerId, String customerName,
@@ -48,7 +49,7 @@ public record OrderApprovedInboundCommand(
deliveryWarehouseId, deliveryWarehouseName, expectedArrivalAt, businessType, channelCode, channelName,
carrierId, carrierName, taxMode, pricingTaxMode, containerMode, customsInspection, destinationType,
destinationWarehouseId, destinationCode, destinationName, publicRemark, expressCompanyName,
expressTrackingNo, totalPackageCount, null, null);
expressTrackingNo, totalPackageCount, null, null, "STANDARD");
}
/** 兼容尚未携带订单总箱数的历史事件。 */
public OrderApprovedInboundCommand(
@@ -63,7 +64,7 @@ public record OrderApprovedInboundCommand(
deliveryWarehouseId, deliveryWarehouseName, expectedArrivalAt, businessType, channelCode, channelName,
carrierId, carrierName, taxMode, pricingTaxMode, containerMode, customsInspection, destinationType,
destinationWarehouseId, destinationCode, destinationName, publicRemark, expressCompanyName,
expressTrackingNo, null, null, null);
expressTrackingNo, null, null, null, "STANDARD");
}
/** 兼容未携带报价包税维度的历史调用方。 */
@@ -79,6 +80,6 @@ public record OrderApprovedInboundCommand(
deliveryWarehouseId, deliveryWarehouseName, expectedArrivalAt, businessType, channelCode, channelName,
carrierId, carrierName, taxMode, null, containerMode, customsInspection, destinationType,
destinationWarehouseId, destinationCode, destinationName, publicRemark, expressCompanyName,
expressTrackingNo, null, null, null);
expressTrackingNo, null, null, null, "STANDARD");
}
}
@@ -38,6 +38,7 @@ public class InboundOrderEntity extends BaseEntity {
private Long deliveryWarehouseId;
private String deliveryWarehouseName;
private String businessType;
private String fulfillmentType;
private String channelCode;
private String channelName;
private Long carrierId;
@@ -97,13 +97,19 @@ public class LoadingDeliveryService {
@Transactional
public LoadingActionResponse completeLoading(long tenantId, long inboundId, long operatorId,
String operatorName, String key, CompleteLoadingRequest request) {
return completeLoading(tenantId, inboundId, inboundId, operatorId, operatorName, key, request);
}
private LoadingActionResponse completeLoading(long tenantId, long inboundId, long evidenceInboundId,
long operatorId, String operatorName, String key,
CompleteLoadingRequest request) {
String hash = hash(request);
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, COMPLETE_LOADING, key, hash);
if (replay != null) return replay;
Snapshot inbound = requireInbound(tenantId, inboundId);
CompletionTarget target = repository.completionTarget(tenantId, inboundId);
if (target != null) {
return completeFulfillmentSlice(tenantId, inboundId, operatorId, operatorName, key, request, target);
return completeFulfillmentSlice(tenantId, evidenceInboundId, operatorId, operatorName, key, request, target);
}
if ("LOADED".equals(inbound.processStatus()) || "DELIVERED".equals(inbound.processStatus())) {
loadingModelBridge.requireCompleted(tenantId, inboundId);
@@ -114,7 +120,7 @@ public class LoadingDeliveryService {
loadingModelBridge.requireCompleted(tenantId, inboundId);
completionGate.requireReleasedBooking(tenantId, inboundId);
requireCompletedPicking(tenantId, inboundId);
requireCompletionEvidence(tenantId, inboundId);
requireCompletionEvidence(tenantId, evidenceInboundId);
if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent();
addHistory(tenantId, inboundId, "LOADING", "LOADED", COMPLETE_LOADING, request.remark(), operatorId, operatorName);
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
@@ -129,13 +135,19 @@ public class LoadingDeliveryService {
public LoadingActionResponse completeAfterPdaSettlement(long tenantId, long inboundId,
long operatorId, String operatorName,
String key, String remark) {
return completeAfterPdaSettlement(tenantId, inboundId, inboundId, operatorId, operatorName, key, remark);
}
public LoadingActionResponse completeAfterPdaSettlement(long tenantId, long inboundId,
long evidenceInboundId, long operatorId,
String operatorName, String key, String remark) {
CompletionTarget target = repository.completionTarget(tenantId, inboundId);
long completionInboundId = target == null ? inboundId : target.completionInboundId();
Snapshot current = repository.findInboundForUpdate(tenantId, completionInboundId);
if (current == null || current.version() == null) {
throw new BizException(ResultCode.CONFLICT, "PDA结算后的实装履约分片不存在,请刷新后重试");
}
return completeLoading(tenantId, inboundId, operatorId, operatorName, key,
return completeLoading(tenantId, inboundId, evidenceInboundId, operatorId, operatorName, key,
new CompleteLoadingRequest(current.version(), remark));
}
@@ -144,13 +156,17 @@ public class LoadingDeliveryService {
* every external/business hard gate must pass before the PDA task is mutated.
*/
public void requirePdaCompletionReady(long tenantId, long inboundId) {
requirePdaCompletionReady(tenantId, inboundId, inboundId);
}
public void requirePdaCompletionReady(long tenantId, long inboundId, long evidenceInboundId) {
Snapshot inbound = requireInbound(tenantId, inboundId);
if (!"LOADING".equals(inbound.processStatus())) {
throw new BizException(ResultCode.CONFLICT, "PDA确认装柜完成前订单必须处于LOADING");
}
loadingModelBridge.requireStarted(tenantId, inboundId);
completionGate.requireReleasedBooking(tenantId, inboundId);
requireCompletionEvidence(tenantId, inboundId);
requireCompletionEvidence(tenantId, evidenceInboundId);
}
private LoadingActionResponse completeFulfillmentSlice(long tenantId, long evidenceInboundId,
@@ -167,7 +183,7 @@ public class LoadingDeliveryService {
loadingModelBridge.requireCompleted(tenantId, target.sourceInboundId());
completionGate.requireReleasedBooking(tenantId, target.sourceInboundId());
requireCompletedPicking(tenantId, target.sourceInboundId());
requireCompletionEvidence(tenantId, target.sourceInboundId());
requireCompletionEvidence(tenantId, evidenceInboundId);
if (repository.completeLoadedChild(tenantId, target.sourceInboundId(), operatorId,
request.expectedVersion()) != 1) throw concurrent();
repository.aggregateRootLoaded(tenantId, target.rootInboundId(), operatorId);
@@ -38,7 +38,7 @@ public interface InboundSplitSettlementMapper {
INSERT INTO fl_inbound_order (id,tenant_id,task_id,order_id,order_no,inbound_code,process_status,
order_status_snapshot,measure_review_status,fee_status,fee_review_status,notification_status,
expected_arrival_at,customer_id,customer_name,company_id,company_name,warehouse_id,warehouse_name,
delivery_warehouse_id,delivery_warehouse_name,business_type,channel_code,channel_name,carrier_id,carrier_name,
delivery_warehouse_id,delivery_warehouse_name,business_type,fulfillment_type,channel_code,channel_name,carrier_id,carrier_name,
tax_mode,pricing_tax_mode,container_mode,customs_inspection,destination_type,destination_warehouse_id,
destination_code,destination_name,hold_flag,current_hold_reason,total_package_count,package_count,
actual_gross_weight_kg,chargeable_weight_kg,actual_volume_cbm,chargeable_volume_cbm,pallet_count,volume_weight_kg,
@@ -48,7 +48,7 @@ public interface InboundSplitSettlementMapper {
SELECT #{childId},tenant_id,task_id,#{childOrderId},#{childOrderNo},#{childInboundCode},
CASE WHEN #{splitPurpose}='LOADED' THEN 'LOADING' ELSE 'WAIT_SPLIT' END,order_status_snapshot,
'APPROVED',fee_status,fee_review_status,notification_status,expected_arrival_at,customer_id,customer_name,
company_id,company_name,warehouse_id,warehouse_name,delivery_warehouse_id,delivery_warehouse_name,business_type,
company_id,company_name,warehouse_id,warehouse_name,delivery_warehouse_id,delivery_warehouse_name,business_type,fulfillment_type,
channel_code,channel_name,carrier_id,carrier_name,tax_mode,pricing_tax_mode,container_mode,customs_inspection,
destination_type,destination_warehouse_id,destination_code,destination_name,hold_flag,current_hold_reason,
#{packageCount},#{packageCount},
@@ -149,6 +149,34 @@ public interface InboundSplitSettlementMapper {
@Param("childMeasurementItemId") long childMeasurementItemId,
@Param("lineNo") int lineNo, @Param("packageCount") int packageCount);
@Update("""
UPDATE fl_inbound_carton_label
SET inbound_id=#{childInboundId}, order_id=#{childOrderId}, inbound_code=#{childInboundCode},
carton_index=carton_index-#{loadedPackageCount},
total_carton_count=#{remainingPackageCount},
detail_package_count=LEAST(detail_package_count,#{remainingPackageCount})
WHERE tenant_id=#{accountId} AND inbound_id=#{sourceInboundId} AND batch_status='ACTIVE'
ORDER BY carton_index DESC
LIMIT #{remainingPackageCount}
""")
int transferRemainingCartonLabels(@Param("accountId") long accountId,
@Param("sourceInboundId") long sourceInboundId,
@Param("childInboundId") long childInboundId,
@Param("childOrderId") long childOrderId,
@Param("childInboundCode") String childInboundCode,
@Param("loadedPackageCount") int loadedPackageCount,
@Param("remainingPackageCount") int remainingPackageCount);
@Update("""
UPDATE fl_inbound_carton_label
SET total_carton_count=#{loadedPackageCount},
detail_package_count=LEAST(detail_package_count,#{loadedPackageCount})
WHERE tenant_id=#{accountId} AND inbound_id=#{sourceInboundId} AND batch_status='ACTIVE'
""")
int normalizeLoadedCartonLabels(@Param("accountId") long accountId,
@Param("sourceInboundId") long sourceInboundId,
@Param("loadedPackageCount") int loadedPackageCount);
@Update("""
UPDATE fl_inbound_order
SET package_count = GREATEST(COALESCE(package_count, total_package_count) - #{packageCount}, 0),
@@ -177,9 +205,10 @@ public interface InboundSplitSettlementMapper {
@Update("""
UPDATE fl_inbound_measurement_item
SET package_count=#{packageCount},
total_volume_cbm=unit_volume_cbm*#{packageCount},
volume_weight_kg=volume_weight_kg/NULLIF(package_count,0)*#{packageCount},
SET total_volume_cbm=COALESCE(unit_volume_cbm,0)*#{packageCount},
volume_weight_kg=CASE WHEN #{packageCount}=0 THEN 0
ELSE COALESCE(volume_weight_kg/NULLIF(package_count,0)*#{packageCount},0) END,
package_count=#{packageCount},
updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{measurementItemId} AND tenant_id=#{accountId}
""")
@@ -218,7 +247,9 @@ public interface InboundSplitSettlementMapper {
@Update("""
UPDATE fl_loading_plan_item plan_item
JOIN fl_loading_pick_item pick_item ON pick_item.loading_plan_item_id = plan_item.id
SET plan_item.package_count = pick_item.picked_package_count,
SET plan_item.pallet_count = CEIL(COALESCE(plan_item.pallet_count,0)
* pick_item.picked_package_count / NULLIF(plan_item.package_count,0)),
plan_item.package_count = pick_item.picked_package_count,
plan_item.updated_by = #{operatorId}, plan_item.updated_by_name = #{operatorName},
plan_item.updated_at = CURRENT_TIMESTAMP(3), plan_item.version = plan_item.version + 1
WHERE plan_item.id = #{planItemId} AND plan_item.deleted = 0
@@ -3,6 +3,8 @@ package com.baiyue.firstleg.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
@@ -10,12 +12,15 @@ import java.util.List;
@Mapper
public interface LoadingCartonManifestMapper {
@Select("""
SELECT box_code FROM fl_inbound_carton_label
WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND inbound_detail_id=#{inboundDetailId}
AND batch_status='ACTIVE'
SELECT inbound_detail_id, box_code FROM fl_inbound_carton_label
WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} AND batch_status='ACTIVE'
ORDER BY carton_index,id
""")
List<String> labelPool(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("inboundDetailId") long inboundDetailId);
List<LabelRow> labelPool(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
@Getter @Setter
class LabelRow {
private Long inboundDetailId;
private String boxCode;
}
}
@@ -19,7 +19,7 @@ public interface LoadingCompletedEventMapper {
COALESCE(o.root_inbound_id, o.id) AS root_inbound_id,
o.order_id AS source_order_id, o.order_no AS source_order_no,
COALESCE(root_order.order_id, o.order_id) AS root_order_id,
o.inbound_code, o.business_type, o.destination_warehouse_id, o.destination_code AS destination_warehouse_code,
o.inbound_code, o.business_type, o.fulfillment_type, o.destination_type, o.destination_warehouse_id, o.destination_code AS destination_warehouse_code,
o.destination_name AS destination_warehouse_name,
i.id AS loading_plan_item_id, i.inbound_detail_id AS source_inbound_detail_id, i.sku_code,
i.product_name_zh, i.product_name_en, i.package_count, i.product_quantity,
@@ -73,7 +73,7 @@ public interface LoadingCompletedEventMapper {
private Integer productQuantity; private Integer palletCount; private BigDecimal grossWeightKg;
private BigDecimal netWeightKg; private BigDecimal dimensionalWeightKg; private BigDecimal volumeCbm;
private String fbaTrackingNo; private String shipmentNo; private String shippingMark;
private String businessType;
private String businessType; private String fulfillmentType; private String destinationType;
private String customerRemark; private String internalRemark; private String warehouseRemark;
}
@@ -174,7 +174,13 @@ public interface LoadingListQueryMapper {
SELECT i.id AS item_id, i.account_id, i.customer_name, i.order_id, i.order_no,
i.inbound_id, i.inbound_detail_id, i.inbound_code, b.container_no,
i.sku_code, i.destination_warehouse_name, i.product_name_zh,
product_name_en, product_image_url, package_count, pallet_count,
product_name_en, product_image_url, i.package_count,
COALESCE(NULLIF(i.pallet_count,0),
CEIL(COALESCE(NULLIF(inbound.pallet_count,0),
(SELECT COUNT(*) FROM fl_inbound_weight_result weight
WHERE weight.tenant_id=i.account_id AND weight.inbound_id=i.inbound_id
AND weight.deleted=0 AND weight.recognition_status='CONFIRMED'),0)
* i.package_count / NULLIF(inbound.total_package_count,0)),0) pallet_count,
gross_weight_kg, net_weight_kg, volume_cbm, dimensional_weight_kg,
product_quantity, units_per_carton, fba_tracking_no, shipment_no,
shipping_mark, internal_remark, customer_remark, warehouse_remark,
@@ -182,6 +188,7 @@ public interface LoadingListQueryMapper {
FROM fl_loading_plan_item i
JOIN fl_loading_plan p ON p.id=i.loading_plan_id
JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE'
LEFT JOIN fl_inbound_order inbound ON inbound.id=i.inbound_id AND inbound.tenant_id=i.account_id
WHERE i.loading_plan_id = #{planId} AND i.deleted = 0
ORDER BY i.load_sequence, i.id
""")
@@ -72,11 +72,25 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
@Param("operatorName") String operatorName);
@Select("""
SELECT COALESCE(SUM(plan_item.package_count),0) expectedCount,
SELECT COALESCE(SUM(CASE
WHEN pick_item.item_status='SKIPPED' AND pick_item.picked_package_count=0 THEN 0
WHEN pick_item.item_status='PARTIAL' AND pick_item.picked_package_count>0
AND pick_item.picked_package_count<=plan_item.package_count
THEN pick_item.picked_package_count
ELSE plan_item.package_count
END),0) expectedCount,
COALESCE(SUM(pick_item.picked_package_count),0) pickedCount,
COUNT(DISTINCT pick_task.id) taskCount,
COUNT(DISTINCT CASE WHEN pick_task.task_status='COMPLETED' THEN pick_task.id END) completedTaskCount,
SUM(CASE WHEN pick_item.id IS NULL OR pick_item.picked_package_count<>plan_item.package_count THEN 1 ELSE 0 END) unresolvedCount
SUM(CASE
WHEN pick_item.id IS NULL THEN 1
WHEN pick_item.item_status='PICKED'
AND pick_item.picked_package_count=plan_item.package_count THEN 0
WHEN pick_item.item_status='PARTIAL' AND pick_item.picked_package_count>0
AND pick_item.picked_package_count<=plan_item.package_count THEN 0
WHEN pick_item.item_status='SKIPPED' AND pick_item.picked_package_count=0 THEN 0
ELSE 1
END) unresolvedCount
FROM fl_loading_plan_item plan_item
JOIN fl_inbound_order inbound_order ON inbound_order.id=plan_item.inbound_id
AND inbound_order.tenant_id=#{tenantId} AND inbound_order.deleted=0
@@ -107,10 +121,12 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
}
@Select("""
SELECT DISTINCT plan_item.account_id tenantId,plan_item.inbound_id inboundId
SELECT plan_item.account_id tenantId,plan_item.inbound_id inboundId
FROM fl_loading_pick_item pick_item
JOIN fl_loading_plan_item plan_item ON plan_item.id=pick_item.loading_plan_item_id
WHERE pick_item.pick_task_id=#{taskId}
GROUP BY plan_item.account_id,plan_item.inbound_id
ORDER BY MIN(pick_item.id)
""")
java.util.List<InboundOwner> selectInboundOwners(@Param("taskId") long taskId);
@@ -214,7 +230,8 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
long unresolvedCount(@Param("taskId") long taskId);
/**
* 以装柜计划的全部有效明细为完成口径,不能只检查当前 PDA 任务中的明细。
* 以结算后的装柜计划事实为完成口径,不能只检查当前 PDA 任务中的明细。
* PARTIAL 在本查询前已把计划箱数收敛为实际拣货箱数;收敛前的严格小于关系由结算计划校验。
*/
@Select("""
SELECT COUNT(*)
@@ -228,7 +245,15 @@ public interface LoadingPickItemMapper extends BaseMapper<LoadingPickItemEntity>
WHERE pick_item.loading_plan_item_id = plan_item.id
AND pick_task.loading_plan_id = #{planId}
AND pick_task.task_status = 'LOADING'
AND pick_item.picked_package_count = plan_item.package_count
AND (
(pick_item.item_status = 'PICKED'
AND pick_item.picked_package_count = plan_item.package_count)
OR (pick_item.item_status = 'PARTIAL'
AND pick_item.picked_package_count > 0
AND pick_item.picked_package_count = plan_item.package_count)
OR (pick_item.item_status = 'SKIPPED'
AND pick_item.picked_package_count = 0)
)
)
""")
long unfinishedCount(@Param("planId") long planId);
@@ -15,7 +15,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
WHERE o.inbound_role='MASTER'
AND split_item.tenant_id=o.tenant_id
AND split_item.parent_inbound_id=o.id
AND split_item.source_measurement_item_id=i.id),0) availablePackageCount,
AND split_item.source_inbound_detail_id=i.inbound_detail_id),0) availablePackageCount,
COALESCE(o.actual_gross_weight_kg / NULLIF(o.total_package_count, 0), 0) unitGrossWeightKg,
COALESCE(o.actual_net_weight_kg / NULLIF(o.total_package_count, 0), 0) unitNetWeightKg,
COALESCE(i.unit_volume_cbm,0) unitVolumeCbm
@@ -24,11 +24,17 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
AND m.status='APPROVED'
AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2
WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED')
JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id
JOIN (
SELECT tenant_id,measurement_id,inbound_detail_id,SUM(package_count) package_count,
COALESCE(SUM(COALESCE(total_volume_cbm,COALESCE(unit_volume_cbm,0)*package_count))
/ NULLIF(SUM(package_count),0),0) unit_volume_cbm
FROM fl_inbound_measurement_item
WHERE inbound_detail_id IS NOT NULL AND package_count>0
GROUP BY tenant_id,measurement_id,inbound_detail_id
) i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id
WHERE o.warehouse_id=#{warehouseId} AND o.process_status='WAIT_SPLIT'
AND o.actual_gross_weight_kg IS NOT NULL AND o.actual_net_weight_kg IS NOT NULL
AND (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT'))
AND i.inbound_detail_id IS NOT NULL
AND i.package_count>COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
WHERE li.account_id=o.tenant_id AND li.inbound_id=o.id
AND li.inbound_detail_id=i.inbound_detail_id AND li.deleted=0),0)
@@ -37,8 +43,8 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
WHERE o.inbound_role='MASTER'
AND split_item.tenant_id=o.tenant_id
AND split_item.parent_inbound_id=o.id
AND split_item.source_measurement_item_id=i.id),0)
ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC
AND split_item.source_inbound_detail_id=i.inbound_detail_id),0)
ORDER BY o.inbounded_at ASC, o.id ASC, i.inbound_detail_id ASC
""")
List<Candidate> candidates(@Param("warehouseId") long warehouseId);
@@ -56,7 +62,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
WHERE o.inbound_role='MASTER'
AND split_item.tenant_id=o.tenant_id
AND split_item.parent_inbound_id=o.id
AND split_item.source_measurement_item_id=i.id),0) availablePackageCount,
AND split_item.source_inbound_detail_id=i.inbound_detail_id),0) availablePackageCount,
COALESCE(o.actual_gross_weight_kg / NULLIF(o.total_package_count, 0), 0) unitGrossWeightKg,
COALESCE(o.actual_net_weight_kg / NULLIF(o.total_package_count, 0), 0) unitNetWeightKg,
COALESCE(i.unit_volume_cbm,0) unitVolumeCbm
@@ -65,13 +71,27 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
AND m.status='APPROVED'
AND m.id=(SELECT MAX(m2.id) FROM fl_inbound_measurement m2
WHERE m2.tenant_id=o.tenant_id AND m2.inbound_id=o.id AND m2.status='APPROVED')
JOIN fl_inbound_measurement_item i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id
WHERE o.warehouse_id=#{warehouseId} AND o.id=#{inboundId} AND o.process_status='WAIT_SPLIT'
JOIN (
SELECT tenant_id,measurement_id,inbound_detail_id,SUM(package_count) package_count,
COALESCE(SUM(COALESCE(total_volume_cbm,COALESCE(unit_volume_cbm,0)*package_count))
/ NULLIF(SUM(package_count),0),0) unit_volume_cbm
FROM fl_inbound_measurement_item
WHERE inbound_detail_id IS NOT NULL AND package_count>0
GROUP BY tenant_id,measurement_id,inbound_detail_id
) i ON i.tenant_id=o.tenant_id AND i.measurement_id=m.id
WHERE o.warehouse_id=#{warehouseId} AND o.id=#{inboundId}
AND (o.process_status='WAIT_SPLIT' OR (o.process_status='LOADING' AND EXISTS (
SELECT 1 FROM fl_loading_plan_item same_plan_item
WHERE same_plan_item.loading_plan_id=#{planId}
AND same_plan_item.account_id=o.tenant_id
AND same_plan_item.inbound_id=o.id
AND same_plan_item.deleted=0
)))
AND o.actual_gross_weight_kg IS NOT NULL AND o.actual_net_weight_kg IS NOT NULL
AND (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT'))
AND i.inbound_detail_id=#{detailId}
ORDER BY m.id DESC LIMIT 1 FOR UPDATE
""")
Candidate candidate(@Param("warehouseId") long warehouseId,@Param("inboundId") long inboundId,
@Param("detailId") long detailId);
Candidate candidate(@Param("planId") long planId,@Param("warehouseId") long warehouseId,
@Param("inboundId") long inboundId,@Param("detailId") long detailId);
}
@@ -87,6 +87,25 @@ public interface OutboundDispatchReadMapper {
SELECT 'TRUCK_APPOINTMENT',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.reason,h.created_at
FROM fl_truck_appointment_history h WHERE h.appointment_id=#{truckAppointmentId}
UNION ALL
SELECT 'CUSTOMS',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.note,h.created_at
FROM fl_customs_history h JOIN fl_customs_case customs_case ON customs_case.id=h.customs_case_id
WHERE customs_case.tenant_id=#{tenantId} AND customs_case.order_id=#{orderId}
AND customs_case.inbound_id=#{inboundId} AND customs_case.container_booking_id=#{containerBookingId}
UNION ALL
SELECT 'SAILING_SCHEDULE',h.action,NULL,NULL,h.operator_id,h.operator_name,h.reason,h.created_at
FROM fl_port_schedule_history h JOIN fl_port_schedule port_schedule ON port_schedule.id=h.port_schedule_id
WHERE port_schedule.customer_id=#{tenantId} AND port_schedule.ocean_booking_id=#{oceanBookingId}
AND port_schedule.container_booking_id=#{containerBookingId}
AND EXISTS (
SELECT 1 FROM fl_loading_plan_item schedule_item
JOIN fl_loading_plan schedule_plan ON schedule_plan.id=schedule_item.loading_plan_id
JOIN fl_inbound_order schedule_inbound ON schedule_inbound.id=schedule_item.inbound_id
AND schedule_inbound.tenant_id=schedule_item.account_id AND schedule_inbound.deleted=0
WHERE schedule_item.deleted=0 AND schedule_plan.container_booking_id=port_schedule.container_booking_id
AND schedule_inbound.tenant_id=#{tenantId} AND schedule_inbound.order_id=#{orderId}
AND schedule_inbound.id=#{inboundId}
)
UNION ALL
SELECT 'OUTBOUND_DISPATCH',h.action,h.from_status,h.to_status,h.operator_id,h.operator_name,h.remark,h.created_at
FROM fl_outbound_dispatch_history h WHERE h.tenant_id=#{tenantId} AND h.inbound_id=#{inboundId}
AND h.container_booking_id=#{containerBookingId}
@@ -95,6 +114,7 @@ public interface OutboundDispatchReadMapper {
List<TimelineItem> timeline(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("containerBookingId") long containerBookingId,
@Param("loadingPlanId") long loadingPlanId,
@Param("orderId") long orderId,
@Param("oceanBookingId") Long oceanBookingId,
@Param("truckAppointmentId") Long truckAppointmentId);
@@ -57,6 +57,6 @@ public class OutboundDispatchRepository {
}
public List<TimelineItem> timeline(OutboundDispatchReadMapper.Binding binding, Long oceanId, Long truckId) {
return reads.timeline(binding.tenantId(), binding.inboundId(), binding.containerBookingId(),
binding.loadingPlanId(), oceanId, truckId);
binding.loadingPlanId(), binding.orderId(), oceanId, truckId);
}
}
@@ -106,6 +106,11 @@ public class OutboundDispatchService {
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 (repository.reverse(state.getId(), state.getVersion(), operatorId, operatorName, request.remark()) != 1) {
throw concurrent();
}
@@ -49,8 +49,8 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
@Select("""
SELECT * FROM fl_carrier_schedule_query
WHERE status IN ('READY','MANUAL_REQUIRED')
AND next_allowed_at IS NOT NULL AND next_allowed_at&lt;=#{now}
AND (lease_until IS NULL OR lease_until&lt;#{now})
AND next_allowed_at IS NOT NULL AND next_allowed_at <= #{now}
AND (lease_until IS NULL OR lease_until < #{now})
ORDER BY next_allowed_at,id LIMIT #{limit}
""")
List<CarrierScheduleQueryEntity> selectDue(@Param("now") LocalDateTime now, @Param("limit") int limit);
@@ -59,8 +59,8 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
UPDATE fl_carrier_schedule_query SET lease_owner=#{owner},lease_until=#{leaseUntil},
updated_at=CURRENT_TIMESTAMP(3)
WHERE id=#{id} AND status IN ('READY','MANUAL_REQUIRED')
AND next_allowed_at IS NOT NULL AND next_allowed_at&lt;=#{now}
AND (lease_until IS NULL OR lease_until&lt;#{now})
AND next_allowed_at IS NOT NULL AND next_allowed_at <= #{now}
AND (lease_until IS NULL OR lease_until < #{now})
""")
int claimLease(@Param("id") long id, @Param("owner") String owner,
@Param("now") LocalDateTime now, @Param("leaseUntil") LocalDateTime leaseUntil);
@@ -70,6 +70,17 @@ public class CarrierScheduleQueryRepository {
.orderByAsc(CarrierScheduleNodeFactEntity::getId));
}
public boolean insertIfAbsent(CarrierScheduleNodeFactEntity value) { return nodes.insertIgnore(value) == 1; }
public CarrierScheduleNodeFactEntity node(long queryId, String eventKey) {
return nodes.selectOne(new LambdaQueryWrapper<CarrierScheduleNodeFactEntity>()
.eq(CarrierScheduleNodeFactEntity::getQueryId, queryId)
.eq(CarrierScheduleNodeFactEntity::getEventKey, eventKey));
}
public boolean hasActualNode(long queryId, String standardNode) {
return nodes.selectCount(new LambdaQueryWrapper<CarrierScheduleNodeFactEntity>()
.eq(CarrierScheduleNodeFactEntity::getQueryId, queryId)
.eq(CarrierScheduleNodeFactEntity::getStandardNode, standardNode)
.isNotNull(CarrierScheduleNodeFactEntity::getActualAt)) > 0;
}
public List<CarrierScheduleExceptionFactEntity> exceptions(long id) {
return exceptions.selectList(new LambdaQueryWrapper<CarrierScheduleExceptionFactEntity>()
.eq(CarrierScheduleExceptionFactEntity::getQueryId, id)
@@ -79,6 +90,11 @@ public class CarrierScheduleQueryRepository {
public boolean insertIfAbsent(CarrierScheduleExceptionFactEntity value) {
return exceptions.insertIfAbsent(value) == 1;
}
public CarrierScheduleExceptionFactEntity exception(long queryId, String eventKey) {
return exceptions.selectOne(new LambdaQueryWrapper<CarrierScheduleExceptionFactEntity>()
.eq(CarrierScheduleExceptionFactEntity::getQueryId, queryId)
.eq(CarrierScheduleExceptionFactEntity::getEventKey, eventKey));
}
public boolean claimOperation(Long queryId, long operatorId, String operation, String key, String hash) {
return operations.claim(IdWorker.getId(), queryId, operatorId, operation, key, hash) == 1;
}
@@ -0,0 +1,75 @@
package com.baiyue.firstleg.portschedule.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventIdentifiers;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
import com.baiyue.common.event.EventTypes;
import com.baiyue.common.event.FirstLegContainerShippingNodeRecordedV1;
import com.baiyue.common.event.RegionCode;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.ZoneId;
/** Writes actual carrier node facts to the domestic transactional Outbox. */
@Service
public class CarrierScheduleNodeOutboxService {
@Resource private InboundOutboxMapper outbox;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}")
private String scheduleZone = "Asia/Shanghai";
public void publish(CarrierScheduleQueryEntity query, CarrierScheduleNodeFactEntity fact,
String containerNo) {
if (fact.getActualAt() == null) return;
ZoneId zone = ZoneId.of(scheduleZone);
var payload = new FirstLegContainerShippingNodeRecordedV1(
string(query.getId()), string(query.getCustomerId()), string(query.getOceanBookingId()),
string(query.getContainerBookingId()), containerNo, fact.getStandardNode(), fact.getNodeSequence(),
fact.getObservedAt().atZone(zone).toInstant(), fact.getActualAt().atZone(zone).toInstant(),
fact.getLocationCode(), fact.getLocationName(), fact.getProviderCode(), fact.getProviderEventId(),
fact.getProviderNodeCode(), fact.getSourceReference(), fact.getRawPayloadHash());
JsonNode payloadNode = objectMapper.valueToTree(payload);
String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode));
long aggregateVersion = Math.max(0, query.getVersion() == null ? 0 : query.getVersion());
String eventKey = "CARRIER_NODE:" + query.getId() + ':' + fact.getId();
EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(),
EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, 1,
fact.getActualAt().atZone(zone).toInstant(), RegionCode.CN, RegionCode.OVERSEAS,
string(query.getCustomerId()), "CONTAINER_BOOKING", string(query.getContainerBookingId()),
aggregateVersion, aggregateVersion + 1, EventIdentifiers.newUuid(), eventKey, null,
payloadNode, payloadHash).requireValid();
InboundOutboxEntity row = new InboundOutboxEntity();
row.setId(IdWorker.getId()); row.setTenantId(query.getCustomerId());
row.setAggregateType(envelope.aggregateType()); row.setAggregateId(query.getContainerBookingId());
row.setEventType(envelope.eventType()); row.setEventKey(eventKey); row.setEventVersion(1);
row.setAggregateSequence(Math.toIntExact(aggregateVersion)); row.setPayloadHash(payloadHash);
row.setPayloadJson(json(envelope)); row.setPublishStatus("PENDING"); row.setRetryCount(0);
row.setCreatedAt(LocalDateTime.now());
try { outbox.insert(row); } catch (DuplicateKeyException ignored) { }
}
private String json(Object value) {
try { return objectMapper.writeValueAsString(value); }
catch (JsonProcessingException exception) {
throw new BizException(ResultCode.INTERNAL_ERROR, "船节点交接事件序列化失败");
}
}
private static String string(Long value) { return value == null ? null : String.valueOf(value); }
}
@@ -71,22 +71,24 @@ public class CarrierScheduleQueryScheduler {
return;
}
var result = response.data();
List<CarrierScheduleIntegrationClient.Observation> observations = result.observations() == null
? List.of() : result.observations();
for (var observation : observations) {
service.applyProviderObservation(candidate.getId(), new ProviderObservationRequest(
result.providerCode(), observation.providerEventId(), observation.providerNodeCode(),
observation.standardNode(), observation.observedAt(), observation.estimatedAt(),
observation.actualAt(), observation.locationCode(), observation.locationName(),
observation.sourceReference(), observation.rawPayloadHash()));
}
List<CarrierScheduleIntegrationClient.ExceptionObservation> exceptions = result.exceptions() == null
? List.of() : result.exceptions();
for (var exception : exceptions) {
service.applyProviderException(candidate.getId(), new ProviderExceptionRequest(
result.providerCode(), exception.providerEventId(), exception.providerStatus(),
exception.exceptionCode(), exception.active(), exception.observedAt(), exception.locationCode(),
exception.locationName(), exception.sourceReference(), exception.rawPayloadHash()));
if ("SUCCESS".equals(result.outcome())) {
List<CarrierScheduleIntegrationClient.Observation> observations = result.observations() == null
? List.of() : result.observations();
for (var observation : observations) {
service.applyProviderObservation(candidate.getId(), new ProviderObservationRequest(
result.providerCode(), observation.providerEventId(), observation.providerNodeCode(),
observation.standardNode(), observation.observedAt(), observation.estimatedAt(),
observation.actualAt(), observation.locationCode(), observation.locationName(),
observation.sourceReference(), observation.rawPayloadHash()));
}
List<CarrierScheduleIntegrationClient.ExceptionObservation> exceptions = result.exceptions() == null
? List.of() : result.exceptions();
for (var exception : exceptions) {
service.applyProviderException(candidate.getId(), new ProviderExceptionRequest(
result.providerCode(), exception.providerEventId(), exception.providerStatus(),
exception.exceptionCode(), exception.active(), exception.observedAt(), exception.locationCode(),
exception.locationName(), exception.sourceReference(), exception.rawPayloadHash()));
}
}
service.completeScheduledAttempt(candidate.getId(), result.outcome(), result.errorCode(),
result.errorMessage());
@@ -59,6 +59,7 @@ public class CarrierScheduleQueryService {
@Resource private CarrierScheduleQueryRepository repository;
@Resource private PortScheduleRepository portSchedules;
@Resource private CarrierScheduleAdapterRegistry adapters;
@Resource private CarrierScheduleNodeOutboxService nodeOutbox;
@Resource private ObjectMapper objectMapper;
@Value("${baiyue.carrier-schedule.scheduler.zone:Asia/Shanghai}")
private String scheduleZone = "Asia/Shanghai";
@@ -127,9 +128,10 @@ public class CarrierScheduleQueryService {
if (concurrent != null) return concurrent;
throw exception;
}
addNode(value.getId(), "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM",
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;
@@ -140,14 +142,17 @@ public class CarrierScheduleQueryService {
CarrierScheduleQueryEntity value = requiredLocked(id);
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
CarrierScheduleQueryEntity before = copy(value);
if (!addNode(id, request.providerCode(), request.providerEventId(), request.providerNodeCode(), node,
CarrierScheduleNodeFactEntity fact = addNode(id, request.providerCode(), request.providerEventId(),
request.providerNodeCode(), node,
request.observedAt(), request.estimatedAt(), request.actualAt(), request.locationCode(),
request.locationName(), request.sourceReference(), request.rawPayloadHash())) {
request.locationName(), request.sourceReference(), request.rawPayloadHash());
if (fact == null) {
return detail(value);
}
boolean advanced = advanceActualNode(value, node, request.actualAt(), request.observedAt(),
0L, "carrier-scheduler");
CarrierScheduleQueryEntity after = advanced ? required(id) : value;
nodeOutbox.publish(after, fact, requireContainerNo(after.getContainerBookingId()));
addHistory(id, "NODE_OBSERVED", before.getVersion(), after.getVersion(), request.standardNode(), before,
after, 0L, "carrier-scheduler");
return detail(after);
@@ -180,9 +185,11 @@ public class CarrierScheduleQueryService {
CarrierScheduleNode node = CarrierScheduleNode.require(request.standardNode());
CarrierScheduleQueryEntity before = copy(value);
String providerEventId = sha256("MANUAL|" + id + '|' + key);
if (!addNode(id, "MANUAL", providerEventId, node.name(), node, request.observedAt(),
CarrierScheduleNodeFactEntity fact = addNode(id, "MANUAL", providerEventId, node.name(), node,
request.observedAt(),
request.estimatedAt(), request.actualAt(), request.locationCode(), request.locationName(),
"MANUAL:" + operatorId, hash(operation, id, request))) {
"MANUAL:" + operatorId, hash(operation, id, request));
if (fact == null) {
return complete(id, operatorId, key, detail(value));
}
boolean advanced = advanceActualNode(value, node, request.actualAt(), request.observedAt(),
@@ -191,6 +198,7 @@ public class CarrierScheduleQueryService {
throw concurrent();
}
CarrierScheduleQueryEntity after = required(id);
nodeOutbox.publish(after, fact, requireContainerNo(after.getContainerBookingId()));
addHistory(id, operation, before.getVersion(), after.getVersion(), request.reason(), before, after,
operatorId, operatorName);
return complete(id, operatorId, key, detail(after));
@@ -221,6 +229,12 @@ public class CarrierScheduleQueryService {
return false;
}
public boolean hasOutboundDepartureFact(long customerId, long oceanBookingId, long containerBookingId) {
CarrierScheduleQueryEntity query = repository.findByBusinessScope(customerId, oceanBookingId,
containerBookingId);
return query != null && repository.hasActualNode(query.getId(), CarrierScheduleNode.DEPARTED_WAREHOUSE.name());
}
@Transactional
public boolean deferMissedNormalSlot(long id, LocalDateTime scanAt) {
CarrierScheduleQueryEntity value = requiredLocked(id);
@@ -426,7 +440,8 @@ public class CarrierScheduleQueryService {
value.getReason(), value.getOperatorId(), value.getOperatorName(), value.getCreatedAt());
}
private boolean addNode(long queryId, String providerCode, String providerEventId, String providerNodeCode,
private CarrierScheduleNodeFactEntity addNode(long queryId, String providerCode, String providerEventId,
String providerNodeCode,
CarrierScheduleNode node, LocalDateTime observedAt, LocalDateTime estimatedAt,
LocalDateTime actualAt, String locationCode, String locationName,
String sourceReference, String rawPayloadHash) {
@@ -439,7 +454,12 @@ public class CarrierScheduleQueryService {
fact.setNodeSequence(node.sequence()); fact.setObservedAt(observedAt); fact.setEstimatedAt(estimatedAt);
fact.setActualAt(actualAt); fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName));
fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash);
return repository.insertIfAbsent(fact);
if (repository.insertIfAbsent(fact)) return fact;
CarrierScheduleNodeFactEntity existing = repository.node(queryId, fact.getEventKey());
if (existing != null && !Objects.equals(existing.getRawPayloadHash(), rawPayloadHash)) {
throw conflict("相同providerEventId对应不同rawPayloadHash,拒绝覆盖既有节点事实");
}
return null;
}
private boolean addException(long queryId, String providerCode, String providerEventId,
@@ -454,7 +474,20 @@ public class CarrierScheduleQueryService {
fact.setExceptionCode(code.name()); fact.setActive(active); fact.setObservedAt(observedAt);
fact.setLocationCode(trim(locationCode)); fact.setLocationName(trim(locationName));
fact.setSourceReference(trim(sourceReference)); fact.setRawPayloadHash(rawPayloadHash);
return repository.insertIfAbsent(fact);
if (repository.insertIfAbsent(fact)) return true;
CarrierScheduleExceptionFactEntity existing = repository.exception(queryId, fact.getEventKey());
if (existing != null && !Objects.equals(existing.getRawPayloadHash(), rawPayloadHash)) {
throw conflict("相同providerEventId对应不同rawPayloadHash,拒绝覆盖既有异常事实");
}
return false;
}
private String requireContainerNo(long containerBookingId) {
ContainerBookingEntity container = portSchedules.containerBooking(containerBookingId);
if (container == null || !StringUtils.hasText(container.getContainerNo())) {
throw conflict("跨区船节点交接要求柜次具备柜号");
}
return container.getContainerNo().trim().toUpperCase(Locale.ROOT);
}
private static QueryView view(CarrierScheduleQueryEntity value, LocalDateTime now) {
@@ -60,7 +60,8 @@ public class InboundMeasurementRepository {
.eq(InboundOrderEntity::getTenantId, inbound.getTenantId())
.eq(InboundOrderEntity::getId, inbound.getId())
.set(InboundOrderEntity::getActualGrossWeightKg, inbound.getActualGrossWeightKg())
.set(InboundOrderEntity::getActualNetWeightKg, inbound.getActualNetWeightKg())) == 1;
.set(InboundOrderEntity::getActualNetWeightKg, inbound.getActualNetWeightKg())
.set(InboundOrderEntity::getPalletCount, inbound.getPalletCount())) == 1;
}
public InboundMeasurementEntity findMeasurement(long tenantId, long inboundId, long measurementId) {
@@ -15,6 +15,7 @@ import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity;
import com.baiyue.firstleg.entity.InboundPickupSnapshotEntity;
import com.baiyue.firstleg.entity.InboundWeightResultEntity;
import com.baiyue.firstleg.mapper.InboundFileMapper;
import com.baiyue.firstleg.mapper.InboundInternalNoteMapper;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
@@ -22,6 +23,7 @@ import com.baiyue.firstleg.mapper.InboundPalletAllocationMapper;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.mapper.InboundTransportSnapshotMapper;
import com.baiyue.firstleg.mapper.InboundPickupSnapshotMapper;
import com.baiyue.firstleg.mapper.InboundWeightResultMapper;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
@@ -48,6 +50,8 @@ public class InboundOrderRepository {
private InboundInternalNoteMapper notes;
@Resource
private InboundStatusHistoryMapper histories;
@Resource
private InboundWeightResultMapper weights;
public PageResult<InboundOrderSummary> page(Long tenantId, InboundOrderQuery query) {
LambdaQueryWrapper<InboundOrderEntity> wrapper = new LambdaQueryWrapper<InboundOrderEntity>()
@@ -287,6 +291,18 @@ public class InboundOrderRepository {
Collectors.joining("、"))));
}
public Map<Long, Integer> confirmedPalletCountsMap(Long tenantId, Map<Long, Long> tenantByInbound) {
if (tenantByInbound.isEmpty()) return Map.of();
return weights.selectList(new LambdaQueryWrapper<InboundWeightResultEntity>()
.eq(tenantId != null, InboundWeightResultEntity::getTenantId, tenantId)
.in(InboundWeightResultEntity::getInboundId, tenantByInbound.keySet())
.eq(InboundWeightResultEntity::getRecognitionStatus, "CONFIRMED"))
.stream()
.filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId()))
.collect(Collectors.groupingBy(InboundWeightResultEntity::getInboundId,
java.util.LinkedHashMap::new, Collectors.summingInt(item -> 1)));
}
private static boolean owns(Map<Long, Long> tenantByInbound, Long inboundId, Long tenantId) {
return inboundId != null && tenantId != null && tenantId.equals(tenantByInbound.get(inboundId));
}
@@ -13,11 +13,14 @@ import java.util.List;
@Repository
public class LoadingCompletedOutboxRepository {
private static final List<String> CROSS_REGION_EVENT_TYPES = List.of(
EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,
EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED);
@Resource private InboundOutboxMapper mapper;
public List<InboundOutboxEntity> pending(int limit, LocalDateTime now, LocalDateTime expiredLeaseBefore) {
return mapper.selectList(new LambdaQueryWrapper<InboundOutboxEntity>()
.eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)
.in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES)
.and(status -> status.eq(InboundOutboxEntity::getPublishStatus, "PENDING")
.and(retry -> retry.isNull(InboundOutboxEntity::getNextRetryAt)
.or().le(InboundOutboxEntity::getNextRetryAt, now))
@@ -29,15 +32,17 @@ public class LoadingCompletedOutboxRepository {
public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime expiredLeaseBefore) {
return mapper.update(null, new LambdaUpdateWrapper<InboundOutboxEntity>()
.eq(InboundOutboxEntity::getId, id)
.eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)
.in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES)
.and(status -> status.eq(InboundOutboxEntity::getPublishStatus, "PENDING")
.and(retry -> retry.isNull(InboundOutboxEntity::getNextRetryAt)
.or().le(InboundOutboxEntity::getNextRetryAt, now))
.or(stale -> stale.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING")
.le(InboundOutboxEntity::getLockedAt, expiredLeaseBefore)))
.set(InboundOutboxEntity::getPublishStatus, "PROCESSING").set(InboundOutboxEntity::getLockedBy, worker)
.set(InboundOutboxEntity::getLockedAt, now)) == 1;
}
public InboundOutboxEntity claimed(long id, String worker) { return mapper.selectOne(new LambdaQueryWrapper<InboundOutboxEntity>()
.eq(InboundOutboxEntity::getId, id).eq(InboundOutboxEntity::getEventType, EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)
.eq(InboundOutboxEntity::getId, id).in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES)
.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING").eq(InboundOutboxEntity::getLockedBy, worker)); }
public void published(long id, String worker, LocalDateTime now) { mapper.update(null, claimedBase(id, worker)
.set(InboundOutboxEntity::getPublishStatus, "PUBLISHED").set(InboundOutboxEntity::getPublishedAt, now)
@@ -48,7 +53,7 @@ public class LoadingCompletedOutboxRepository {
.set(InboundOutboxEntity::getNextRetryAt, retryAt).set(InboundOutboxEntity::getLockedBy, null)
.set(InboundOutboxEntity::getLockedAt, null).set(InboundOutboxEntity::getLastError, truncate(error))); }
private LambdaUpdateWrapper<InboundOutboxEntity> base(long id, String status) { return new LambdaUpdateWrapper<InboundOutboxEntity>()
.eq(InboundOutboxEntity::getId,id).eq(InboundOutboxEntity::getEventType,EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)
.eq(InboundOutboxEntity::getId,id).in(InboundOutboxEntity::getEventType,CROSS_REGION_EVENT_TYPES)
.eq(InboundOutboxEntity::getPublishStatus,status); }
private LambdaUpdateWrapper<InboundOutboxEntity> claimedBase(long id,String worker) { return base(id,"PROCESSING").eq(InboundOutboxEntity::getLockedBy,worker); }
private static String truncate(String value) { if(value==null)return "unknown"; String v=value.replaceAll("[\\r\\n\\t]+"," "); return v.length()>500?v.substring(0,500):v; }
@@ -6,11 +6,13 @@ import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.mapper.ContainerBookingMapper;
import com.baiyue.firstleg.mapper.LoadingPlanHistoryMapper;
import com.baiyue.firstleg.mapper.LoadingPlanItemMapper;
import com.baiyue.firstleg.mapper.LoadingPlanMapper;
import com.baiyue.firstleg.mapper.SmartLoadingCandidateMapper;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
import org.springframework.stereotype.Repository;
import jakarta.annotation.Resource;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -26,10 +28,16 @@ public class SmartLoadingRepository {
@Resource private LoadingPlanHistoryMapper histories;
@Resource private ContainerBookingMapper bookings;
@Resource private SmartLoadingCandidateMapper candidates;
@Resource private InboundOrderMapper inbounds;
public List<Candidate> candidates(long warehouseId) { return candidates.candidates(warehouseId); }
public Candidate candidate(long warehouseId, long inboundId, long detailId) {
return candidates.candidate(warehouseId, inboundId, detailId);
public Candidate candidate(long planId, long warehouseId, long inboundId, long detailId) {
return candidates.candidate(planId, warehouseId, inboundId, detailId);
}
public InboundOrderEntity inbound(long accountId, long inboundId) {
return inbounds.selectOne(new LambdaQueryWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, accountId)
.eq(InboundOrderEntity::getId, inboundId));
}
public List<LoadingPlanItemEntity> activeItemsByInbound(long planId, long accountId, long inboundId) {
return items.selectList(new LambdaQueryWrapper<LoadingPlanItemEntity>()
@@ -41,7 +41,7 @@ public class FeePaymentSnapshotValidator {
}
return Snapshot.empty();
}
if (!"PAYABLE".equals(direction) || !MODES.contains(mode)) {
if (!"PAYABLE".equals(direction) || mode == null || !MODES.contains(mode)) {
throw bad("应付费用必须选择ON_ACCOUNT或DIRECT_PAID");
}
if ("ON_ACCOUNT".equals(mode)) {
@@ -330,6 +330,7 @@ public class InboundMeasurementService {
repository.listWeights(inbound.getTenantId(), inbound.getId()));
inbound.setActualGrossWeightKg(totals.grossWeightKg());
inbound.setActualNetWeightKg(totals.netWeightKg());
inbound.setPalletCount(totals.palletCount());
if ("WAIT_MEASURE".equals(inbound.getProcessStatus())) {
inbound.setProcessStatus("MEASURING");
}
@@ -68,6 +68,7 @@ public class InboundOrderCreationService {
inbound.setDeliveryWarehouseId(command.deliveryWarehouseId());
inbound.setDeliveryWarehouseName(command.deliveryWarehouseName());
inbound.setBusinessType(command.businessType());
inbound.setFulfillmentType(fulfillmentType(command.fulfillmentType()));
inbound.setChannelCode(command.channelCode());
inbound.setChannelName(command.channelName());
inbound.setCarrierId(command.carrierId());
@@ -160,6 +161,7 @@ public class InboundOrderCreationService {
.set(InboundOrderEntity::getDeliveryWarehouseName, command.deliveryWarehouseName())
.set(InboundOrderEntity::getExpectedArrivalAt, command.expectedArrivalAt())
.set(InboundOrderEntity::getBusinessType, command.businessType())
.set(InboundOrderEntity::getFulfillmentType, fulfillmentType(command.fulfillmentType()))
.set(InboundOrderEntity::getChannelCode, command.channelCode())
.set(InboundOrderEntity::getChannelName, command.channelName())
.set(InboundOrderEntity::getDestinationType, command.destinationType())
@@ -181,6 +183,7 @@ public class InboundOrderCreationService {
.set(InboundOrderEntity::getProcessStatus, "WAIT_MEASURE")
.set(InboundOrderEntity::getOrderStatusSnapshot, "APPROVED")
.set(InboundOrderEntity::getTotalPackageCount, command.totalPackageCount())
.set(InboundOrderEntity::getFulfillmentType, fulfillmentType(command.fulfillmentType()))
.set(InboundOrderEntity::getUpdatedAt, LocalDateTime.now())
.setSql("version = version + 1");
if (orders.update(null, update) != 1) {
@@ -217,6 +220,7 @@ public class InboundOrderCreationService {
inbound.setDeliveryWarehouseId(command.deliveryWarehouseId());
inbound.setDeliveryWarehouseName(command.deliveryWarehouseName());
inbound.setBusinessType(command.businessType());
inbound.setFulfillmentType(fulfillmentType(command.fulfillmentType()));
inbound.setChannelCode(command.channelCode());
inbound.setChannelName(command.channelName());
inbound.setCarrierId(command.carrierId());
@@ -241,6 +245,12 @@ public class InboundOrderCreationService {
return inbound;
}
private static String fulfillmentType(String value) {
if ("STANDARD".equals(value) || "ONE_CLICK_DROPSHIP".equals(value)) return value;
throw new BizException(ResultCode.BAD_REQUEST,
"履约类型必须为 STANDARD 或 ONE_CLICK_DROPSHIP");
}
private void addHistory(long inboundId, long tenantId, String from, String to,
String action, LocalDateTime now) {
InboundStatusHistoryEntity history = new InboundStatusHistoryEntity();
@@ -1,5 +1,6 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventEnvelopeValidator;
import com.baiyue.firstleg.client.EventBridgeClient;
@@ -15,7 +16,7 @@ import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.UUID;
/** 至少一次投递装柜完成跨区事件;事件桥按 eventId 幂等接收。 */
/** 至少一次投递国内头程跨区事件;事件桥按 eventId + payloadHash 幂等接收。 */
@Component @Slf4j
public class LoadingCompletedOutboxRelay {
@Resource private LoadingCompletedOutboxRepository repository;
@@ -38,22 +39,48 @@ public class LoadingCompletedOutboxRelay {
EventEnvelope event=objectMapper.readValue(row.getPayloadJson(),EventEnvelope.class);
EventEnvelopeValidator.requireValid(event);
var response=client.receive(internalToken,event);
if(response==null || !response.successful() || response.data()==null
|| !event.eventId().equals(response.data().eventId())) throw new IllegalStateException("事件桥未确认装柜完成事件");
requireAcknowledgement(event, response);
repository.published(row.getId(),workerId,LocalDateTime.now());
} catch(Exception exception) {
String error=safeError(exception);
repository.retry(row.getId(),workerId,LocalDateTime.now().plusSeconds(Math.max(retrySeconds,1)),error);
log.warn("装柜完成事件投递失败: eventKey={}, reason={}",row.getEventKey(),error);
log.warn("头程跨区事件投递失败: eventType={}, eventKey={}, reason={}",
row.getEventType(), row.getEventKey(), error);
}
}
}
private void requireAcknowledgement(EventEnvelope event,
ApiResponse<EventBridgeClient.Acknowledgement> response) {
if(response==null) throw new IllegalStateException("事件桥未返回响应");
if(!response.successful()) {
throw new IllegalStateException("事件桥拒绝头程跨区事件: code=" + response.code()
+ ", message=" + response.message() + ", requestId=" + response.requestId()
+ ", traceId=" + response.traceId());
}
if(response.data()==null || !event.eventId().equals(response.data().eventId())) {
throw new IllegalStateException("事件桥 ACK 不匹配: expectedEventId=" + event.eventId()
+ ", actualEventId=" + (response.data()==null?null:response.data().eventId()));
}
}
private String safeError(Exception exception) {
String value=exception.getMessage();
if(value==null || value.isBlank()) value=exception.getClass().getSimpleName();
Throwable diagnostic=diagnosticCause(exception);
String message=diagnostic.getMessage();
String value=diagnostic.getClass().getSimpleName()
+ ((message==null || message.isBlank())?"":": " + message);
if(internalToken!=null && !internalToken.isBlank()) value=value.replace(internalToken,"***");
value=value.replaceAll("[\\r\\n\\t]+"," ");
return value.length()>500?value.substring(0,500):value;
}
private Throwable diagnosticCause(Throwable failure) {
Throwable current=failure;
while(current.getCause()!=null && current.getCause()!=current
&& ("NoFallbackAvailableException".equals(current.getClass().getSimpleName())
|| "No fallback available.".equals(current.getMessage()))) {
current=current.getCause();
}
return current;
}
}
@@ -16,6 +16,7 @@ import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper;
import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.Row;
import com.baiyue.firstleg.mapper.LoadingCompletedEventMapper.PalletRow;
import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper;
import com.baiyue.firstleg.mapper.LoadingCartonManifestMapper.LabelRow;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -30,9 +31,11 @@ import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/** 在装柜完成事务内按客户写跨区业务 Outbox。 */
@Service
@@ -88,8 +91,10 @@ public class LoadingCompletedOutboxService {
row.getCustomerRemark(), row.getInternalRemark(), row.getWarehouseRemark(), null,
cartonMarks.getOrDefault(row.getLoadingPlanItemId(), List.of())))
.toList();
String orderType = dropship(first.getBusinessType()) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG";
List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> pallets = palletSnapshots(sorted, palletRows);
String fulfillmentType = fulfillmentType(sorted);
String orderType = dropship(fulfillmentType) ? "ONE_CLICK_DROPSHIP" : "FIRST_LEG";
List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> pallets = dropship(fulfillmentType)
? palletSnapshots(sorted, palletRows) : List.of();
if ("ONE_CLICK_DROPSHIP".equals(orderType) && pallets.isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "一键代发入仓单缺少托盘冻结快照");
}
@@ -98,40 +103,86 @@ public class LoadingCompletedOutboxService {
string(first.getRootOrderId()), first.getInboundCode(), string(first.getDestinationWarehouseId()),
first.getDestinationWarehouseCode(), first.getDestinationWarehouseName(), sumInt(sorted, Row::getPackageCount),
sumNullableInt(sorted, Row::getProductQuantity), "ONE_CLICK_DROPSHIP".equals(orderType)
? pallets.size() : sumNullableInt(sorted, Row::getPalletCount), sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg),
sum(sorted, Row::getVolumeCbm), items, string(first.getAccountId()), orderType, pallets);
? pallets.size() : 0, sum(sorted, Row::getGrossWeightKg), sum(sorted, Row::getNetWeightKg),
sum(sorted, Row::getVolumeCbm), items, string(first.getAccountId()), orderType, pallets,
destinationType(sorted));
}
/** 国内按实装箱数从可用标签池稳定随机抽取箱唛,重复发布同一计划时结果不变。 */
private Map<Long, List<String>> cartonLabelPools(List<Row> rows) {
Map<Long, List<String>> result = new java.util.LinkedHashMap<>();
for (Row row : rows) {
if (dropship(row.getBusinessType())) continue;
if (row.getSkuCode() == null || row.getSkuCode().isBlank()) {
Map<LabelPoolKey, List<Row>> byInbound = rows.stream().filter(row -> !dropship(row.getFulfillmentType()))
.filter(row -> "OVERSEAS".equals(destinationType(List.of(row))))
.collect(java.util.stream.Collectors.groupingBy(
row -> new LabelPoolKey(row.getAccountId(), row.getSourceInboundId()),
LinkedHashMap::new, java.util.stream.Collectors.toList()));
for (Map.Entry<LabelPoolKey, List<Row>> entry : byInbound.entrySet()) {
List<Row> inboundRows = entry.getValue().stream()
.sorted(Comparator.comparing(Row::getLoadingPlanItemId)).toList();
if (inboundRows.stream().anyMatch(row -> row.getSkuCode() == null || row.getSkuCode().isBlank())) {
throw new BizException(ResultCode.CONFLICT, "装柜明细缺少 SKU,不能建立箱唛与 SKU 的对应关系");
}
List<String> labels = Objects.requireNonNullElse(cartonManifestMapper.labelPool(row.getAccountId(),
row.getSourceInboundId(), row.getSourceInboundDetailId()), List.of());
if (labels.isEmpty()) throw new BizException(ResultCode.CONFLICT, "装柜明细没有已打印的可用箱唛池,不能生成海外交接事件");
if (labels.stream().anyMatch(label -> label == null || label.isBlank())
|| labels.stream().distinct().count() != labels.size()) {
List<LabelRow> labels = Objects.requireNonNullElse(cartonManifestMapper.labelPool(
entry.getKey().accountId(), entry.getKey().inboundId()), List.of());
if (labels.isEmpty()) {
throw new BizException(ResultCode.CONFLICT, "装柜明细没有已打印的可用箱唛池,不能生成海外交接事件");
}
if (labels.stream().anyMatch(label -> label == null || label.getBoxCode() == null
|| label.getBoxCode().isBlank())
|| labels.stream().map(LabelRow::getBoxCode).distinct().count() != labels.size()) {
throw new BizException(ResultCode.CONFLICT, "装柜明细箱唛池包含空值或重复箱号");
}
if (labels.size() < Objects.requireNonNullElse(row.getPackageCount(), 0)) {
throw new BizException(ResultCode.CONFLICT, "装柜明细箱唛数量少于实装箱数,不能生成海外交接事件");
Set<String> used = new HashSet<>();
for (Row row : inboundRows) {
int loadedPackages = Objects.requireNonNullElse(row.getPackageCount(), 0);
String seed = row.getLoadingPlanId() + ":" + row.getLoadingPlanItemId();
Comparator<LabelRow> stableOrder = Comparator.comparing(label -> EventPayloadHash.sha256(
(seed + ":" + label.getBoxCode()).getBytes(StandardCharsets.UTF_8)));
List<String> selected = new ArrayList<>();
labels.stream().filter(label -> !used.contains(label.getBoxCode())
&& Objects.equals(label.getInboundDetailId(), row.getSourceInboundDetailId()))
.sorted(stableOrder).limit(loadedPackages).map(LabelRow::getBoxCode).forEach(selected::add);
if (selected.size() < loadedPackages) {
labels.stream().filter(label -> !used.contains(label.getBoxCode())
&& !selected.contains(label.getBoxCode()))
.sorted(stableOrder).limit(loadedPackages - selected.size())
.map(LabelRow::getBoxCode).forEach(selected::add);
}
if (selected.size() < loadedPackages) {
throw new BizException(ResultCode.CONFLICT, "装柜明细箱唛数量少于实装箱数,不能生成海外交接事件");
}
used.addAll(selected);
result.put(row.getLoadingPlanItemId(), List.copyOf(selected));
}
int loadedPackages = Objects.requireNonNullElse(row.getPackageCount(), 0);
String seed = row.getLoadingPlanId() + ":" + row.getLoadingPlanItemId();
List<String> selected = labels.stream()
.sorted(Comparator.comparing(label -> EventPayloadHash.sha256(
(seed + ":" + label).getBytes(StandardCharsets.UTF_8))))
.limit(loadedPackages)
.toList();
result.put(row.getLoadingPlanItemId(), selected);
}
return result;
}
private String destinationType(List<Row> rows) {
Set<String> values = rows.stream().map(Row::getDestinationType)
.map(value -> value == null ? "" : value.trim().toUpperCase(java.util.Locale.ROOT))
.collect(java.util.stream.Collectors.toSet());
if (values.size() != 1 || (!values.contains("FBA") && !values.contains("OVERSEAS"))) {
throw new BizException(ResultCode.CONFLICT,
"装柜入仓单目的类型必须唯一且为 FBA 或 OVERSEAS,不能生成海外交接事件");
}
return values.iterator().next();
}
private String fulfillmentType(List<Row> rows) {
Set<String> values = rows.stream().map(Row::getFulfillmentType)
.map(value -> value == null || value.isBlank() ? "STANDARD" : value)
.collect(java.util.stream.Collectors.toSet());
if (values.size() != 1
|| (!values.contains("STANDARD") && !values.contains("ONE_CLICK_DROPSHIP"))) {
throw new BizException(ResultCode.CONFLICT,
"装柜入仓单履约类型必须唯一且为 STANDARD 或 ONE_CLICK_DROPSHIP");
}
return values.iterator().next();
}
private record LabelPoolKey(long accountId, long inboundId) { }
private List<FirstLegLoadingPlanCompletedV2.PalletSnapshot> palletSnapshots(List<Row> values, List<PalletRow> source) {
if (source.isEmpty()) return List.of();
Map<String, List<PalletRow>> byPallet = source.stream().collect(java.util.stream.Collectors.groupingBy(
@@ -185,8 +236,7 @@ public class LoadingCompletedOutboxService {
}
private static boolean dropship(String value) {
return value != null && ("ONE_CLICK_DROPSHIP".equalsIgnoreCase(value)
|| value.toUpperCase(java.util.Locale.ROOT).contains("DROPSHIP"));
return "ONE_CLICK_DROPSHIP".equals(value);
}
private record DetailSnapshot(Long detailId, String skuCode, String productName,
@@ -450,16 +450,20 @@ public class LoadingPickService {
throw conflict("存在尚未处理的PDA拣货明细,不能确认装柜完成");
}
if (repository.review(taskId) != null) throw conflict("PDA任务已经确认装柜完成");
List<LoadingPickItemMapper.InboundOwner> owners = repository.inboundOwners(taskId).stream()
List<LoadingPickItemMapper.InboundOwner> discoveredOwners = repository.inboundOwners(taskId).stream()
.filter(owner -> owner.tenantId() != null && owner.inboundId() != null)
.toList();
if (discoveredOwners.isEmpty()) throw conflict("PDA任务未关联有效入库单");
LoadingPickItemMapper.InboundOwner evidenceOwner = discoveredOwners.get(0);
List<LoadingPickItemMapper.InboundOwner> owners = discoveredOwners.stream()
.sorted(Comparator.comparing(LoadingPickItemMapper.InboundOwner::tenantId)
.thenComparing(LoadingPickItemMapper.InboundOwner::inboundId))
.toList();
if (owners.isEmpty()) throw conflict("PDA任务未关联有效入库单");
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());
loadingDeliveryService.requirePdaCompletionReady(owner.tenantId(), owner.inboundId(),
evidenceOwner.inboundId());
}
settlementService.settle(taskId, operatorId, operatorName);
if (!repository.allPlanItemsPicked(task.getLoadingPlanId())) {
@@ -472,8 +476,9 @@ public class LoadingPickService {
loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, operatorId, operatorName);
repository.syncInboundProjections(taskId, operatorId, operatorName);
for (LoadingPickItemMapper.InboundOwner owner : owners) {
loadingDeliveryService.completeAfterPdaSettlement(owner.tenantId(), owner.inboundId(), operatorId,
operatorName, "PDA-" + operationId + '-' + owner.inboundId(), remark);
loadingDeliveryService.completeAfterPdaSettlement(owner.tenantId(), owner.inboundId(),
evidenceOwner.inboundId(), operatorId, operatorName,
"PDA-" + operationId + '-' + owner.inboundId(), remark);
}
loadingListService.publishCompletedEvent(task.getLoadingPlanId());
completeTruckAfterCommit(task.getContainerBookingId(), taskId, operatorId, operatorName);
@@ -185,6 +185,9 @@ public class PdaPickSettlementService {
requireAffected(splits.cloneChildWithPurpose(parent.tenantId(), parentInboundId, rootInboundId, remainderChildId,
orderSplit.remainderChild().orderId(), orderSplit.remainderChild().orderNo(),
remainderChildCode, childPackageCount, sequence, "REMAINDER", operatorId, operatorName), "余量子入库单");
splits.transferRemainingCartonLabels(parent.tenantId(), parentInboundId, remainderChildId,
orderSplit.remainderChild().orderId(), remainderChildCode, pickedPackageCount, childPackageCount);
splits.normalizeLoadedCartonLabels(parent.tenantId(), parentInboundId, pickedPackageCount);
}
requireAffected(splits.insertBatchWithChildren(batchId, parent.tenantId(), rootInboundId, operationId, parentInboundId,
idempotencyKey, rootVersion, parentAvailable, null, remainderChildId == 0L ? null : remainderChildId,
@@ -132,12 +132,19 @@ public class SmartLoadingService {
if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName);
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
Candidate candidate = repository.candidate(booking.getWarehouseId(), request.inboundId(), request.inboundDetailId());
Candidate candidate = repository.candidate(planId, booking.getWarehouseId(), request.inboundId(),
request.inboundDetailId());
if (candidate == null) throw conflict("入库货物不存在或尚未完成入库");
requireSameChannel(booking, candidate);
requireSameCustomerScope(booking, candidate);
splitPreloadService.ensurePendingForLoadingAllocation(candidate.accountId(), candidate.inboundId(),
operatorId, operatorName);
var task = loadingPickService.mutationSnapshot(planId);
if (task.taskId() != null && "LOADING".equals(task.taskStatus())) {
splitPreloadService.ensureForRunningLoadingAllocation(candidate.accountId(), candidate.inboundId(),
operatorId, operatorName);
} else {
splitPreloadService.ensurePendingForLoadingAllocation(candidate.accountId(), candidate.inboundId(),
operatorId, operatorName);
}
OrderCargoClient.CargoBinding cargo = loadCargo(candidate);
LoadingPlanItemEntity existing = repository.activeItem(planId, candidate.accountId(), request.inboundDetailId());
int total = request.packageCount() + (existing == null ? 0 : existing.getPackageCount());
@@ -156,7 +163,8 @@ public class SmartLoadingService {
} else {
existing.setPackageCount(total); existing.setGrossWeightKg(unit(candidate.unitGrossWeightKg()).multiply(BigDecimal.valueOf(total)));
existing.setNetWeightKg(unit(candidate.unitNetWeightKg()).multiply(BigDecimal.valueOf(total)));
existing.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(total))); applyCargo(existing, cargo, total);
existing.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(total)));
existing.setPalletCount(estimatedPalletCount(candidate, total)); applyCargo(existing, cargo, total);
existing.setUpdatedBy(operatorId); existing.setUpdatedByName(operatorName);
if (repository.update(existing) != 1) throw conflict("装柜草稿已被并发修改");
changed = existing;
@@ -293,8 +301,15 @@ public class SmartLoadingService {
LoadingPlanItemEntity item = repository.item(planId, itemId);
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在");
if (!Objects.equals(item.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突");
LoadingPickService.MutationSnapshot pickSnapshot = loadingPickService.mutationSnapshot(planId);
int pickedPackageCount = pickSnapshot == null ? 0
: pickSnapshot.pickedByPlanItem().getOrDefault(itemId, 0);
if (request.packageCount() < pickedPackageCount) {
throw conflict("计划箱数不能低于已拣货箱数");
}
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
Candidate candidate = repository.candidate(booking.getWarehouseId(), item.getInboundId(), item.getInboundDetailId());
Candidate candidate = repository.candidate(planId, booking.getWarehouseId(), item.getInboundId(),
item.getInboundDetailId());
if (candidate == null || request.packageCount() > candidate.availablePackageCount() + item.getPackageCount()) throw validation("调整箱数超过可用箱数");
requireSameChannel(booking, candidate);
requireSameCustomerScope(booking, candidate);
@@ -306,7 +321,8 @@ public class SmartLoadingService {
item.setPackageCount(request.packageCount());
item.setGrossWeightKg(unit(candidate.unitGrossWeightKg()).multiply(BigDecimal.valueOf(request.packageCount())));
item.setNetWeightKg(unit(candidate.unitNetWeightKg()).multiply(BigDecimal.valueOf(request.packageCount())));
item.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(request.packageCount()))); applyCargo(item, cargo, request.packageCount());
item.setVolumeCbm(unit(candidate.unitVolumeCbm()).multiply(BigDecimal.valueOf(request.packageCount())));
item.setPalletCount(estimatedPalletCount(candidate, request.packageCount())); applyCargo(item, cargo, request.packageCount());
item.setUpdatedBy(operatorId); item.setUpdatedByName(operatorName);
if (repository.update(item) != 1) throw conflict("装柜明细已被并发修改");
history(planId, itemId, "ADJUST_ITEM", plan.getPlanStatus(), plan.getPlanStatus(), operation,
@@ -437,8 +453,16 @@ public class SmartLoadingService {
i.setGrossWeightKg(unit(c.unitGrossWeightKg()).multiply(BigDecimal.valueOf(count)));
i.setNetWeightKg(unit(c.unitNetWeightKg()).multiply(BigDecimal.valueOf(count)));
i.setVolumeCbm(unit(c.unitVolumeCbm()).multiply(BigDecimal.valueOf(count)));
i.setPalletCount(estimatedPalletCount(c, count));
applyCargo(i, cargo, count); i.setLoadSequence(seq); i.setDeleted(false); i.setCreatedBy(op); i.setCreatedByName(name); i.setUpdatedBy(op); i.setUpdatedByName(name); i.setVersion(0); return i;
}
private int estimatedPalletCount(Candidate candidate, int packageCount) {
InboundOrderEntity inbound = repository.inbound(candidate.accountId(), candidate.inboundId());
if (inbound == null || inbound.getPalletCount() == null || inbound.getPalletCount() <= 0
|| inbound.getTotalPackageCount() == null || inbound.getTotalPackageCount() <= 0) return 0;
return BigDecimal.valueOf(inbound.getPalletCount()).multiply(BigDecimal.valueOf(packageCount))
.divide(BigDecimal.valueOf(inbound.getTotalPackageCount()), 0, RoundingMode.CEILING).intValueExact();
}
private void applyCargo(LoadingPlanItemEntity item, OrderCargoClient.CargoBinding cargo, int boxes) {
item.setSkuCode(cargo.skuNo());
item.setProductNameEn(cargo.cargoNameEn());
@@ -56,6 +56,8 @@ public class UnifiedInboundQueryService {
public UnifiedInboundDetail detail(long tenantId, long orderId) {
InboundOrderEntity inbound = inbounds.findByOrderId(tenantId, orderId);
if (inbound == null) return null;
applyConfirmedPalletCount(inbound, inbounds.confirmedPalletCountsMap(tenantId,
Map.of(inbound.getId(), inbound.getTenantId())));
InboundOrderDetail detail = inbounds.detail(tenantId, inbound);
String mainStatus = mainStatus(inbound.getProcessStatus());
boolean includePhaseThree = requiresPhaseThreeSummary(mainStatus);
@@ -93,6 +95,8 @@ public class UnifiedInboundQueryService {
var transports = operations.findTransports(phaseThreeOwners);
Map<Long, Long> owners = records.stream().collect(Collectors.toMap(
InboundOrderEntity::getId, InboundOrderEntity::getTenantId, (left, right) -> left));
var confirmedPalletCounts = inbounds.confirmedPalletCountsMap(null, owners);
records.forEach(inbound -> applyConfirmedPalletCount(inbound, confirmedPalletCounts));
var locationCodes = inbounds.locationCodesMap(null, owners);
var pickups = inbounds.pickupMap(null, owners);
return records.stream().map(inbound -> summary(inbound, null, preloads.get(inbound.getId()),
@@ -101,6 +105,14 @@ public class UnifiedInboundQueryService {
transports.get(inbound.getId()), locationCodes.get(inbound.getId()), pickups.get(inbound.getId()))).toList();
}
private static void applyConfirmedPalletCount(InboundOrderEntity inbound,
Map<Long, Integer> confirmedPalletCounts) {
if ((inbound.getPalletCount() == null || inbound.getPalletCount() <= 0)
&& confirmedPalletCounts.getOrDefault(inbound.getId(), 0) > 0) {
inbound.setPalletCount(confirmedPalletCounts.get(inbound.getId()));
}
}
private UnifiedInboundSummary summary(InboundOrderEntity inbound, InboundOrderDetail detail,
com.baiyue.firstleg.split.entity.PreloadPlanEntity preload,
com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity pick,
@@ -68,11 +68,35 @@ public class SplitPreloadService {
@Transactional
public void ensurePendingForLoadingAllocation(long tenantId, long inboundId, long operatorId,
String operatorName) {
ensureLoadingAllocationLifecycle(tenantId, inboundId, operatorId, operatorName, false);
}
/**
* A running PDA task may restore or add an unpicked allocation after the plan-level preload
* confirmation. CONFIRMING remains immutable; CONFIRMED is accepted because the caller owns
* the active PDA task lock and will synchronise the restored loading item immediately.
*/
@Transactional
public void ensureForRunningLoadingAllocation(long tenantId, long inboundId, long operatorId,
String operatorName) {
ensureLoadingAllocationLifecycle(tenantId, inboundId, operatorId, operatorName, true);
}
private void ensureLoadingAllocationLifecycle(long tenantId, long inboundId, long operatorId,
String operatorName, boolean running) {
InboundSnapshot inbound = requireInbound(tenantId, inboundId);
requireWaitingSplit(inbound, "仅待分柜订单可以建立智能装柜预装计划");
PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId);
boolean waitingSplit = isWaitingSplit(inbound.processStatus());
boolean loading = "LOADING".equals(inbound.processStatus());
if (!waitingSplit && !(running && loading)) {
throw new BizException(ResultCode.CONFLICT, "仅待分柜订单可以建立智能装柜预装计划");
}
if (loading && plan == null) {
throw new BizException(ResultCode.CONFLICT, "装柜中的订单缺少既有预装计划,不能新增分配");
}
if (plan != null) {
if (!PreloadStatus.PENDING.equals(plan.getStatus())) {
if (!PreloadStatus.PENDING.equals(plan.getStatus())
&& !(running && PreloadStatus.CONFIRMED.equals(plan.getStatus()))) {
throw new BizException(ResultCode.CONFLICT, "预装计划已进入确认流程,不能修改智能装柜分配");
}
return;