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:
@@ -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
|
||||
|
||||
+7
-1
@@ -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, "草稿预入库快照不完整");
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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;
|
||||
+5
-9
@@ -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);
|
||||
}
|
||||
+5
-5
@@ -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
-1
@@ -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;
|
||||
+11
@@ -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) {}
|
||||
}
|
||||
+7
-6
@@ -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;
|
||||
|
||||
+21
-5
@@ -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);
|
||||
|
||||
+37
-6
@@ -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);
|
||||
|
||||
+30
-10
@@ -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);
|
||||
}
|
||||
|
||||
+20
@@ -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);
|
||||
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -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();
|
||||
}
|
||||
|
||||
+4
-4
@@ -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<=#{now}
|
||||
AND (lease_until IS NULL OR lease_until<#{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<=#{now}
|
||||
AND (lease_until IS NULL OR lease_until<#{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);
|
||||
|
||||
+16
@@ -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;
|
||||
}
|
||||
|
||||
+75
@@ -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); }
|
||||
}
|
||||
+18
-16
@@ -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());
|
||||
|
||||
+41
-8
@@ -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) {
|
||||
|
||||
+2
-1
@@ -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));
|
||||
}
|
||||
|
||||
+9
-4
@@ -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();
|
||||
|
||||
+33
-6
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+74
-24
@@ -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;
|
||||
|
||||
@@ -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.}
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+32
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -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")
|
||||
|
||||
+80
@@ -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);
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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;
|
||||
|
||||
+5
-5
@@ -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() {
|
||||
|
||||
+9
-8
@@ -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");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+22
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+19
@@ -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());
|
||||
|
||||
+1
@@ -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");
|
||||
}
|
||||
|
||||
+45
@@ -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("<");
|
||||
assertThat(claimLease)
|
||||
.contains("next_allowed_at <= #{now}")
|
||||
.contains("lease_until < #{now}")
|
||||
.doesNotContain("<");
|
||||
}
|
||||
|
||||
@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);
|
||||
|
||||
+79
@@ -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;
|
||||
}
|
||||
}
|
||||
+39
@@ -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;
|
||||
}
|
||||
|
||||
+66
@@ -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);
|
||||
|
||||
+40
@@ -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"})
|
||||
|
||||
+38
@@ -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");
|
||||
}
|
||||
}
|
||||
+3
@@ -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");
|
||||
}
|
||||
|
||||
+36
-1
@@ -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
|
||||
|
||||
+47
@@ -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",
|
||||
|
||||
+13
-3
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+29
-2
@@ -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
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
+8
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+24
@@ -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);
|
||||
|
||||
+40
-1
@@ -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 {
|
||||
|
||||
+81
-11
@@ -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);
|
||||
}
|
||||
|
||||
+9
-7
@@ -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,
|
||||
|
||||
+15
@@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user