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