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;
+1 -1
View File
@@ -44,7 +44,7 @@ baiyue:
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long}
lastleg:
event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL:http://127.0.0.1:9312}
event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL:}
clients:
order:
url: ${ORDER_SERVICE_URL:}
@@ -27,6 +27,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order (
delivery_warehouse_id BIGINT UNSIGNED NULL COMMENT '交货仓逻辑ID',
delivery_warehouse_name VARCHAR(128) NULL COMMENT '交货仓名称快照',
business_type VARCHAR(32) NOT NULL COMMENT '业务类型',
fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD' COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发',
channel_code VARCHAR(64) NULL COMMENT '渠道编码',
channel_name VARCHAR(128) NULL COMMENT '渠道名称快照',
carrier_id BIGINT UNSIGNED NULL COMMENT '计费船司逻辑ID',
@@ -77,6 +78,26 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order (
CONSTRAINT fk_inbound_task FOREIGN KEY (task_id) REFERENCES fl_fulfillment_task (id)
) ENGINE=InnoDB COMMENT='二期头程入库单聚合根和列表查询快照';
SET @inbound_fulfillment_type_sql = IF(
EXISTS(
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'fl_inbound_order'
AND column_name = 'fulfillment_type'
),
'SELECT 1',
'ALTER TABLE fl_inbound_order ADD COLUMN fulfillment_type VARCHAR(32) NULL COMMENT ''履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发'' AFTER business_type'
);
PREPARE inbound_fulfillment_type_stmt FROM @inbound_fulfillment_type_sql;
EXECUTE inbound_fulfillment_type_stmt;
DEALLOCATE PREPARE inbound_fulfillment_type_stmt;
UPDATE fl_inbound_order
SET fulfillment_type = 'STANDARD'
WHERE fulfillment_type IS NULL OR fulfillment_type = '';
ALTER TABLE fl_inbound_order
MODIFY COLUMN fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD'
COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发';
-- 既有开发库若存在跨客户重复码则严格失败,禁止自动覆盖或改写入仓码。
SET @inbound_global_code_index_sql = IF(
NOT EXISTS(
@@ -181,6 +181,26 @@ CREATE TABLE IF NOT EXISTS fl_carrier_schedule_query_operation (
CONSTRAINT chk_carrier_schedule_operation_status CHECK (status IN ('PROCESSING','SUCCESS'))
) ENGINE=InnoDB COMMENT='船司查询写操作幂等与请求摘要';
-- CREATE TABLE IF NOT EXISTS 不会更新已有表的 CHECK。历史开发库仍可能只允许
-- CREATE/ATTEMPT/MANUAL_RESULT,必须在人工节点和自动节点写入前收敛为当前动作集。
DROP PROCEDURE IF EXISTS migrate_carrier_schedule_history_action_check;
DELIMITER $$
CREATE PROCEDURE migrate_carrier_schedule_history_action_check()
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.table_constraints
WHERE constraint_schema=DATABASE() AND table_name='fl_carrier_schedule_query_history'
AND constraint_name='chk_carrier_schedule_history_action' AND constraint_type='CHECK') THEN
ALTER TABLE fl_carrier_schedule_query_history DROP CHECK chk_carrier_schedule_history_action;
END IF;
ALTER TABLE fl_carrier_schedule_query_history
ADD CONSTRAINT chk_carrier_schedule_history_action CHECK
(action IN ('CREATE','AUTO_CREATE','MANUAL_RESULT','MANUAL_NODE','NODE_OBSERVED',
'EXCEPTION_OBSERVED','PROVIDER_QUERY','STOP'));
END$$
DELIMITER ;
CALL migrate_carrier_schedule_history_action_check();
DROP PROCEDURE IF EXISTS migrate_carrier_schedule_history_action_check;
DROP TRIGGER IF EXISTS trg_carrier_schedule_query_before_insert;
DELIMITER $$
CREATE TRIGGER trg_carrier_schedule_query_before_insert BEFORE INSERT ON fl_carrier_schedule_query FOR EACH ROW
@@ -813,14 +813,23 @@ BEGIN
SET NEW.preload_pushed_at = COALESCE(NEW.preload_pushed_at, CURRENT_TIMESTAMP(3));
ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN
IF EXISTS (SELECT 1 FROM fl_loading_plan_item item
LEFT JOIN fl_loading_pick_item pick_item
ON pick_item.loading_plan_item_id = item.id
LEFT JOIN fl_loading_pick_task pick_task
ON pick_task.id = pick_item.pick_task_id
AND pick_task.loading_plan_id = OLD.id
WHERE item.loading_plan_id = OLD.id AND item.deleted = 0
AND (pick_task.id IS NULL
OR pick_item.picked_package_count <> item.package_count)) THEN
AND NOT EXISTS (
SELECT 1 FROM fl_loading_pick_item pick_item
JOIN fl_loading_pick_task pick_task ON pick_task.id = pick_item.pick_task_id
WHERE pick_item.loading_plan_item_id = item.id
AND pick_task.loading_plan_id = OLD.id
AND pick_task.task_status = 'LOADING'
AND (
(pick_item.item_status = 'PICKED'
AND pick_item.picked_package_count = item.package_count)
OR (pick_item.item_status = 'PARTIAL'
AND pick_item.picked_package_count > 0
AND pick_item.picked_package_count = item.package_count)
OR (pick_item.item_status = 'SKIPPED'
AND pick_item.picked_package_count = 0)
)
)) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '仍有未完成拣货明细,不能完成装柜';
END IF;
SET NEW.confirmed_at = COALESCE(NEW.confirmed_at, CURRENT_TIMESTAMP(3));
@@ -10,6 +10,9 @@ CREATE TABLE IF NOT EXISTS fl_preload_plan (
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号,每次更新递增',
items_json JSON NOT NULL COMMENT '可编辑分柜明细JSON快照;人工确认后作为预装依据',
remark VARCHAR(1000) NULL COMMENT '预装备注',
confirmed_by BIGINT UNSIGNED NULL COMMENT '确认预装的操作人ID',
confirmed_by_name VARCHAR(64) NULL COMMENT '确认预装的操作人姓名快照',
confirmed_at DATETIME(3) NULL COMMENT '预装确认时间',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建预装清单的操作人ID',
created_by_name VARCHAR(64) NULL COMMENT '创建操作人姓名快照',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改操作人ID',
@@ -62,6 +65,13 @@ BEGIN
END$$
DELIMITER ;
CALL phase3_split_add_column('fl_preload_plan', 'confirmed_by',
'BIGINT UNSIGNED NULL COMMENT ''确认预装的操作人ID'' AFTER remark');
CALL phase3_split_add_column('fl_preload_plan', 'confirmed_by_name',
'VARCHAR(64) NULL COMMENT ''确认预装的操作人姓名快照'' AFTER confirmed_by');
CALL phase3_split_add_column('fl_preload_plan', 'confirmed_at',
'DATETIME(3) NULL COMMENT ''预装确认时间'' AFTER confirmed_by_name');
CALL phase3_split_add_column('fl_inbound_order', 'parent_inbound_id',
'BIGINT UNSIGNED NULL COMMENT ''生成本单的主入库单ID;仅二级子单有值'' AFTER id');
CALL phase3_split_add_column('fl_inbound_order', 'split_level',
@@ -9,7 +9,7 @@ paths:
summary: List official carrier sources and compliant access modes
x-permission: platform:firstleg:port-schedule-query:list
responses:
'200': {description: MATSON, SML, ZIM, COSCO, EMC and HEDE descriptors}
'200': {description: 'MATSON, SML, ZIM, COSCO, EMC and HEDE descriptors'}
/api/platform/v1/carrier-schedule-queries:
get:
summary: Query carrier schedule tasks
@@ -18,7 +18,7 @@ paths:
description: 成功或业务失败
content:
application/json:
schema: {$ref: '#/components/schemas/CustomerRuleVersionSummary'}
schema: {$ref: '#/components/schemas/CustomerRuleVersionListApiResponse'}
'403': {description: 无权限}
/api/platform/v1/fba-pricing/customer-rules/versions:
post:
@@ -231,6 +231,44 @@ components:
VersionId: {name: versionId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
schemas:
CustomerRuleVersionListApiResponse:
type: object
required: [code, message, data, timestamp]
properties:
code: {type: string, example: '200'}
message: {type: string}
data:
type: array
items: {$ref: '#/components/schemas/CustomerRuleVersionResource'}
requestId: {type: string, nullable: true}
traceId: {type: string, nullable: true}
timestamp: {type: string, format: date-time}
CustomerRuleVersionResource:
type: object
properties:
id: {type: string, pattern: '^[1-9][0-9]*$'}
tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
ruleCode: {type: string}
ruleVersion: {type: integer}
customerId: {type: string, pattern: '^[1-9][0-9]*$'}
customerName: {type: string}
businessType: {type: string}
chargeMode: {type: string}
taxMode: {type: string}
conversionFactor: {type: number}
roundingScale: {type: integer}
roundingMode: {type: string}
currency: {type: string}
effectiveFrom: {type: string, format: date-time}
effectiveTo: {type: string, format: date-time, nullable: true}
status: {type: string}
remark: {type: string, nullable: true}
createdBy: {type: string, nullable: true}
createdAt: {type: string, format: date-time, nullable: true}
updatedBy: {type: string, nullable: true}
updatedAt: {type: string, format: date-time, nullable: true}
deleted: {type: boolean, nullable: true}
version: {type: integer, nullable: true}
FeeSnapshotApiResponse:
type: object
required: [code, message, data]
@@ -415,7 +453,7 @@ components:
required: [expectedFeeVersion, chargeableQuantity, unitPrice, additionalAmount, reason]
properties:
expectedFeeVersion: {type: integer, minimum: 1}
chargeableQuantity: {type: number, exclusiveMinimum: 0, description: 计费量,单位沿用当前费用快照的 chargeUnit}
chargeableQuantity: {type: number, minimum: 0, exclusiveMinimum: true, description: 计费量,单位沿用当前费用快照的 chargeUnit}
unitPrice: {type: number, minimum: 0, description: 人工确认单价}
additionalAmount: {type: number, minimum: 0, multipleOf: 0.01}
reason: {type: string, minLength: 1, maxLength: 500}
@@ -432,7 +470,7 @@ components:
businessType: {type: string, maxLength: 32}
chargeMode: {type: string, enum: [KG, CBM]}
taxMode: {type: string, enum: [TAX_INCLUDED, TAX_EXCLUDED]}
conversionFactor: {type: number, exclusiveMinimum: 0, example: 167}
conversionFactor: {type: number, minimum: 0, exclusiveMinimum: true, example: 167}
roundingScale: {type: integer, minimum: 0, maximum: 4}
roundingMode: {type: string, enum: [UP, DOWN, CEILING, FLOOR, HALF_UP, HALF_DOWN, HALF_EVEN]}
currency: {type: string, pattern: '^[A-Z]{3}$'}
@@ -454,7 +492,7 @@ components:
destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
destinationName: {type: string, maxLength: 128, nullable: true}
chargeUnit: {type: string, enum: [KG, CBM]}
unitPrice: {type: number, exclusiveMinimum: 0}
unitPrice: {type: number, minimum: 0, exclusiveMinimum: true}
currency: {type: string, pattern: '^[A-Z]{3}$'}
additionalFee: {type: number, minimum: 0}
effectiveFrom: {type: string, format: date-time}
@@ -578,7 +616,7 @@ components:
pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'}
chargeUnit: {type: string, enum: [KG, CBM]}
weightFromKg: {type: number, minimum: 0, nullable: true}
unitPrice: {type: number, exclusiveMinimum: 0}
unitPrice: {type: number, minimum: 0, exclusiveMinimum: true}
currency: {type: string, pattern: '^[A-Z]{3}$'}
sourceSheetName: {type: string, maxLength: 128}
sourceRowNumber: {type: integer, minimum: 1}
@@ -1112,7 +1112,6 @@ components:
notificationStatus: {type: string, nullable: true}
version: {type: integer, nullable: true}
expectedArrivalAt: {type: string, format: date-time, nullable: true}
requestedPickupAt: {type: string, format: date-time, nullable: true, description: Customer-requested pickup appointment from fl_inbound_pickup_snapshot.pickup_at}
customerId: {type: integer, format: int64, nullable: true}
customerName: {type: string, nullable: true}
companyId: {type: integer, format: int64, nullable: true}
@@ -1122,6 +1121,7 @@ components:
deliveryWarehouseId: {type: integer, format: int64, nullable: true}
deliveryWarehouseName: {type: string, nullable: true}
businessType: {type: string, nullable: true}
fulfillmentType: {type: string, enum: [STANDARD, ONE_CLICK_DROPSHIP], nullable: true}
channelCode: {type: string, nullable: true}
channelName: {type: string, nullable: true}
taxMode: {type: string, nullable: true}
@@ -1466,7 +1466,7 @@ components:
adjustmentReason: {type: string, minLength: 1, maxLength: 500, nullable: true}
OrderApprovedInboundCommand:
type: object
required: [tenantId, orderId, inboundCode, customerName, warehouseId]
required: [tenantId, orderId, inboundCode, customerName, warehouseId, fulfillmentType]
properties:
tenantId: {type: string, pattern: '^[1-9][0-9]*$', description: 当前客户隔离 ID}
orderId: {type: string, pattern: '^[1-9][0-9]*$'}
@@ -1480,6 +1480,7 @@ components:
deliveryWarehouseName: {type: string, nullable: true}
expectedArrivalAt: {type: string, format: date-time, nullable: true}
businessType: {type: string, nullable: true}
fulfillmentType: {type: string, enum: [STANDARD, ONE_CLICK_DROPSHIP], description: 独立履约类型;不得复用计费 businessType}
channelCode: {type: string, nullable: true}
channelName: {type: string, nullable: true}
carrierId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 历史兼容的内部运输供应商快照}
@@ -1810,6 +1811,7 @@ components:
deliveryWarehouseId: {type: string, nullable: true}
deliveryWarehouseName: {type: string, nullable: true}
businessType: {type: string, nullable: true}
fulfillmentType: {type: string, enum: [STANDARD, ONE_CLICK_DROPSHIP], nullable: true}
channelCode: {type: string, nullable: true}
channelName: {type: string, nullable: true}
containerMode: {type: string, nullable: true}
@@ -34,7 +34,7 @@ paths:
content:
application/json: {schema: {$ref: '#/components/schemas/UpsertEvidenceRequest'}}
responses:
'200': {description: Business envelope; ownership, type and version failures use business error codes}
'200': {description: 'Business envelope; ownership, type and version failures use business error codes'}
/api/platform/v1/inbounds/{inboundId}/loading/evidence/{evidenceId}/content/pda:
get:
summary: Download one loading evidence photo for the active PDA worker
@@ -60,7 +60,8 @@ paths:
'200': {description: Business envelope; gate or version failures use business error code}
/api/platform/v1/first-leg/outbound-dispatches/reverse-dispatch:
post:
summary: Reverse WAITING_FOR_LOADING to LOADED
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.
parameters:
- {$ref: '#/components/parameters/IdempotencyKey'}
requestBody:
@@ -93,7 +93,7 @@ paths:
required: true
content: {application/json: {schema: {$ref: '#/components/schemas/BatchRequest'}}}
responses:
'200': {description: Completed task, identical replay result, or A0409 when any snapshot changed}
'200': {description: 'Completed task, identical replay result, or A0409 when any snapshot changed'}
/api/platform/v1/first-leg/pda-loading/tasks/{taskId}/review:
post:
summary: 人工确认装柜完成的兼容入口
@@ -8,7 +8,7 @@ paths:
get:
summary: Query truck appointments
parameters:
- {name: customerId, in: query, description: Filter by customer, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: customerId, in: query, description: 'Filter by customer', schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses:
'200':
description: Paged truck appointments with stable booking/container snapshots and attachment count
@@ -29,13 +29,13 @@ paths:
application/json:
schema: {$ref: '#/components/schemas/SaveRequest'}
responses:
'200': {description: Created appointment, content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}}
'200': {description: 'Created appointment', content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}}
/api/platform/v1/truck-appointments/{id}:
parameters: [{$ref: '#/components/parameters/AppointmentId'}]
get:
summary: Query a truck appointment
responses:
'200': {description: Appointment detail, content: {application/json: {schema: {$ref: '#/components/schemas/DetailStableReferences'}}}}
'200': {description: 'Appointment detail', content: {application/json: {schema: {$ref: '#/components/schemas/DetailStableReferences'}}}}
put:
summary: Update an editable truck appointment with optimistic locking
requestBody:
@@ -84,20 +84,20 @@ paths:
get:
summary: Query immutable appointment status history
responses:
'200': {description: History entries including action, from/to status, operator, reason and time}
'200': {description: 'History entries including action, from/to status, operator, reason and time'}
/api/platform/v1/truck-appointments/{id}/attachments:
parameters: [{$ref: '#/components/parameters/AppointmentId'}]
get:
summary: Query attachment references owned by the appointment
description: 'Requires platform:firstleg:truck-appointment:list.'
responses:
'200': {description: Attachment references, content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/Attachment'}}}}}
'200': {description: 'Attachment references', content: {application/json: {schema: {type: array, items: {$ref: '#/components/schemas/Attachment'}}}}}
post:
summary: Reference a file already owned by the appointment customer and business scope
description: 'Requires platform:firstleg:truck-appointment:edit. The file must belong to customerId + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId.'
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/AttachmentRequest'}}}}
responses:
'200': {description: Created attachment reference, content: {application/json: {schema: {$ref: '#/components/schemas/Attachment'}}}}
'200': {description: 'Created attachment reference', content: {application/json: {schema: {$ref: '#/components/schemas/Attachment'}}}}
/api/platform/v1/truck-appointments/{id}/attachments/upload:
parameters: [{$ref: '#/components/parameters/AppointmentId'}]
post:
@@ -181,8 +181,8 @@ components:
required: [customerId, carrierId, plannedLoadingAt, expectedVersion]
properties:
outboundOrderId: {type: integer, format: int64, minimum: 1, nullable: true}
oceanBookingId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional booking association; no RELEASED-state prerequisite. When supplied, customer and warehouse must match.}
containerBookingId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional ACTIVE container association. When supplied, customer and warehouse must match.}
oceanBookingId: {type: integer, format: int64, minimum: 1, nullable: true, description: 'Optional booking association; no RELEASED-state prerequisite. When supplied, customer and warehouse must match.'}
containerBookingId: {type: integer, format: int64, minimum: 1, nullable: true, description: 'Optional ACTIVE container association. When supplied, customer and warehouse must match.'}
loadingPlanId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional loading-plan association. It may be supplied only with containerBookingId and must be that container's current plan.}
warehouseId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional warehouse scope. Linked booking/container warehouse facts must agree; when omitted the linked fact is frozen if available.}
customerId: {type: integer, format: int64, minimum: 1, description: Required ACTIVE IAM customer used for isolation and attachment ownership.}
@@ -20,7 +20,8 @@ class BaiyueFirstlegApplicationContractTest {
.contains("com.baiyue.firstleg.outbound.dispatch.mapper")
.contains("com.baiyue.firstleg.portschedule.mapper")
.contains("com.baiyue.firstleg.customs.mapper")
.contains("com.baiyue.firstleg.customerstatistics.mapper");
.contains("com.baiyue.firstleg.customerstatistics.adapter.out.persistence")
.doesNotContain("com.baiyue.firstleg.customerstatistics.mapper");
}
@Test
@@ -0,0 +1,32 @@
package com.baiyue.firstleg.client;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class EventBridgeClientDeploymentContractTest {
private static final Path REPOSITORY_ROOT = Path.of("..").toAbsolutePath().normalize();
@Test
void firstlegBridgeTargetComesFromEnvironmentWithoutLoopbackFallback() throws Exception {
String application = Files.readString(REPOSITORY_ROOT.resolve("firstleg/src/main/resources/application.yml"));
String nacos = Files.readString(REPOSITORY_ROOT.resolve("config/nacos/cn/firstleg-service.yml"));
assertTrue(application.contains("event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL:}"));
assertFalse(application.contains("LASTLEG_EVENT_BRIDGE_URL:http://127.0.0.1"));
assertTrue(nacos.contains("event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL}"));
}
@Test
void cnComposeForwardsTargetAndPublishesStableNetworkAlias() throws Exception {
String compose = Files.readString(REPOSITORY_ROOT.resolve("deploy/docker-compose.yml"));
assertTrue(compose.contains("LASTLEG_EVENT_BRIDGE_URL: ${LASTLEG_EVENT_BRIDGE_URL}"));
assertTrue(compose.contains("SERVER_PORT: 9312"));
assertTrue(compose.contains("aliases: [event-bridge-cn]"));
}
}
@@ -4,9 +4,12 @@ import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.web.bind.annotation.RequestParam;
import org.yaml.snakeyaml.Yaml;
import java.nio.charset.StandardCharsets;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@@ -82,9 +85,57 @@ class FbaPricingContractTest {
.doesNotContain("format: int64");
}
@Test
@DisplayName("客户规则列表响应锁定数组包络和实体核心字段")
void freezesCustomerRuleVersionListResponse() throws Exception {
String source = new ClassPathResource("openapi/fba-pricing-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
Map<String, Object> api = map(new Yaml().load(source));
Map<String, Object> paths = map(api.get("paths"));
Map<String, Object> get = map(map(paths.get(
"/api/platform/v1/fba-pricing/customer-rules")).get("get"));
Map<String, Object> success = map(map(get.get("responses")).get("200"));
Map<String, Object> responseSchema = map(map(map(success.get("content"))
.get("application/json")).get("schema"));
assertThat(responseSchema).containsEntry(
"$ref", "#/components/schemas/CustomerRuleVersionListApiResponse");
Map<String, Object> schemas = map(map(api.get("components")).get("schemas"));
Map<String, Object> envelope = map(schemas.get("CustomerRuleVersionListApiResponse"));
assertThat(list(envelope.get("required"))).containsExactly("code", "message", "data", "timestamp");
Map<String, Object> envelopeProperties = map(envelope.get("properties"));
assertThat(envelopeProperties).containsOnlyKeys(
"code", "message", "data", "requestId", "traceId", "timestamp");
Map<String, Object> data = map(envelopeProperties.get("data"));
assertThat(data).containsEntry("type", "array");
assertThat(map(data.get("items"))).containsEntry(
"$ref", "#/components/schemas/CustomerRuleVersionResource");
Map<String, Object> resource = map(schemas.get("CustomerRuleVersionResource"));
assertThat(map(resource.get("properties"))).containsOnlyKeys(
"id", "tenantId", "ruleCode", "ruleVersion", "customerId", "customerName",
"businessType", "chargeMode", "taxMode", "conversionFactor", "roundingScale",
"roundingMode", "currency", "effectiveFrom", "effectiveTo", "status", "remark",
"createdBy", "createdAt", "updatedBy", "updatedAt", "deleted", "version");
for (String longField : List.of("id", "tenantId", "customerId", "createdBy", "updatedBy")) {
assertThat(map(map(resource.get("properties")).get(longField))).containsEntry("type", "string");
}
assertThat(source).doesNotContain("CustomerRuleVersionSummary");
}
private static Method method(String name) {
return java.util.Arrays.stream(PlatformFbaMasterDataController.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals(name))
.findFirst().orElseThrow();
}
@SuppressWarnings("unchecked")
private static Map<String, Object> map(Object value) {
return (Map<String, Object>) value;
}
@SuppressWarnings("unchecked")
private static List<Object> list(Object value) {
return (List<Object>) value;
}
}
@@ -37,12 +37,17 @@ class UnifiedInboundOpenApiContractTest {
assertThat(stringMap(inbound.get("properties"))).containsKeys(
"id", "tenantId", "orderId", "inboundCode", "processStatus", "version",
"customerId", "warehouseId", "deliveryWarehouseId", "totalPackageCount",
"businessType", "channelCode", "pricingTaxMode", "containerMode", "destinationType",
"businessType", "fulfillmentType", "channelCode", "pricingTaxMode", "containerMode", "destinationType",
"holdFlag", "actualGrossWeightKg", "actualNetWeightKg", "actualVolumeCbm",
"volumeWeightKg", "chargeableWeightKg", "chargeableVolumeCbm", "palletCount",
"inboundedAt", "requestedPickupAt", "expressCompanyCode", "expressCompanyName", "containerNo",
"cabinetNo", "estimatedSailingAt", "actualSailingAt", "estimatedPortArrivalAt",
"actualPortArrivalAt", "createdByName", "createdAt", "updatedAt");
Map<String, Object> approval = stringMap(schemas.get("OrderApprovedInboundCommand"));
assertThat(stringList(approval.get("required"))).contains("fulfillmentType");
Map<String, Object> fulfillment = stringMap(stringMap(approval.get("properties")).get("fulfillmentType"));
assertThat(stringList(fulfillment.get("enum"))).containsExactly("STANDARD", "ONE_CLICK_DROPSHIP");
}
@SuppressWarnings("unchecked")
@@ -0,0 +1,80 @@
package com.baiyue.firstleg.customerstatistics;
import com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.tngtech.archunit.base.DescribedPredicate;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.domain.JavaClasses;
import com.tngtech.archunit.core.importer.ClassFileImporter;
import com.tngtech.archunit.core.importer.ImportOption;
import org.junit.jupiter.api.Test;
import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage;
import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
class CustomerStatisticsArchitectureTest {
private static final String FIRSTLEG = "com.baiyue.firstleg..";
private static final String CUSTOMER_STATISTICS = "com.baiyue.firstleg.customerstatistics..";
private static final String APPLICATION = "com.baiyue.firstleg.customerstatistics.application..";
private static final String OUTPUT_PORT = "com.baiyue.firstleg.customerstatistics.application.port.out..";
private static final String PERSISTENCE = "com.baiyue.firstleg.customerstatistics.adapter.out.persistence..";
private static final String WEB = "com.baiyue.firstleg.customerstatistics.adapter.in.web..";
private static final JavaClasses PRODUCTION_CLASSES = new ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS)
.importPackages("com.baiyue.firstleg");
@Test
void applicationModelAndPortsStayFreeOfWebAndPersistenceFrameworks() {
noClasses().that().resideInAPackage(APPLICATION)
.should().dependOnClassesThat().resideInAnyPackage(
"org.springframework.web..",
"org.apache.ibatis..",
"com.baomidou.mybatisplus..")
.check(PRODUCTION_CLASSES);
}
@Test
void applicationOnlyDependsOnItsOwnSliceWithinFirstleg() {
classes().that().resideInAPackage(APPLICATION)
.should().onlyDependOnClassesThat(
resideOutsideOfPackage(FIRSTLEG).or(resideInAPackage(APPLICATION)))
.check(PRODUCTION_CLASSES);
}
@Test
void persistenceAdapterCollaboratesThroughTheApplicationOutputPort() {
classes().that().resideInAPackage(PERSISTENCE)
.should().onlyDependOnClassesThat(
resideOutsideOfPackage(CUSTOMER_STATISTICS).or(resideInAPackage(OUTPUT_PORT)))
.andShould().beAssignableTo(CustomerTransitStatisticsReader.class)
.check(PRODUCTION_CLASSES);
}
@Test
void webAdapterOnlyUsesApplicationAndScopeGuardWithinFirstleg() {
DescribedPredicate<JavaClass> scopeGuard = new DescribedPredicate<>("FirstlegScopeGuard") {
@Override
public boolean test(JavaClass javaClass) {
return javaClass.getName().equals(FirstlegScopeGuard.class.getName());
}
};
classes().that().resideInAPackage(WEB)
.should().onlyDependOnClassesThat(
resideOutsideOfPackage(FIRSTLEG)
.or(resideInAPackage(APPLICATION))
.or(resideInAPackage(WEB))
.or(scopeGuard))
.check(PRODUCTION_CLASSES);
}
@Test
void otherFirstlegProductionPackagesDoNotDependOnCustomerStatistics() {
noClasses().that().resideOutsideOfPackage(CUSTOMER_STATISTICS)
.should().dependOnClassesThat().resideInAPackage(CUSTOMER_STATISTICS)
.check(PRODUCTION_CLASSES);
}
}
@@ -1,8 +1,8 @@
package com.baiyue.firstleg.customerstatistics;
import com.baiyue.firstleg.customerstatistics.controller.PlatformCustomerTransitStatisticsController;
import com.baiyue.firstleg.customerstatistics.mapper.CustomerTransitStatisticsMapper;
import com.baiyue.firstleg.customerstatistics.service.CustomerTransitStatisticsService;
import com.baiyue.firstleg.customerstatistics.adapter.in.web.PlatformCustomerTransitStatisticsController;
import com.baiyue.firstleg.customerstatistics.adapter.out.persistence.CustomerTransitStatisticsMapper;
import com.baiyue.firstleg.customerstatistics.application.CustomerTransitStatisticsService;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -1,8 +1,8 @@
package com.baiyue.firstleg.customerstatistics;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.customerstatistics.mapper.CustomerTransitStatisticsMapper;
import com.baiyue.firstleg.customerstatistics.service.CustomerTransitStatisticsService;
import com.baiyue.firstleg.customerstatistics.application.CustomerTransitStatisticsService;
import com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@@ -19,12 +19,12 @@ import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CustomerTransitStatisticsServiceTest {
@Mock CustomerTransitStatisticsMapper mapper;
@Mock CustomerTransitStatisticsReader reader;
@InjectMocks CustomerTransitStatisticsService service;
@Test void preservesDecimalTeuAndUnknownCount() {
var asOf = LocalDateTime.of(2026, 8, 13, 12, 0);
when(mapper.summarize(88L)).thenReturn(new CustomerTransitStatisticsMapper.SummaryRow(
when(reader.summarize(88L)).thenReturn(new CustomerTransitStatisticsReader.SummaryProjection(
88L, 4L, new BigDecimal("6.25"), 1L, asOf));
var result = service.summarize(88L);
@@ -35,7 +35,7 @@ class CustomerTransitStatisticsServiceTest {
assertThat(result.unknownContainerCount()).isEqualTo(1);
assertThat(result.asOf()).isEqualTo(asOf);
assertThat(result.definition()).contains("20*=1", "40*=2", "45*=2.25", "未知箱型");
verify(mapper).summarize(88L);
verify(reader).summarize(88L);
}
@Test void rejectsInvalidCustomerBeforeQuery() {
@@ -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.gate.OutboundDispatchGateContributor.Context;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
class CustomsDispatchGateContributorTest {
@Mock LoadingListRepository repository;
@Mock CustomsRepository repository;
@InjectMocks CustomsDispatchGateContributor contributor;
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this); }
@@ -22,15 +22,16 @@ class CustomsDispatchGateContributorTest {
.isEqualTo("INVALID_CUSTOMER_SCOPE");
}
@Test void passesOnlyCompletedSimpleDeclaration() {
ContainerDocumentEntity value = new ContainerDocumentEntity();
when(repository.document(81L, "CUSTOMS_DECLARATION")).thenReturn(value);
@Test void passesOnlyCanonicalClearanceCompletedCase() {
CustomsCaseEntity value = new CustomsCaseEntity();
value.setStatus("RECEIPT_AVAILABLE");
when(repository.gateCase(10001L, 21L, 31L, 81L)).thenReturn(value);
assertThat(contributor.assess(context()).passed()).isFalse();
value.setCompletedAt(java.time.LocalDateTime.now());
value.setStatus("CLEARANCE_COMPLETED");
assertThat(contributor.assess(context()))
.satisfies(item -> {
assertThat(item.passed()).isTrue();
assertThat(item.requirement()).isEqualTo("COMPLETED");
assertThat(item.requirement()).isEqualTo("CLEARANCE_COMPLETED");
});
}
@@ -45,4 +45,26 @@ class OutboundDispatchReadMapperContractTest {
assertThat(oceanSql).contains("booking.customer_id=#{tenantId}").doesNotContain("#{customerId}");
assertThat(truckSql).contains("customer_id=#{tenantId}").doesNotContain("#{customerId}");
}
@Test
void timelineIncludesCustomsAndSailingHistoriesWithinTheFullOutboundScope() throws Exception {
String sql = String.join("\n", OutboundDispatchReadMapper.class
.getMethod("timeline", long.class, long.class, long.class, long.class,
long.class, Long.class, Long.class)
.getAnnotation(Select.class).value());
assertThat(sql)
.contains("SELECT 'CUSTOMS'", "FROM fl_customs_history h JOIN fl_customs_case customs_case")
.contains("customs_case.tenant_id=#{tenantId}", "customs_case.order_id=#{orderId}")
.contains("customs_case.inbound_id=#{inboundId}",
"customs_case.container_booking_id=#{containerBookingId}")
.contains("SELECT 'SAILING_SCHEDULE'",
"FROM fl_port_schedule_history h JOIN fl_port_schedule port_schedule")
.contains("port_schedule.customer_id=#{tenantId}",
"port_schedule.ocean_booking_id=#{oceanBookingId}")
.contains("port_schedule.container_booking_id=#{containerBookingId}")
.contains("schedule_inbound.tenant_id=#{tenantId}", "schedule_inbound.order_id=#{orderId}")
.contains("schedule_inbound.id=#{inboundId}")
.contains("ORDER BY occurredAt,module");
}
}
@@ -102,6 +102,25 @@ class OutboundDispatchServiceTest {
verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any());
}
@Test
void reverseIsRejectedAfterActualDepartedWarehouseFactExists() {
CarrierScheduleQueryService carrierSchedules = mock(CarrierScheduleQueryService.class);
OutboundDispatchService service = new OutboundDispatchService(repository, List.of(), carrierSchedules);
stubReadyChain();
OutboundDispatchEntity state = new OutboundDispatchEntity();
state.setId(33L); state.setTenantId(7L); state.setInboundId(11L); state.setContainerBookingId(22L);
state.setStatus("WAITING_FOR_LOADING"); state.setVersion(1);
when(repository.dispatch(7L, 11L, 22L)).thenReturn(state);
when(repository.claim(eq(9L), eq("REVERSE_DISPATCH"), eq("reverse-blocked"), anyString())).thenReturn(true);
when(carrierSchedules.hasOutboundDepartureFact(7L, 66L, 22L)).thenReturn(true);
assertThatThrownBy(() -> service.reverse(9L, "admin", "reverse-blocked",
new ActionRequest(11L, 22L, 1, "误操作")))
.isInstanceOf(BizException.class).hasMessageContaining("DEPARTED_WAREHOUSE");
verify(repository, never()).reverse(anyLong(), anyInt(), anyLong(), anyString(), any());
}
@Test
void selectorsThatMatchMultipleContainerBindingsRequireDisambiguation() {
OutboundDispatchService service = new OutboundDispatchService(repository, List.of());
@@ -46,6 +46,7 @@ class OutboundDispatchSqlContractTest {
.contains("outbound-dispatches/check-gate:")
.contains("outbound-dispatches/confirm-dispatch:")
.contains("outbound-dispatches/reverse-dispatch:")
.contains("Once an actual DEPARTED_WAREHOUSE node exists")
.contains("expectedVersion")
.contains("Idempotency-Key");
}
@@ -1,5 +1,8 @@
package com.baiyue.firstleg.portschedule.repository;
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryMapper;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
@@ -10,6 +13,26 @@ import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class CarrierScheduleQuerySqlContractTest {
@Test
void annotationSqlUsesExecutableComparisonOperators() throws Exception {
String selectDue = String.join("\n", CarrierScheduleQueryMapper.class
.getMethod("selectDue", java.time.LocalDateTime.class, int.class)
.getAnnotation(Select.class).value());
String claimLease = String.join("\n", CarrierScheduleQueryMapper.class
.getMethod("claimLease", long.class, String.class, java.time.LocalDateTime.class,
java.time.LocalDateTime.class)
.getAnnotation(Update.class).value());
assertThat(selectDue)
.contains("next_allowed_at <= #{now}")
.contains("lease_until < #{now}")
.doesNotContain("&lt;");
assertThat(claimLease)
.contains("next_allowed_at <= #{now}")
.contains("lease_until < #{now}")
.doesNotContain("&lt;");
}
@Test
void definesTaskAttemptsHistoryIdempotencyAndHardLimit() throws Exception {
assertThat(moduleSql())
@@ -34,6 +57,10 @@ class CarrierScheduleQuerySqlContractTest {
.contains("DISCHARGED_FROM_VESSEL")
.contains("PICKED_UP','EMPTY_RETURNED")
.contains("CUSTOMS_HOLD','TERMINAL_HOLD','DOCUMENT_HOLD','UNKNOWN_HOLD")
.contains("CREATE PROCEDURE migrate_carrier_schedule_history_action_check()")
.contains("DROP CHECK chk_carrier_schedule_history_action")
.contains("'MANUAL_NODE','NODE_OBSERVED'")
.contains("'EXCEPTION_OBSERVED','PROVIDER_QUERY','STOP'")
.contains("provider_event_id")
.contains("'PUBLIC_FORM','PUBLIC_WEB_PROXY','PUBLIC_HTML','HCAPTCHA','OFFICIAL_API'")
.contains("lease_owner")
@@ -86,6 +113,10 @@ class CarrierScheduleQuerySqlContractTest {
.contains("SET @carrier_schedule_now = CONVERT_TZ(UTC_TIMESTAMP(3),'+00:00','+08:00')")
.contains("SET @carrier_schedule_next = CASE")
.contains("CREATE PROCEDURE ensure_carrier_schedule_retry_check()")
.contains("CREATE PROCEDURE migrate_carrier_schedule_history_action_check()")
.contains("DROP CHECK chk_carrier_schedule_history_action")
.contains("'MANUAL_NODE','NODE_OBSERVED'")
.contains("'EXCEPTION_OBSERVED','PROVIDER_QUERY','STOP'")
.contains("ADD CONSTRAINT chk_carrier_schedule_query_attempt CHECK (attempt_count BETWEEN 0 AND 3)")
.contains("统一迁移失败:船司查询固定槽位字段、索引或尝试次数约束不完整")
.doesNotContain("DROP CHECK chk_carrier_schedule_query_attempt")
@@ -95,6 +126,20 @@ class CarrierScheduleQuerySqlContractTest {
.doesNotContain("'platform:firstleg:port-schedule-query:attempt','执行船司查询'");
}
@Test
void unifiedOutboxSupportsVersionedCrossRegionCarrierNodeFacts() throws Exception {
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
StandardCharsets.UTF_8);
assertThat(unified)
.contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'event_key'")
.contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'event_version'")
.contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'aggregate_sequence'")
.contains("CALL firstleg_finance_add_column('fl_inbound_outbox', 'payload_hash'")
.contains("uk_inbound_outbox_event_key")
.contains("MODIFY COLUMN payload_hash VARCHAR(71)")
.contains("MODIFY COLUMN payload_json LONGTEXT NOT NULL");
}
private static String moduleSql() throws Exception {
return new ClassPathResource("db/phase3-carrier-schedule-query-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
@@ -0,0 +1,79 @@
package com.baiyue.firstleg.portschedule.service;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventTypes;
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.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
class CarrierScheduleNodeOutboxServiceTest {
private final InboundOutboxMapper mapper = mock(InboundOutboxMapper.class);
private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules()
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
private final CarrierScheduleNodeOutboxService service = new CarrierScheduleNodeOutboxService();
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(service, "outbox", mapper);
ReflectionTestUtils.setField(service, "objectMapper", objectMapper);
ReflectionTestUtils.setField(service, "scheduleZone", "Asia/Shanghai");
}
@Test
void writesVersionedActualPickupReadyEnvelopeWithStringIds() throws Exception {
CarrierScheduleQueryEntity query = query();
CarrierScheduleNodeFactEntity fact = fact();
service.publish(query, fact, "ZCSU7881970");
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(mapper).insert(captor.capture());
InboundOutboxEntity row = captor.getValue();
EventEnvelope envelope = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class);
assertThat(row.getEventType()).isEqualTo(EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED);
assertThat(envelope.eventVersion()).isEqualTo(1);
assertThat(envelope.aggregateType()).isEqualTo("CONTAINER_BOOKING");
assertThat(envelope.aggregateId()).isEqualTo("2088000000000000003");
assertThat(envelope.aggregateVersion()).isEqualTo(7);
assertThat(envelope.handoverVersion()).isEqualTo(8);
assertThat(envelope.payload().path("standardNode").asText()).isEqualTo("PICKUP_READY");
assertThat(envelope.payload().path("actualAt").asText()).isEqualTo("2026-08-25T01:00:00Z");
}
@Test
void estimatedOnlyFactNeverEntersCrossRegionOutbox() {
CarrierScheduleNodeFactEntity fact = fact(); fact.setActualAt(null);
service.publish(query(), fact, "ZCSU7881970");
verify(mapper, never()).insert(org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
}
private static CarrierScheduleQueryEntity query() {
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity();
value.setId(2088000000000000001L); value.setCustomerId(2088000000000000002L);
value.setContainerBookingId(2088000000000000003L); value.setOceanBookingId(2088000000000000004L);
value.setVersion(7); return value;
}
private static CarrierScheduleNodeFactEntity fact() {
CarrierScheduleNodeFactEntity value = new CarrierScheduleNodeFactEntity();
value.setId(2088000000000000005L); value.setStandardNode("PICKUP_READY"); value.setNodeSequence(80);
value.setObservedAt(LocalDateTime.of(2026, 8, 25, 9, 2));
value.setActualAt(LocalDateTime.of(2026, 8, 25, 9, 0)); value.setProviderCode("ZIM");
value.setProviderEventId("evt-1"); value.setProviderNodeCode("AVAILABLE");
value.setSourceReference("ZIM:evt-1"); value.setRawPayloadHash("a".repeat(64)); return value;
}
}
@@ -24,6 +24,7 @@ import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.never;
class CarrierScheduleQuerySchedulerTest {
@Test
@@ -106,6 +107,44 @@ class CarrierScheduleQuerySchedulerTest {
verify(service).completeScheduledAttempt(1L, "SUCCESS", null, null);
}
@Test
void leaseLoserNeverCallsProviderOrWritesAttempt() {
CarrierScheduleQueryRepository repository = mock(CarrierScheduleQueryRepository.class);
CarrierScheduleQueryService service = mock(CarrierScheduleQueryService.class);
CarrierScheduleIntegrationClient integration = mock(CarrierScheduleIntegrationClient.class);
CarrierScheduleQueryScheduler scheduler = new CarrierScheduleQueryScheduler(repository, service, integration);
CarrierScheduleQueryEntity task = task(1L);
when(repository.claimLease(anyLong(), anyString(), any(), any())).thenReturn(false);
scheduler.execute(task, LocalDateTime.of(2026, 8, 25, 9, 0));
verify(integration, never()).query(any(), any());
verify(service, never()).completeScheduledAttempt(anyLong(), anyString(), any(), any());
}
@Test
void retryableProviderResultCannotAdvanceEvenWhenItCarriesStrayFacts() {
CarrierScheduleQueryRepository repository = mock(CarrierScheduleQueryRepository.class);
CarrierScheduleQueryService service = mock(CarrierScheduleQueryService.class);
CarrierScheduleIntegrationClient integration = mock(CarrierScheduleIntegrationClient.class);
CarrierScheduleQueryScheduler scheduler = new CarrierScheduleQueryScheduler(repository, service, integration);
CarrierScheduleQueryEntity task = task(1L); task.setCarrierCode("ZIM");
task.setTrackingType("CONTAINER_NO"); task.setTrackingNo("ZCSU7881970");
LocalDateTime now = LocalDateTime.of(2026, 8, 25, 9, 0);
var stray = new CarrierScheduleIntegrationClient.Observation("evt-stray", "AVAILABLE", "PICKUP_READY",
now, null, now, "USLAX", "Los Angeles", "ZIM:evt-stray", "a".repeat(64));
when(repository.claimLease(anyLong(), anyString(), any(), any())).thenReturn(true);
when(integration.query(any(), any())).thenReturn(new CarrierScheduleIntegrationClient.Envelope<>("200", null,
new CarrierScheduleIntegrationClient.QueryResult("ZIM", "RETRYABLE_FAILURE", List.of(stray), List.of(),
"HTTP_503", "unavailable", now)));
scheduler.execute(task, now);
verify(service, never()).applyProviderObservation(anyLong(), any());
verify(service, never()).applyProviderException(anyLong(), any());
verify(service).completeScheduledAttempt(1L, "RETRYABLE_FAILURE", "HTTP_503", "unavailable");
}
private static CarrierScheduleQueryEntity task(long id) {
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity(); value.setId(id); return value;
}
@@ -11,6 +11,7 @@ import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderExc
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleExceptionFactEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity;
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository;
@@ -38,11 +39,13 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.times;
class CarrierScheduleQueryServiceTest {
private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 10, 12, 0);
@Mock private CarrierScheduleQueryRepository repository;
@Mock private PortScheduleRepository portSchedules;
@Mock private CarrierScheduleNodeOutboxService nodeOutbox;
@InjectMocks private CarrierScheduleQueryService service;
@BeforeEach
@@ -56,6 +59,9 @@ class CarrierScheduleQueryServiceTest {
when(repository.nodes(anyLong())).thenReturn(List.of());
when(repository.exceptions(anyLong())).thenReturn(List.of());
when(repository.histories(anyLong())).thenReturn(List.of());
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
container.setContainerNo("ZCSU7881970");
when(portSchedules.containerBooking(20L)).thenReturn(container);
}
@Test
@@ -165,6 +171,25 @@ class CarrierScheduleQueryServiceTest {
assertThat(task.getNextAllowedAt()).isNull();
}
@Test
void lateActualFactAfterEmptyReturnIsAuditedWithoutRestartingTask() {
CarrierScheduleQueryEntity task = task(0, 4);
task.setStatus("STOPPED"); task.setLatestStandardNode("EMPTY_RETURNED");
task.setLatestNodeObservedAt(NOW); task.setNextAllowedAt(null); task.setStopReason("已还柜");
when(repository.lock(1L)).thenReturn(task);
when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(true);
service.applyProviderObservation(1L, new ProviderObservationRequest("ZIM", "late-discharge",
"DISCHARGED", "DISCHARGED_FROM_VESSEL", NOW.plusHours(1), null, NOW.minusDays(2),
"USLAX", "Los Angeles", "ZIM:late-discharge", "d".repeat(64)));
assertThat(task.getStatus()).isEqualTo("STOPPED");
assertThat(task.getLatestStandardNode()).isEqualTo("EMPTY_RETURNED");
assertThat(task.getNextAllowedAt()).isNull();
verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString());
verify(nodeOutbox).publish(eq(task), any(CarrierScheduleNodeFactEntity.class), eq("ZCSU7881970"));
}
@Test
void manualActualEmptyReturnedUsesExistingPermissionFlowAndStopsIdempotently() {
CarrierScheduleQueryEntity task = task(0, 3);
@@ -224,6 +249,47 @@ class CarrierScheduleQueryServiceTest {
assertThat(task.getLatestStandardNode()).isNull();
}
@Test
void allFourHoldTypesAndReleaseFactsRemainSeparateFromBusinessProgress() {
CarrierScheduleQueryEntity task = task(0, 1);
task.setLatestStandardNode("IN_TRANSIT");
when(repository.lock(1L)).thenReturn(task);
when(repository.insertIfAbsent(any(CarrierScheduleExceptionFactEntity.class))).thenReturn(true);
for (String code : List.of("CUSTOMS_HOLD", "TERMINAL_HOLD", "DOCUMENT_HOLD", "UNKNOWN_HOLD")) {
service.applyProviderException(1L, new ProviderExceptionRequest("TRACK_API", code + "-ON",
code, code, true, NOW, null, null, "provider:" + code, "a".repeat(64)));
service.applyProviderException(1L, new ProviderExceptionRequest("TRACK_API", code + "-OFF",
code + " RELEASED", code, false, NOW.plusMinutes(1), null, null,
"provider:" + code + ":released", "b".repeat(64)));
}
verify(repository, times(8)).insertIfAbsent(any(CarrierScheduleExceptionFactEntity.class));
verify(repository, never()).updateState(any(), any(Integer.class), anyLong(), anyString());
assertThat(task.getLatestStandardNode()).isEqualTo("IN_TRANSIT");
}
@Test
void providerEventIdReplayRequiresTheSameRawPayloadHash() {
CarrierScheduleQueryEntity task = task(0, 1);
task.setLatestStandardNode("IN_TRANSIT");
CarrierScheduleNodeFactEntity existing = new CarrierScheduleNodeFactEntity();
existing.setEventKey("ZIM:evt-duplicate"); existing.setRawPayloadHash("a".repeat(64));
when(repository.lock(1L)).thenReturn(task);
when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(false);
when(repository.node(1L, "ZIM:evt-duplicate")).thenReturn(existing);
ProviderObservationRequest replay = new ProviderObservationRequest("ZIM", "evt-duplicate", "GATE_IN",
"AT_ORIGIN_PORT", NOW, null, NOW.minusDays(1), null, null, "ZIM:evt", "a".repeat(64));
service.applyProviderObservation(1L, replay);
verify(nodeOutbox, never()).publish(any(), any(), anyString());
ProviderObservationRequest conflict = new ProviderObservationRequest("ZIM", "evt-duplicate", "GATE_IN",
"AT_ORIGIN_PORT", NOW, null, NOW.minusDays(1), null, null, "ZIM:evt", "b".repeat(64));
assertThatThrownBy(() -> service.applyProviderObservation(1L, conflict))
.isInstanceOf(BizException.class).hasMessageContaining("不同rawPayloadHash");
}
@Test
void everySchedulerCompletionWritesImmutableAttempt() {
CarrierScheduleQueryEntity task = task(0, 0);
@@ -0,0 +1,40 @@
package com.baiyue.firstleg.repository;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class FulfillmentTypeSqlContractTest {
@Test
void baselinesAndUnifiedMigrationPersistIndependentFulfillmentType() throws Exception {
String order = Files.readString(resolve("order/src/main/resources/db/order.sql"), StandardCharsets.UTF_8);
String inbound = Files.readString(resolve("firstleg/src/main/resources/db/phase2-inbound-schema.sql"), StandardCharsets.UTF_8);
String unified = Files.readString(resolve("deploy/sql/头程数据库统一增量-MySQL8.sql"), StandardCharsets.UTF_8);
assertFulfillmentMigration(order, "baiyue_order.ord_order");
assertFulfillmentMigration(inbound, "fl_inbound_order");
assertFulfillmentMigration(unified, "baiyue_order.ord_order");
assertFulfillmentMigration(unified, "fl_inbound_order");
assertThat(unified)
.contains("unified_assert_column('baiyue_order', 'ord_order', 'fulfillment_type')")
.contains("unified_assert_column('baiyue_firstleg', 'fl_inbound_order', 'fulfillment_type')");
}
private static void assertFulfillmentMigration(String sql, String table) {
assertThat(sql)
.contains("fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD'")
.contains("ADD COLUMN fulfillment_type VARCHAR(32) NULL")
.contains("SET fulfillment_type = 'STANDARD'")
.contains("MODIFY COLUMN fulfillment_type VARCHAR(32) NOT NULL DEFAULT 'STANDARD'")
.contains(table);
}
private static Path resolve(String path) {
Path direct = Path.of(path);
return Files.exists(direct) ? direct : Path.of("..").resolve(path).normalize();
}
}
@@ -17,6 +17,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.junit.jupiter.api.Test;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.mockito.ArgumentCaptor;
@@ -43,6 +44,7 @@ class InboundOrderRepositoryTest {
@MockBean private InboundFileMapper files;
@MockBean private InboundInternalNoteMapper notes;
@MockBean private InboundStatusHistoryMapper histories;
@MockBean private InboundWeightResultMapper weights;
@Resource private InboundOrderRepository repository;
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
@@ -0,0 +1,38 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class LoadingCompletedOutboxRepositoryTest {
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void claimRechecksRetryWindowAfterCandidateSelection() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"),
InboundOutboxEntity.class);
InboundOutboxMapper mapper = mock(InboundOutboxMapper.class);
LoadingCompletedOutboxRepository repository = new LoadingCompletedOutboxRepository();
ReflectionTestUtils.setField(repository, "mapper", mapper);
ArgumentCaptor<LambdaUpdateWrapper> wrapper = ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
when(mapper.update(isNull(InboundOutboxEntity.class), wrapper.capture())).thenReturn(1);
LocalDateTime now = LocalDateTime.of(2026, 8, 25, 12, 0);
assertThat(repository.claim(1L, "worker", now, now.minusMinutes(1))).isTrue();
assertThat(wrapper.getValue().getSqlSegment())
.contains("next_retry_at", "<=", "publish_status", "locked_at");
}
}
@@ -60,6 +60,9 @@ class LoadingListQueryMapperContractTest {
assertThat(items)
.contains("i.order_id", "i.order_no", "b.container_no")
.contains("b.record_status='ACTIVE'")
.contains("NULLIF(i.pallet_count,0)")
.contains("fl_inbound_weight_result weight")
.contains("weight.recognition_status='CONFIRMED'")
.contains("i.loading_plan_id = #{planId}")
.contains("i.deleted = 0");
}
@@ -29,6 +29,31 @@ class LoadingPickItemMapperContractTest {
assertThat(progress.getUnresolvedCount()).isEqualTo(1);
}
@Test
void inboundProgressUsesSettledTerminalQuantitiesAndRejectsNonTerminalItems() throws Exception {
Method method = LoadingPickItemMapper.class.getDeclaredMethod(
"selectInboundProgress", long.class, long.class);
String sql = method.getAnnotation(Select.class).value()[0];
assertThat(sql)
.contains("WHEN pick_item.item_status='SKIPPED' AND pick_item.picked_package_count=0 THEN 0")
.contains("WHEN pick_item.item_status='PARTIAL' AND pick_item.picked_package_count>0")
.contains("AND pick_item.picked_package_count<=plan_item.package_count")
.contains("THEN pick_item.picked_package_count")
.contains("ELSE plan_item.package_count")
.contains("WHEN pick_item.id IS NULL THEN 1")
.contains("WHEN pick_item.item_status='PICKED'")
.contains("AND pick_item.picked_package_count=plan_item.package_count THEN 0")
.contains("ELSE 1")
.doesNotContain("pick_item.picked_package_count<>plan_item.package_count");
assertThat(org.springframework.util.StringUtils.countOccurrencesOf(sql,
"pick_item.item_status='PARTIAL'")).isEqualTo(2);
assertThat(org.springframework.util.StringUtils.countOccurrencesOf(sql,
"pick_item.item_status='SKIPPED'")).isEqualTo(2);
assertThat(sql).doesNotContain("pick_item.item_status='PENDING' THEN 0")
.doesNotContain("pick_item.item_status='PICKING' THEN 0");
}
@Test
void completionCheckCoversAllActivePlanItems() throws Exception {
Method method = LoadingPickItemMapper.class.getDeclaredMethod("unfinishedCount", long.class);
@@ -39,7 +64,17 @@ class LoadingPickItemMapperContractTest {
.contains("plan_item.deleted = 0")
.contains("pick_task.loading_plan_id = #{planId}")
.contains("pick_task.task_status = 'LOADING'")
.contains("pick_item.picked_package_count = plan_item.package_count");
.contains("pick_item.item_status = 'PICKED'")
.contains("pick_item.picked_package_count = plan_item.package_count")
.contains("pick_item.item_status = 'PARTIAL'")
.contains("pick_item.picked_package_count > 0")
.doesNotContain("pick_item.picked_package_count < plan_item.package_count")
.contains("pick_item.item_status = 'SKIPPED'")
.contains("pick_item.picked_package_count = 0")
.doesNotContain("pick_item.item_status = 'PENDING'")
.doesNotContain("pick_item.item_status = 'PICKING'");
assertThat(org.springframework.util.StringUtils.countOccurrencesOf(sql,
"pick_item.picked_package_count = plan_item.package_count")).isEqualTo(2);
}
@Test
@@ -10,10 +10,33 @@ import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class PdaSplitSettlementSqlContractTest {
@Test
void addsPreloadConfirmationAuditColumnsForIncrementalPdaAllocation() throws Exception {
String moduleSql = new ClassPathResource("db/phase3-split-preload-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
String unifiedSql = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
StandardCharsets.UTF_8);
assertThat(moduleSql)
.contains("confirmed_by BIGINT UNSIGNED NULL")
.contains("confirmed_by_name VARCHAR(64) NULL")
.contains("confirmed_at DATETIME(3) NULL")
.contains("phase3_split_add_column('fl_preload_plan', 'confirmed_by'")
.contains("phase3_split_add_column('fl_preload_plan', 'confirmed_by_name'")
.contains("phase3_split_add_column('fl_preload_plan', 'confirmed_at'");
assertThat(unifiedSql)
.contains("CREATE PROCEDURE migrate_preload_confirmation_columns()")
.contains("ADD COLUMN confirmed_by BIGINT UNSIGNED NULL")
.contains("ADD COLUMN confirmed_by_name VARCHAR(64) NULL")
.contains("ADD COLUMN confirmed_at DATETIME(3) NULL");
}
@Test
void protectsLoadedAndRemainderSettlement() throws Exception {
String sql = new ClassPathResource("db/phase3-inbound-parent-child-schema.sql")
@@ -77,6 +100,7 @@ class PdaSplitSettlementSqlContractTest {
assertThat(clone)
.contains("#{childOrderId},#{childOrderNo}")
.contains("CASE WHEN #{splitPurpose}='LOADED' THEN 'LOADING' ELSE 'WAIT_SPLIT' END")
.contains("business_type,fulfillment_type")
.contains("actual_gross_weight_kg * #{packageCount}")
.contains("actual_net_weight_kg * #{packageCount}");
@@ -101,6 +125,29 @@ class PdaSplitSettlementSqlContractTest {
.contains("split_status='FULLY_SPLIT'");
}
@Test
void partialPickingConvergesPalletSnapshotWithActualCartons() throws Exception {
String converge = InboundSplitSettlementMapper.class.getMethod("convergePartialPlanItem",
long.class, long.class, String.class)
.getAnnotation(Update.class).value()[0];
assertThat(converge)
.contains("plan_item.pallet_count = CEIL")
.contains("pick_item.picked_package_count / NULLIF(plan_item.package_count,0)")
.contains("plan_item.package_count = pick_item.picked_package_count");
}
@Test
void zeroPackageSettlementNeverWritesNullVolumeWeight() throws Exception {
String update = String.join(" ", InboundSplitSettlementMapper.class.getMethod(
"updateMeasurementItemForLoaded", long.class, long.class, int.class)
.getAnnotation(Update.class).value()).replaceAll("\\s+", " ");
assertThat(update)
.contains("volume_weight_kg=CASE WHEN #{packageCount}=0 THEN 0")
.contains("COALESCE(volume_weight_kg/NULLIF(package_count,0)*#{packageCount},0)");
assertThat(update.indexOf("volume_weight_kg=")).isLessThan(update.indexOf("package_count=#{packageCount}"));
}
@Test
void keepsAllocationConfirmedUntilReleaseStep() throws Exception {
String consume = InboundPalletAllocationMapper.class.getMethod("consume",
@@ -26,7 +26,9 @@ class SmartLoadingCandidateMapperContractTest {
.contains("m.status='APPROVED'")
.contains("SELECT MAX(m2.id)")
.contains("m2.status='APPROVED'")
.contains("i.inbound_detail_id IS NOT NULL")
.contains("WHERE inbound_detail_id IS NOT NULL AND package_count>0")
.contains("SUM(package_count) package_count")
.contains("GROUP BY tenant_id,measurement_id,inbound_detail_id")
.contains("o.warehouse_id=#{warehouseId}")
.contains("o.process_status='WAIT_SPLIT'")
.contains("unitGrossWeightKg")
@@ -36,13 +38,13 @@ class SmartLoadingCandidateMapperContractTest {
.doesNotContain("i.unit_weight_kg")
.contains("SELECT SUM(li.package_count)")
.contains("li.deleted=0")
.contains("ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC");
.contains("ORDER BY o.inbounded_at ASC, o.id ASC, i.inbound_detail_id ASC");
}
@Test
void detailUsesSameBatchRule() throws Exception {
Method detail = SmartLoadingCandidateMapper.class.getDeclaredMethod(
"candidate", long.class, long.class, long.class);
"candidate", long.class, long.class, long.class, long.class);
String sql = String.join(" ", detail.getAnnotation(Select.class).value());
assertThat(sql)
@@ -51,10 +53,18 @@ class SmartLoadingCandidateMapperContractTest {
.contains("SELECT MAX(m2.id)")
.contains("o.warehouse_id=#{warehouseId}")
.contains("o.process_status='WAIT_SPLIT'")
.contains("o.process_status='LOADING'")
.contains("same_plan_item.loading_plan_id=#{planId}")
.contains("same_plan_item.account_id=o.tenant_id")
.contains("same_plan_item.inbound_id=o.id")
.contains("same_plan_item.deleted=0")
.doesNotContain("o.process_status IN ('WAIT_SPLIT','LOADING')")
.contains("unitGrossWeightKg")
.contains("unitNetWeightKg")
.doesNotContain("i.unit_weight_kg")
.contains("SELECT SUM(li.package_count)")
.contains("SUM(package_count) package_count")
.contains("GROUP BY tenant_id,measurement_id,inbound_detail_id")
.contains("FOR UPDATE");
}
}
@@ -89,6 +89,18 @@ class SmartLoadingSqlContractTest {
}
}
@Test
@DisplayName("同一入库明细的多条单箱测量记录只生成一个候选")
void shouldAggregateCartonMeasurementRowsByInboundDetail() throws Exception {
String mapper = Files.readString(Path.of(
"src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java"), StandardCharsets.UTF_8);
assertThat(mapper)
.contains("SUM(package_count) package_count")
.contains("GROUP BY tenant_id,measurement_id,inbound_detail_id")
.contains("split_item.source_inbound_detail_id=i.inbound_detail_id")
.doesNotContain("split_item.source_measurement_item_id=i.id");
}
@Test
@DisplayName("仓库备注使用计划先行与明细单表CAS并允许已拣明细仅改备注")
void shouldUpdateWarehouseRemarkWithoutMultiTableTriggerConflict() throws Exception {
@@ -196,8 +208,6 @@ class SmartLoadingSqlContractTest {
.contains("SET MESSAGE_TEXT = '装柜状态只允许单向推进'")
.contains("IF OLD.plan_status = 'EDITING' AND NEW.plan_status = 'LOADING' THEN")
.contains("ELSEIF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN")
.contains("LEFT JOIN fl_loading_pick_item pick_item")
.contains("pick_item.picked_package_count <> item.package_count")
.contains("IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN")
.contains("SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3))")
.contains("已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数")
@@ -214,6 +224,23 @@ class SmartLoadingSqlContractTest {
.doesNotContain("CONFIRMED")
.doesNotContain("ROLLED_BACK")
.doesNotContain("WITHDRAWN");
assertLoadingCompletionTrigger(migration());
assertLoadingCompletionTrigger(unifiedMigration());
}
private static void assertLoadingCompletionTrigger(String sql) {
assertThat(sql)
.contains("AND NOT EXISTS (")
.contains("pick_task.loading_plan_id = OLD.id")
.contains("pick_task.task_status = 'LOADING'")
.contains("pick_item.item_status = 'PICKED'")
.contains("pick_item.item_status = 'PARTIAL'")
.contains("pick_item.picked_package_count > 0")
.contains("pick_item.item_status = 'SKIPPED'")
.contains("pick_item.picked_package_count = 0")
.doesNotContain("pick_item.picked_package_count <> item.package_count");
assertThat(org.springframework.util.StringUtils.countOccurrencesOf(sql,
"pick_item.picked_package_count = item.package_count")).isEqualTo(2);
}
@Test
@@ -108,7 +108,9 @@ class TenantScopedMutationRepositoryTest {
verify(inboundOrders).update(eq(inbound), wrapper.capture());
assertTenantAndId(wrapper.getValue());
assertThat(((LambdaUpdateWrapper<InboundOrderEntity>) wrapper.getValue()).getSqlSet())
.contains("actual_gross_weight_kg");
.contains("actual_gross_weight_kg")
.contains("actual_net_weight_kg")
.contains("pallet_count");
}
@Test
@@ -37,6 +37,14 @@ class FeePaymentSnapshotValidatorTest {
.isInstanceOf(BizException.class).hasMessageContaining("账期应付不得");
}
@Test
void payableWithoutSettlementModeReturnsBusinessValidationInsteadOfNullPointer() {
assertThatThrownBy(() -> validator.validate(9L, "PAYABLE", null,
null, null, null, null))
.isInstanceOf(BizException.class)
.hasMessageContaining("应付费用必须选择ON_ACCOUNT或DIRECT_PAID");
}
@Test
void directPaidRequiresTimeMethodAndReferenceOrVoucher() {
LocalDateTime paidAt = LocalDateTime.of(2026, 8, 20, 10, 0);
@@ -123,6 +123,7 @@ class InboundMeasurementServiceTest {
assertThat(weight.getNetWeightKg()).isNull();
assertThat(inbound.getActualGrossWeightKg()).isNull();
assertThat(inbound.getActualNetWeightKg()).isNull();
assertThat(inbound.getPalletCount()).isZero();
verify(repository).updateInboundWeightSummary(inbound);
}
@@ -283,6 +284,7 @@ class InboundMeasurementServiceTest {
assertThat(response.netWeightKg()).isEqualByComparingTo("4.536");
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("6.536");
assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("6.536");
assertThat(inbound.getPalletCount()).isEqualTo(2);
assertThat(inbound.getProcessStatus()).isEqualTo("MEASURING");
verify(repository).insertRevision(any(InboundWeightRevisionEntity.class));
}
@@ -630,6 +632,7 @@ class InboundMeasurementServiceTest {
assertThat(inbound.getActualGrossWeightKg()).isNull();
assertThat(inbound.getActualNetWeightKg()).isNull();
assertThat(inbound.getPalletCount()).isZero();
verify(repository).updateInboundWeightSummary(inbound);
}
@@ -655,6 +658,7 @@ class InboundMeasurementServiceTest {
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("5.000");
assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("5.000");
assertThat(inbound.getPalletCount()).isEqualTo(1);
verify(repository).updateInboundWeightSummary(inbound);
}
@@ -72,11 +72,35 @@ class InboundOrderCreationServiceTest {
assertThat(inboundCaptor.getValue().getCarrierId()).isNull();
assertThat(inboundCaptor.getValue().getTaxMode()).isEqualTo("BUY_DECLARATION");
assertThat(inboundCaptor.getValue().getPricingTaxMode()).isEqualTo("TAX_EXCLUDED");
assertThat(inboundCaptor.getValue().getFulfillmentType()).isEqualTo("STANDARD");
assertThat(inboundCaptor.getValue().getTotalPackageCount()).isEqualTo(4);
assertThat(createdId).isEqualTo(inboundCaptor.getValue().getId());
assertThat(replayedId).isEqualTo(900L);
}
@Test
void persistsDropshipFulfillmentTypeWithoutChangingPricingBusinessType() {
OrderApprovedInboundCommand base = approvedCommand("PCHK2608140001");
OrderApprovedInboundCommand command = new OrderApprovedInboundCommand(
base.tenantId(), base.orderId(), base.orderNo(), base.inboundCode(), base.customerId(),
base.customerName(), base.warehouseId(), base.warehouseName(), base.deliveryWarehouseId(),
base.deliveryWarehouseName(), base.expectedArrivalAt(), base.businessType(), base.channelCode(),
base.channelName(), base.carrierId(), base.carrierName(), base.taxMode(), base.pricingTaxMode(),
base.containerMode(), base.customsInspection(), base.destinationType(), base.destinationWarehouseId(),
base.destinationCode(), base.destinationName(), base.publicRemark(), base.expressCompanyName(),
base.expressTrackingNo(), base.totalPackageCount(), base.pickupMethod(), base.requestedPickupAt(),
"ONE_CLICK_DROPSHIP");
when(orders.selectOne(any(Wrapper.class))).thenReturn(null);
when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L));
service.createFromApprovedOrder(command);
ArgumentCaptor<InboundOrderEntity> captor = ArgumentCaptor.forClass(InboundOrderEntity.class);
verify(orders).insert(captor.capture());
assertThat(captor.getValue().getBusinessType()).isEqualTo("FBA");
assertThat(captor.getValue().getFulfillmentType()).isEqualTo("ONE_CLICK_DROPSHIP");
}
@Test
void shouldRejectReplayWithDifferentInboundCode() {
InboundOrderEntity existing = inbound(900L, "RK-OLD", "UNPRICED", null, null);
@@ -1,6 +1,7 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.event.EventEnvelope;
import com.baiyue.common.event.EventPayloadCanonicalizer;
import com.baiyue.common.event.EventPayloadHash;
@@ -18,6 +19,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.cloud.client.circuitbreaker.NoFallbackAvailableException;
import java.time.Instant;
import java.time.LocalDateTime;
@@ -73,7 +75,44 @@ class LoadingCompletedOutboxRelayTest {
relay.relay();
verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), eq("failed *** remote"));
verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class),
eq("IllegalStateException: failed *** remote"));
}
@Test
void persistsRootCauseWhenCircuitBreakerWrapsBridgeFailure() throws Exception {
EventEnvelope event = event();
InboundOutboxEntity row = row(event);
when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row));
when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true);
when(repository.claimed(eq(9001L), anyString())).thenReturn(row);
when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenThrow(
new NoFallbackAvailableException("No fallback available.",
new IllegalStateException("Connection refused: event-bridge-cn:9312")));
relay.relay();
verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class),
eq("IllegalStateException: Connection refused: event-bridge-cn:9312"));
verify(repository, never()).published(anyLong(), anyString(), any());
}
@Test
void persistsDownstreamCodeAndRequestEvidenceWhenBridgeRejectsEvent() throws Exception {
EventEnvelope event = event();
InboundOutboxEntity row = row(event);
when(repository.pending(eq(50), any(), any())).thenReturn(List.of(row));
when(repository.claim(eq(9001L), anyString(), any(), any())).thenReturn(true);
when(repository.claimed(eq(9001L), anyString())).thenReturn(row);
when(client.receive(eq("internal-token"), any(EventEnvelope.class))).thenReturn(ApiResponse.failure(
ResultCode.SERVICE_UNAVAILABLE, "bridge inbox unavailable", "request-123", "trace-456"));
relay.relay();
verify(repository).retry(eq(9001L), anyString(), any(LocalDateTime.class), eq(
"IllegalStateException: 事件桥拒绝头程跨区事件: code=S0503, message=bridge inbox unavailable, "
+ "requestId=request-123, traceId=trace-456"));
verify(repository, never()).published(anyLong(), anyString(), any());
}
private InboundOutboxEntity row(EventEnvelope event) throws Exception {
@@ -8,6 +8,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.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -37,7 +38,9 @@ class LoadingCompletedOutboxServiceTest {
ReflectionTestUtils.setField(service,"cartonManifestMapper",manifests);
ReflectionTestUtils.setField(service,"outboxMapper",outbox);
ReflectionTestUtils.setField(service,"objectMapper",mapper);
when(manifests.labelPool(anyLong(), anyLong(), anyLong())).thenReturn(List.of("BOX-POOL-1"));
when(manifests.labelPool(anyLong(), anyLong())).thenAnswer(invocation -> List.of(
label(101, "BOX-101"), label(102, "BOX-102"),
label(202, "BOX-202"), label(302, "BOX-302")));
}
@Test void groupsByAccountAndKeepsAllOrdersAndItemsStable() throws Exception {
@@ -54,8 +57,8 @@ class LoadingCompletedOutboxServiceTest {
assertThat(first.payload().at("/orders/0/sourceInboundId").asText()).isEqualTo("100");
assertThat(first.payload().at("/orders/0/items/0/sourceInboundDetailId").asText()).isEqualTo("101");
assertThat(first.payload().at("/orders/0/items/0/skuCode").asText()).isEqualTo("SKU101");
assertThat(first.payload().at("/orders/0/items/0/cartonMarks/0").asText()).isEqualTo("BOX-POOL-1");
assertThat(first.payload().at("/orders/0/palletCount").asInt()).isEqualTo(2);
assertThat(first.payload().at("/orders/0/items/0/cartonMarks/0").asText()).isEqualTo("BOX-101");
assertThat(first.payload().at("/orders/0/destinationType").asText()).isEqualTo("OVERSEAS");
assertThat(rows.get(0).getEventKey()).isEqualTo("FIRSTLEG_LOADING_COMPLETED:90:11:2");
assertThat(first.eventVersion()).isEqualTo(2);
}
@@ -69,12 +72,39 @@ class LoadingCompletedOutboxServiceTest {
.hasMessageContaining("缺少 SKU");
}
@Test void fbaFreezesPackageCountWithoutRequiringCartonLabels() throws Exception {
Row value = row(11, 100, 101);
value.setDestinationType("FBA");
when(snapshots.rows(90L)).thenReturn(List.of(value));
service.publish(90L);
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(outbox).insert(captor.capture());
EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class);
assertThat(event.payload().at("/orders/0/destinationType").asText()).isEqualTo("FBA");
assertThat(event.payload().at("/orders/0/items/0/cartonMarks")).isEmpty();
verify(manifests, org.mockito.Mockito.never()).labelPool(11L, 100L);
}
@Test void rejectsUnfrozenDestinationType() {
Row value = row(11, 100, 101);
value.setDestinationType(null);
when(snapshots.rows(90L)).thenReturn(List.of(value));
assertThatThrownBy(() -> service.publish(90L))
.hasMessageContaining("目的类型必须唯一且为 FBA 或 OVERSEAS");
verify(outbox, org.mockito.Mockito.never()).insert(
org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
}
@Test void freezesExactlyLoadedPackagesFromLabelPoolDeterministically() throws Exception {
Row value = row(11, 100, 101);
value.setPackageCount(2);
when(snapshots.rows(90L)).thenReturn(List.of(value));
when(manifests.labelPool(11L, 100L, 101L)).thenReturn(
List.of("BOX-001", "BOX-002", "BOX-003", "BOX-004"));
when(manifests.labelPool(11L, 100L)).thenReturn(List.of(
label(101, "BOX-001"), label(101, "BOX-002"),
label(101, "BOX-003"), label(101, "BOX-004")));
service.publish(90L);
service.publish(90L);
@@ -88,6 +118,25 @@ class LoadingCompletedOutboxServiceTest {
.isEqualTo(second.payload().at("/orders/0/items/0/cartonMarks"));
}
@Test void fillsCollapsedMeasurementDetailFromUnusedInboundLabelsWithoutDuplicates() throws Exception {
Row first = row(11, 100, 101); first.setPackageCount(1);
Row second = row(11, 100, 101); second.setLoadingPlanItemId(102L); second.setPackageCount(2);
when(snapshots.rows(90L)).thenReturn(List.of(first, second));
when(manifests.labelPool(11L, 100L)).thenReturn(List.of(
label(201, "BOX-001"), label(202, "BOX-002"), label(203, "BOX-003")));
service.publish(90L);
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(outbox).insert(captor.capture());
EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class);
List<String> firstMarks = mapper.convertValue(event.payload().at("/orders/0/items/0/cartonMarks"), List.class);
List<String> secondMarks = mapper.convertValue(event.payload().at("/orders/0/items/1/cartonMarks"), List.class);
assertThat(firstMarks).hasSize(1);
assertThat(secondMarks).hasSize(2);
assertThat(firstMarks).doesNotContainAnyElementsOf(secondMarks);
}
@Test void snapshotOnlyPublishesFinalizedLoadedInbounds() throws Exception {
String sql = String.join(" ", LoadingCompletedEventMapper.class.getMethod("rows", long.class)
.getAnnotation(Select.class).value()).replaceAll("\\s+", " ");
@@ -97,9 +146,9 @@ class LoadingCompletedOutboxServiceTest {
}
@Test void publishesDropshipPalletSkuAndCartonSnapshots() throws Exception {
Row first = row(11, 100, 101); first.setBusinessType("ONE_CLICK_DROPSHIP");
Row first = row(11, 100, 101); first.setFulfillmentType("ONE_CLICK_DROPSHIP");
first.setPackageCount(2); first.setProductQuantity(20); first.setSkuCode("SKU-A");
Row second = row(11, 100, 102); second.setBusinessType("ONE_CLICK_DROPSHIP");
Row second = row(11, 100, 102); second.setFulfillmentType("ONE_CLICK_DROPSHIP");
second.setPackageCount(1); second.setProductQuantity(5); second.setSkuCode("SKU-B");
when(snapshots.rows(90L)).thenReturn(List.of(first, second));
when(snapshots.palletRows(90L)).thenReturn(List.of(
@@ -119,18 +168,33 @@ class LoadingCompletedOutboxServiceTest {
assertThat(event.payload().at("/orders/0/palletCount").asInt()).isEqualTo(2);
}
@Test void publishesOrdinaryRecommendedPalletNumbersForTailLegReuse() throws Exception {
@Test
void pricingBusinessTypeCannotTurnAStandardOrderIntoDropship() throws Exception {
Row value = row(11, 100, 101);
value.setBusinessType("ONE_CLICK_DROPSHIP");
value.setFulfillmentType("STANDARD");
when(snapshots.rows(90L)).thenReturn(List.of(value));
when(snapshots.palletRows(90L)).thenReturn(List.of(
pallet(11, 100, 1, "FL-PLT-001", 1, 101, "SKU101", 1, 10, "BOX-POOL-1")));
service.publish(90L);
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(outbox).insert(captor.capture());
EventEnvelope event = mapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class);
assertThat(event.payload().at("/orders/0/pallets/0/palletNo").asText()).isEqualTo("FL-PLT-001");
assertThat(event.payload().at("/orders/0/orderType").asText()).isEqualTo("FIRST_LEG");
assertThat(event.payload().at("/orders/0/pallets")).isEmpty();
}
@Test
void rejectsMixedFulfillmentTypesWithinOneInbound() {
Row first = row(11, 100, 101);
Row second = row(11, 100, 102);
second.setFulfillmentType("ONE_CLICK_DROPSHIP");
when(snapshots.rows(90L)).thenReturn(List.of(first, second));
assertThatThrownBy(() -> service.publish(90L))
.hasMessageContaining("履约类型必须唯一");
verify(outbox, org.mockito.Mockito.never()).insert(
org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
}
private Row row(long account,long inbound,long detail) {
@@ -140,6 +204,8 @@ class LoadingCompletedOutboxServiceTest {
r.setSourceOrderId(inbound+1000); r.setSourceOrderNo("FL"+inbound); r.setRootOrderId(1099L);
r.setInboundCode("RK"+inbound); r.setLoadingPlanItemId(detail); r.setDestinationWarehouseId(93L); r.setDestinationWarehouseCode("ONT8");
r.setDestinationWarehouseName("ONT8"); r.setSourceInboundDetailId(detail); r.setSkuCode("SKU"+detail);
r.setDestinationType("OVERSEAS");
r.setFulfillmentType("STANDARD");
r.setPackageCount(1); r.setProductQuantity(10); r.setPalletCount(1); r.setGrossWeightKg(BigDecimal.ONE);
r.setNetWeightKg(BigDecimal.ONE); r.setVolumeCbm(BigDecimal.ONE); return r;
}
@@ -152,4 +218,8 @@ class LoadingCompletedOutboxServiceTest {
row.setSourceInboundDetailId(detail); row.setSkuCode(sku); row.setProductNameZh("产品");
row.setPackageCount(packages); row.setProductQuantity(quantity); row.setBoxCode(boxCode); return row;
}
private LabelRow label(long detailId, String boxCode) {
LabelRow row = new LabelRow(); row.setInboundDetailId(detailId); row.setBoxCode(boxCode); return row;
}
}
@@ -508,9 +508,12 @@ class LoadingPickServiceTest {
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1);
var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L);
when(repository.inboundOwners(20L)).thenReturn(List.of(owner));
var incrementalOwner = new LoadingPickItemMapper.InboundOwner(101L, 60L);
when(repository.inboundOwners(20L)).thenReturn(List.of(owner, incrementalOwner));
when(repository.inboundForUpdate(101L, 50L)).thenReturn(
new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7));
when(repository.inboundForUpdate(101L, 60L)).thenReturn(
new LoadingInboundMapper.Snapshot(60L, 101L, "LOADING", 3));
service.complete(20L, new CompleteLoadingRequest(0, "complete-1", "checked"), 9L, "operator");
service.complete(20L, new CompleteLoadingRequest(0, "complete-1", "checked"), 9L, "operator");
@@ -519,14 +522,17 @@ class LoadingPickServiceTest {
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
verify(settlementService).settle(20L, 9L, "operator");
verify(repository).syncInboundProjections(20L, 9L, "operator");
verify(loadingDeliveryService).requirePdaCompletionReady(101L, 50L);
verify(loadingDeliveryService).requirePdaCompletionReady(101L, 50L, 50L);
verify(loadingDeliveryService).requirePdaCompletionReady(101L, 60L, 50L);
verify(truckAppointmentService).completeLoadingFromPda(99L, 20L, 9L, "operator");
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator",
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 50L, 9L, "operator",
"PDA-complete-1-50", "checked");
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 60L, 50L, 9L, "operator",
"PDA-complete-1-60", "checked");
verify(loadingListService).publishCompletedEvent(10L);
InOrder finalization = inOrder(loadingListService, loadingDeliveryService);
finalization.verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
finalization.verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator",
finalization.verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 50L, 9L, "operator",
"PDA-complete-1-50", "checked");
finalization.verify(loadingListService).publishCompletedEvent(10L);
}
@@ -556,7 +562,7 @@ class LoadingPickServiceTest {
verify(settlementService).settle(20L, 9L, "operator");
verify(repository).approve(20L, 9L, "operator", "checked");
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator",
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 50L, 9L, "operator",
"PDA-complete-not-blocked-50", "checked");
verify(loadingListService).publishCompletedEvent(10L);
}
@@ -574,7 +580,7 @@ class LoadingPickServiceTest {
new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7));
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
"入库单并发修改"))
.when(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator",
.when(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 50L, 9L, "operator",
"PDA-complete-finalize-fails-50", "checked");
assertThatThrownBy(() -> service.complete(20L,
@@ -66,6 +66,9 @@ class PdaPickSettlementServiceTest {
verify(splits).cloneChildWithPurpose(eq(10001L), eq(31L), eq(31L), anyLong(),
eq(1002L), eq("ORDER-REMAINDER"), eq("IN-001-1"), eq(12),
eq(1), eq("REMAINDER"), eq(9L), eq("operator"));
verify(splits).transferRemainingCartonLabels(eq(10001L), eq(31L), anyLong(),
eq(1002L), eq("IN-001-1"), eq(8), eq(12));
verify(splits).normalizeLoadedCartonLabels(10001L, 31L, 8);
verify(splits).markSourceLoaded(10001L, 31L, 8, 9L, 3);
verify(allocations).consume(10001L, 31L, 1L, 8);
verify(warehouse).settle(eq("internal-token"), org.mockito.ArgumentMatchers.argThat(request ->
@@ -16,6 +16,7 @@ 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.repository.SmartLoadingRepository;
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.split.service.SplitPreloadService;
@@ -41,6 +42,7 @@ import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.catchThrowableOfType;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
@@ -240,7 +242,7 @@ class SmartLoadingServiceTest {
LoadingPlanEntity plan = plan("EDITING", 0);
when(repository.plan(1)).thenReturn(plan);
when(repository.booking(99L)).thenReturn(booking());
when(repository.candidate(100, 10, 20)).thenReturn(candidate(5));
when(repository.candidate(1, 100, 10, 20)).thenReturn(candidate(5));
when(repository.activeItem(1L, 9L, 20L)).thenReturn(item(7, 9, 20, 4));
BizException error = catchThrowableOfType(() -> service.assign(1,
@@ -257,7 +259,7 @@ class SmartLoadingServiceTest {
linked.setCustomerId(88L);
when(repository.plan(1L)).thenReturn(plan);
when(repository.booking(99L)).thenReturn(linked);
when(repository.candidate(100L, 10L, 20L)).thenReturn(candidate(5));
when(repository.candidate(1L, 100L, 10L, 20L)).thenReturn(candidate(5));
BizException error = catchThrowableOfType(() -> service.assign(1L,
new AssignRequest(10L, 20L, 1, 0, "assign-customer-mismatch"), 7L, "u"), BizException.class);
@@ -299,6 +301,9 @@ class SmartLoadingServiceTest {
@Test
void snapshotsCargoFields() {
stubAssignment();
InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setId(10L); inbound.setTenantId(9L); inbound.setTotalPackageCount(10); inbound.setPalletCount(4);
when(repository.inbound(9L, 10L)).thenReturn(inbound);
when(cargoClient.detail(anyString(), eq(9L), eq(30L), eq(20L))).thenReturn(ApiResponse.success(
new OrderCargoClient.CargoBinding(20L, 30L, 1, "中文品名", 10,
new BigDecimal("20"), new BigDecimal("2"), "SKU-1", "English name",
@@ -319,6 +324,7 @@ class SmartLoadingServiceTest {
assertThat(saved.getProductNameEn()).isEqualTo("English name");
assertThat(saved.getUnitsPerCarton()).isEqualTo(4);
assertThat(saved.getProductQuantity()).isEqualTo(20);
assertThat(saved.getPalletCount()).isEqualTo(2);
assertThat(saved.getNetWeightKg()).isEqualByComparingTo("7.5");
assertThat(saved.getCustomerRemark()).isEqualTo("客户备注");
assertThat(saved.getInternalRemark()).isEqualTo("最新内部备注");
@@ -351,8 +357,10 @@ class SmartLoadingServiceTest {
when(repository.booking(99)).thenReturn(booking());
when(repository.touch(1, 5, 7, "u")).thenReturn(1);
when(repository.items(1)).thenReturn(List.of());
when(repository.candidate(100, 10, 20)).thenReturn(candidate(10));
when(repository.candidate(1, 100, 10, 20)).thenReturn(candidate(10));
when(repository.activeItem(1L, 9L, 20L)).thenReturn(null);
when(loadingPickService.mutationSnapshot(1L)).thenReturn(
new LoadingPickService.MutationSnapshot(77L, "LOADING", 6, Map.of()));
when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(ApiResponse.success(
new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 1,
BigDecimal.ONE, BigDecimal.ONE, null, null, null, null, null, null)));
@@ -360,12 +368,16 @@ class SmartLoadingServiceTest {
Plan result = service.assign(1, new AssignRequest(10L, 20L, 1, 5, "assign-loading"), 7, "u");
assertThat(result.status()).isEqualTo("LOADING");
var order = inOrder(loadingPickService, repository, loadingModelBridge);
verify(repository).candidate(1L, 100L, 10L, 20L);
var order = inOrder(loadingPickService, splitPreloadService, repository, loadingModelBridge);
order.verify(loadingPickService).lockMutable(1L);
order.verify(splitPreloadService).ensureForRunningLoadingAllocation(9L, 10L, 7L, "u");
order.verify(repository).touch(1, 5, 7, "u");
order.verify(loadingModelBridge).bindIncrementalAllocation(1L, 9L, 10L,
7L, "u", "assign-loading");
order.verify(loadingPickService).sync(eq(1L), any(LoadingPlanItemEntity.class));
verify(splitPreloadService, never()).ensurePendingForLoadingAllocation(
anyLong(), anyLong(), anyLong(), anyString());
}
@Test
@@ -382,7 +394,7 @@ class SmartLoadingServiceTest {
Plan result = service.assign(1, request, 7, "u");
assertThat(result.status()).isEqualTo("LOADING");
verify(repository, never()).candidate(anyLong(), anyLong(), anyLong());
verify(repository, never()).candidate(anyLong(), anyLong(), anyLong(), anyLong());
verify(cargoClient, never()).detail(anyString(), anyLong(), anyLong(), anyLong());
}
@@ -581,7 +593,7 @@ class SmartLoadingServiceTest {
when(repository.plan(1L)).thenReturn(plan, plan("LOADING", 3));
when(repository.item(1L, 8L)).thenReturn(line);
when(repository.booking(99L)).thenReturn(booking());
when(repository.candidate(100L, 10L, 20L)).thenReturn(candidate(10));
when(repository.candidate(1L, 100L, 10L, 20L)).thenReturn(candidate(10));
when(repository.items(1L)).thenReturn(List.of(line));
when(repository.touch(1L, 2, 7L, "u")).thenReturn(1);
when(repository.update(line)).thenReturn(1);
@@ -593,6 +605,23 @@ class SmartLoadingServiceTest {
assertThat(updated.getValue().getWarehouseRemark()).isEqualTo("装柜时注意外箱");
}
@Test
void pickedQuantityConflictPrecedesCandidateAvailabilityValidation() {
LoadingPlanEntity plan = plan("LOADING", 2);
LoadingPlanItemEntity line = item(8, 9, 20, 2);
when(repository.plan(1L)).thenReturn(plan);
when(repository.item(1L, 8L)).thenReturn(line);
when(loadingPickService.mutationSnapshot(1L)).thenReturn(
new LoadingPickService.MutationSnapshot(77L, "LOADING", 6, Map.of(8L, 2)));
assertThatThrownBy(() -> service.adjust(1L, 8L,
new AdjustRequest(1, 2, 0, "adjust-picked"), 7L, "u"))
.hasMessageContaining("不能低于已拣货箱数");
verify(repository, never()).candidate(anyLong(), anyLong(), anyLong(), anyLong());
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString());
}
@Test
void preservesDownstreamCode() {
stubAssignment();
@@ -850,7 +879,7 @@ class SmartLoadingServiceTest {
when(repository.plan(1)).thenReturn(plan, plan("EDITING", 1));
when(repository.booking(99)).thenReturn(booking());
when(repository.items(1)).thenReturn(List.of());
when(repository.candidate(100, 10, 20)).thenReturn(candidate(10));
when(repository.candidate(1, 100, 10, 20)).thenReturn(candidate(10));
when(repository.activeItem(1L, 9L, 20L)).thenReturn(null);
when(repository.touch(1, 0, 7, "u")).thenReturn(1);
}
@@ -58,8 +58,8 @@ class TaillegRealProducerAcceptanceTest {
BOOKING, MARKER + "-BOOK", MARKER + "-CNTR", MARKER + "-CAB", 92L, "真实验收海外仓", "40HQ");
jdbc.update("INSERT IGNORE INTO fl_loading_plan(id,plan_no,container_booking_id,plan_status,created_by," +
"updated_by,version) VALUES(?,?,?,'LOADING',1,1,0)", PLAN, MARKER + "-PLAN", BOOKING);
seedInbound(FIRST_INBOUND, 990022221L, MARKER + "-FIRST-IN", "FBA_SEA", "LOADED", "ONT8");
seedInbound(DROPSHIP_INBOUND, 990022222L, MARKER + "-DROP-IN", "ONE_CLICK_DROPSHIP", "LOADED", "LAX9");
seedInbound(FIRST_INBOUND, 990022221L, MARKER + "-FIRST-IN", "FBA_SEA", "STANDARD", "LOADED", "ONT8");
seedInbound(DROPSHIP_INBOUND, 990022222L, MARKER + "-DROP-IN", "FBA_SEA", "ONE_CLICK_DROPSHIP", "LOADED", "LAX9");
seedItem(990022321L, FIRST_INBOUND, 990022421L, 990022221L, MARKER + "-FIRST-IN", "SKU-REAL-A", 3, "ONT8");
seedItem(990022322L, DROPSHIP_INBOUND, 990022422L, 990022222L, MARKER + "-DROP-IN", "SKU-REAL-D", 2, "LAX9");
for (int i = 1; i <= 3; i++) seedLabel(990022540L + i, FIRST_INBOUND, 990022221L, 990022421L,
@@ -86,13 +86,15 @@ class TaillegRealProducerAcceptanceTest {
id, 990022901L, planItemId, inboundId, detailId, packages, packages);
}
private void seedInbound(long id, long orderId, String code, String type, String status, String destination) {
private void seedInbound(long id, long orderId, String code, String businessType, String fulfillmentType,
String status, String destination) {
jdbc.update("INSERT IGNORE INTO fl_inbound_order(id,tenant_id,order_id,order_no,inbound_code,process_status," +
"order_status_snapshot,customer_name,warehouse_id,warehouse_name,business_type,destination_warehouse_id," +
"order_status_snapshot,customer_name,warehouse_id,warehouse_name,business_type,fulfillment_type,destination_type,destination_warehouse_id," +
"destination_code,destination_name,package_count,actual_volume_cbm,created_by,updated_by) " +
"VALUES(?,?,?,?,?,?, 'IN_TRANSIT',?,92,'真实验收海外仓',?,9001,?,?,?,0.4000,1,1)",
id, CUSTOMER, orderId, MARKER + "-ORDER-" + id, code, status, "真实验收客户", type,
destination, "FBA " + destination, type.equals("ONE_CLICK_DROPSHIP") ? 2 : 3);
"VALUES(?,?,?,?,?,?, 'IN_TRANSIT',?,92,'真实验收海外仓',?,?,'FBA',9001,?,?,?,0.4000,1,1)",
id, CUSTOMER, orderId, MARKER + "-ORDER-" + id, code, status, "真实验收客户", businessType,
fulfillmentType, destination, "FBA " + destination,
fulfillmentType.equals("ONE_CLICK_DROPSHIP") ? 2 : 3);
}
private void seedItem(long id, long inboundId, long detailId, long orderId, String inboundCode,
@@ -13,6 +13,7 @@ import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import java.util.Map;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
@@ -71,6 +72,20 @@ class UnifiedInboundQueryServiceTest {
assertThat(UnifiedInboundQueryService.requiresPhaseThreeSummary("DELIVERED")).isTrue();
}
@Test
void completedHistoricalInboundUsesConfirmedWeightCountWhenStoredPalletCountIsZero() throws Exception {
InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setId(88L);
inbound.setPalletCount(0);
var method = UnifiedInboundQueryService.class.getDeclaredMethod(
"applyConfirmedPalletCount", InboundOrderEntity.class, Map.class);
method.setAccessible(true);
method.invoke(null, inbound, Map.of(88L, 2));
assertThat(inbound.getPalletCount()).isEqualTo(2);
}
@ParameterizedTest
@ValueSource(strings = {"WAIT_MEASURE", "WAIT_SPLIT"})
void phaseTwoDetailDoesNotQueryPhaseThreeTables(String processStatus) {
@@ -80,4 +80,62 @@ class SplitPreloadServiceTest {
verify(repository, never()).createPlan(any());
}
@Test
void runningLoadingAllocationMayReuseConfirmedPreload() {
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 3));
PreloadPlanEntity plan = new PreloadPlanEntity();
plan.setStatus("CONFIRMED");
when(repository.findPlan(10, 20)).thenReturn(plan);
service.ensureForRunningLoadingAllocation(10, 20, 7, "admin");
verify(repository, never()).createPlan(any());
}
@Test
void runningLoadingAllocationMayReusePendingPreload() {
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 3));
PreloadPlanEntity plan = new PreloadPlanEntity();
plan.setStatus("PENDING");
when(repository.findPlan(10, 20)).thenReturn(plan);
service.ensureForRunningLoadingAllocation(10, 20, 7, "admin");
verify(repository, never()).createPlan(any());
}
@Test
void runningLoadingAllocationMustNotCreateMissingPreload() {
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 3));
when(repository.findPlan(10, 20)).thenReturn(null);
assertThatThrownBy(() -> service.ensureForRunningLoadingAllocation(10, 20, 7, "admin"))
.hasMessageContaining("缺少既有预装计划");
verify(repository, never()).createPlan(any());
}
@Test
void ordinaryLoadingAllocationStillRequiresWaitingSplit() {
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 3));
PreloadPlanEntity plan = new PreloadPlanEntity();
plan.setStatus("PENDING");
when(repository.findPlan(10, 20)).thenReturn(plan);
assertThatThrownBy(() -> service.ensurePendingForLoadingAllocation(10, 20, 7, "admin"))
.hasMessageContaining("仅待分柜订单");
verify(repository, never()).createPlan(any());
}
@Test
void runningLoadingAllocationStillRejectsConfirmingPreload() {
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "LOADING", 3));
PreloadPlanEntity plan = new PreloadPlanEntity();
plan.setStatus("CONFIRMING");
when(repository.findPlan(10, 20)).thenReturn(plan);
assertThatThrownBy(() -> service.ensureForRunningLoadingAllocation(10, 20, 7, "admin"))
.hasMessageContaining("已进入确认流程");
}
}
@@ -0,0 +1,64 @@
{
"schemaVersion": 1,
"description": "Development-stage Firstleg slice dependency baseline. Existing edges may be removed; new edges require architecture review.",
"allowedSliceEdges": [
"booking-customs-trucking->inbound",
"booking-customs-trucking->measurement-pricing",
"booking-customs-trucking->outbound",
"booking-customs-trucking->shared-technical",
"booking-customs-trucking->split-loading",
"customer-statistics->shared-technical",
"finance-integration->inbound",
"finance-integration->measurement-pricing",
"finance-integration->shared-technical",
"finance-integration->split-loading",
"inbound->measurement-pricing",
"inbound->outbound",
"inbound->shared-technical",
"inbound->split-loading",
"measurement-pricing->booking-customs-trucking",
"measurement-pricing->finance-integration",
"measurement-pricing->inbound",
"measurement-pricing->shared-technical",
"outbound->booking-customs-trucking",
"outbound->shared-technical",
"outbound->shipping-nodes",
"outbound->split-loading",
"shared-technical->booking-customs-trucking",
"shared-technical->finance-integration",
"shared-technical->inbound",
"shared-technical->measurement-pricing",
"shared-technical->split-loading",
"shipping-nodes->booking-customs-trucking",
"shipping-nodes->inbound",
"shipping-nodes->shared-technical",
"shipping-nodes->split-loading",
"split-loading->booking-customs-trucking",
"split-loading->inbound",
"split-loading->measurement-pricing",
"split-loading->outbound",
"split-loading->shared-technical"
],
"requiredClassLocations": {
"com.baiyue.firstleg.customerstatistics.adapter.in.web.PlatformCustomerTransitStatisticsController": "src/main/java/com/baiyue/firstleg/customerstatistics/adapter/in/web/PlatformCustomerTransitStatisticsController.java",
"com.baiyue.firstleg.customerstatistics.adapter.out.persistence.CustomerTransitStatisticsMapper": "src/main/java/com/baiyue/firstleg/customerstatistics/adapter/out/persistence/CustomerTransitStatisticsMapper.java",
"com.baiyue.firstleg.customerstatistics.application.CustomerTransitStatisticsService": "src/main/java/com/baiyue/firstleg/customerstatistics/application/CustomerTransitStatisticsService.java",
"com.baiyue.firstleg.customerstatistics.application.model.CustomerTransitStatisticsDtos": "src/main/java/com/baiyue/firstleg/customerstatistics/application/model/CustomerTransitStatisticsDtos.java",
"com.baiyue.firstleg.customerstatistics.application.port.out.CustomerTransitStatisticsReader": "src/main/java/com/baiyue/firstleg/customerstatistics/application/port/out/CustomerTransitStatisticsReader.java"
},
"applicationDependencyRule": {
"packagePrefix": "com.baiyue.firstleg.customerstatistics.application",
"allowedInternalPrefix": "com.baiyue.firstleg.customerstatistics.application",
"forbiddenImportPrefixes": [
"org.springframework.web",
"org.apache.ibatis",
"com.baomidou.mybatisplus"
]
},
"sliceSpecificRules": {
"customer-statistics": {
"allowedOutgoing": ["shared-technical"],
"allowedIncoming": []
}
}
}