Merge remote-tracking branch 'origin/test' into dev

# Conflicts:
#	deploy/maven/settings-cn.xml
This commit is contained in:
2026-09-03 15:23:55 +08:00
28 changed files with 867 additions and 156 deletions
@@ -1,6 +1,6 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.SmartLoadingDtos.*; import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.SmartLoadingService;
import jakarta.validation.Valid; import jakarta.validation.constraints.Positive; import jakarta.annotation.Resource; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.util.List;
@@ -13,7 +13,9 @@ public class PlatformSmartLoadingController {
@GetMapping("/candidates") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
public ApiResponse<List<Candidate>> candidates(@AuthenticationPrincipal AuthenticatedUser u,@RequestParam @Positive long bookingId,@RequestParam @Positive long warehouseId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.candidates(bookingId,warehouseId));}
@GetMapping("/inbound-candidates") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
public ApiResponse<List<InboundCandidate>> inboundCandidates(@AuthenticationPrincipal AuthenticatedUser u,@RequestParam @Positive long bookingId,@RequestParam @Positive long warehouseId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.inboundCandidates(bookingId,warehouseId));}
public ApiResponse<PageResult<InboundCandidate>> inboundCandidates(@AuthenticationPrincipal AuthenticatedUser u,@Valid InboundCandidateQuery q){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.inboundCandidates(q));}
@GetMapping("/containers") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
public ApiResponse<PageResult<WorkbenchContainer>> containers(@AuthenticationPrincipal AuthenticatedUser u,@Valid WorkbenchContainerQuery q){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.containers(q));}
@GetMapping("/plans/by-booking/{bookingId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
public ApiResponse<Plan> draftByBooking(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long bookingId){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.draftByBooking(bookingId));}
@GetMapping("/plans/{planId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
@@ -1,15 +1,48 @@
package com.baiyue.firstleg.dto;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.List;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List;
public final class SmartLoadingDtos { private SmartLoadingDtos(){}
public record ChannelSummary(String channelCode, String channelName,
long candidateInboundCount, long availablePackageCount, long availableContainerCount) {}
public enum CandidateOrderStatus { PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING }
public record InboundCandidateQuery(
@NotEmpty @Size(max=9) List<@NotBlank String> channelGroupCodes,
@Positive Long warehouseId, @Positive Long customerId, @Size(max=40) String inboundCode,
LocalDate expectedArrivalStart, LocalDate expectedArrivalEnd, CandidateOrderStatus orderStatus,
@Min(1) long page, @Min(1) @Max(200) long size) {
public InboundCandidateQuery {
channelGroupCodes = channelGroupCodes == null ? List.of() : List.copyOf(channelGroupCodes);
if (page == 0) page = 1;
if (size == 0) size = 20;
}
}
public record GroupMemberCandidate(Long accountId, Long inboundId, Long orderId, String orderNo,
String inboundCode, String customerName, Integer availablePackageCount,
String orderStatusSnapshot, LocalDateTime expectedArrivalAt) {}
public record InboundCandidate(Long accountId, Long inboundId, Long orderId, String orderNo, String inboundCode,
Long warehouseId, String channelCode, String channelName, Long destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsSummary,
Integer availablePackageCount, BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg,
BigDecimal averageUnitVolumeCbm, BigDecimal availableVolumeCbm,
Long coLoadGroupId, String groupNo, Integer memberCount, Integer groupTotalBoxes,
Boolean locked, String blockReason, String orderStatusSnapshot, LocalDateTime expectedArrivalAt) {}
Boolean locked, String blockReason, String orderStatusSnapshot, LocalDateTime expectedArrivalAt,
List<GroupMemberCandidate> groupMembers) {
public InboundCandidate { groupMembers = groupMembers == null ? List.of() : List.copyOf(groupMembers); }
public Long customerId() { return accountId; }
}
public record WorkbenchContainerQuery(
@NotEmpty @Size(max=9) List<@NotBlank String> channelGroupCodes,
@Positive Long warehouseId, @Positive Long customerId, @Size(max=40) String inboundCode,
LocalDate expectedArrivalStart, LocalDate expectedArrivalEnd, CandidateOrderStatus orderStatus,
@Min(1) long page, @Min(1) @Max(200) long size) {
public WorkbenchContainerQuery {
channelGroupCodes = channelGroupCodes == null ? List.of() : List.copyOf(channelGroupCodes);
if (page == 0) page = 1;
if (size == 0) size = 20;
}
}
public record WorkbenchContainer(Long bookingId, String bookingNo, String cabinetNo, String containerNo,
Long warehouseId, String warehouseName, String channelCode, String channelName, String containerType,
Long planId, String planNo, String planStatus, Integer version) {}
public record Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode,
Long warehouseId, String channelCode, String channelName, Long destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String customerName, String goodsName, Integer availablePackageCount,
BigDecimal unitGrossWeightKg, BigDecimal unitNetWeightKg, BigDecimal unitVolumeCbm,
@@ -10,7 +10,7 @@ import lombok.Setter;
import java.math.BigDecimal;
/** 客户固定箱规的内径和每侧加宽配置。 */
/** 客户固定箱规的内径和长、宽、高尺寸增加值配置。 */
@Getter
@Setter
@TableName("fl_customer_dimension_config")
@@ -1,5 +1,5 @@
package com.baiyue.firstleg.mapper;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.ibatis.annotations.*; import java.util.List;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainer; import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainerQuery; import org.apache.ibatis.annotations.*; import java.util.List; import java.util.Set;
@Mapper public interface SmartLoadingCandidateMapper {
@Select("""
SELECT b.channel_code channelCode,COUNT(DISTINCT b.id) availableContainerCount
@@ -83,6 +83,95 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba
""")
List<Candidate> candidates(@Param("warehouseId") long warehouseId);
@Select("""
SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId,
o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode,
o.warehouse_id warehouseId,o.channel_code channelCode,o.channel_name channelName,o.destination_warehouse_id destinationWarehouseId,
o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName,
o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName,
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)
-COALESCE((SELECT SUM(split_item.package_count) FROM fl_inbound_split_item split_item
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_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,
NULL coLoadGroupId,NULL groupNo,NULL memberCount,NULL groupTotalBoxes,
NULL locked,NULL blockReason,o.order_status_snapshot orderStatusSnapshot,o.expected_arrival_at expectedArrivalAt
FROM fl_inbound_order o
JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id
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 (
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 (#{warehouseId} IS NULL OR 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.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)
+COALESCE((SELECT SUM(split_item.package_count) FROM fl_inbound_split_item split_item
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_inbound_detail_id=i.inbound_detail_id),0)
ORDER BY o.inbounded_at ASC, o.id ASC, i.inbound_detail_id ASC
""")
List<Candidate> globalCandidates(@Param("warehouseId") Long warehouseId);
@Select("""
<script>
SELECT b.id bookingId,b.booking_no bookingNo,b.cabinet_no cabinetNo,b.container_no containerNo,
b.warehouse_id warehouseId,b.warehouse_name warehouseName,b.channel_code channelCode,
b.channel_name channelName,b.container_type containerType,p.id planId,p.plan_no planNo,
CASE WHEN p.id IS NULL OR NOT EXISTS (SELECT 1 FROM fl_loading_plan_item state_item
WHERE state_item.loading_plan_id=p.id AND state_item.deleted=0)
THEN 'WAIT_LOADING' ELSE p.plan_status END planStatus,b.version version
FROM fl_container_booking b
LEFT JOIN fl_loading_plan p ON p.container_booking_id=b.id
WHERE b.record_status='ACTIVE'
AND (p.id IS NULL OR p.plan_status IN ('EDITING','LOADING'))
AND b.channel_code IN
<foreach collection="actualChannels" item="channel" open="(" separator="," close=")">#{channel}</foreach>
<if test="query.warehouseId != null">AND b.warehouse_id=#{query.warehouseId}</if>
<if test="query.customerId != null or query.inboundCode != null or query.expectedArrivalStart != null or query.expectedArrivalEnd != null or query.orderStatus != null">
AND EXISTS (
SELECT 1 FROM fl_loading_plan_item filter_item
JOIN fl_inbound_order filter_inbound ON filter_inbound.tenant_id=filter_item.account_id
AND filter_inbound.id=filter_item.inbound_id
WHERE filter_item.loading_plan_id=p.id AND filter_item.deleted=0
<if test="query.customerId != null">AND filter_item.account_id=#{query.customerId}</if>
<if test="query.inboundCode != null and query.inboundCode != ''">AND filter_item.inbound_code LIKE CONCAT('%',#{query.inboundCode},'%')</if>
<if test="query.expectedArrivalStart != null">AND filter_inbound.expected_arrival_at &gt;= #{query.expectedArrivalStart}</if>
<if test="query.expectedArrivalEnd != null">AND filter_inbound.expected_arrival_at &lt; DATE_ADD(#{query.expectedArrivalEnd},INTERVAL 1 DAY)</if>
<if test="orderStatus != null">
<choose>
<when test="orderStatus == 'PENDING_REVIEW'">AND filter_inbound.order_status_snapshot IN ('SUBMITTED','PENDING_REVIEW')</when>
<when test="orderStatus == 'WAIT_MEASURE'">AND filter_inbound.process_status IN ('WAIT_MEASURE','MEASURING','MEASURE_PENDING','MEASURE_REJECTED','WAIT_PRICING','FEE_CONFIRMING','INBOUNDING')</when>
<when test="orderStatus == 'WAIT_SPLIT'">AND filter_inbound.process_status='WAIT_SPLIT'</when>
<when test="orderStatus == 'LOADING'">AND filter_inbound.process_status='LOADING'</when>
</choose>
</if>
)
</if>
ORDER BY b.updated_at DESC,b.id DESC
</script>
""")
Page<WorkbenchContainer> workbenchContainers(Page<WorkbenchContainer> page,
@Param("query") WorkbenchContainerQuery query,
@Param("actualChannels") Set<String> actualChannels,
@Param("orderStatus") String orderStatus);
@Select("""
SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId,
o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode,
@@ -2,6 +2,10 @@ package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate;
import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainer;
import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainerQuery;
import com.baiyue.common.core.api.PageResult;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity;
@@ -19,6 +23,7 @@ import jakarta.annotation.Resource;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import java.util.List;
import java.util.Set;
/** 智能装柜聚合的真实 MySQL 持久化边界。 */
@Repository
@@ -32,6 +37,13 @@ public class SmartLoadingRepository {
@Resource private InboundOrderMapper inbounds;
public List<Candidate> candidates(long warehouseId) { return candidates.candidates(warehouseId); }
public List<Candidate> globalCandidates(Long warehouseId) { return candidates.globalCandidates(warehouseId); }
public PageResult<WorkbenchContainer> workbenchContainers(WorkbenchContainerQuery query,
Set<String> actualChannels) {
return PageResult.from(candidates.workbenchContainers(
new Page<>(query.page(), query.size()), query, actualChannels,
query.orderStatus() == null ? null : query.orderStatus().name()));
}
public List<ChannelContainerCountRow> channelContainerCounts(long warehouseId) {
return candidates.channelContainerCounts(warehouseId);
}
@@ -27,7 +27,6 @@ import java.util.Map;
public class CustomerDimensionConfigService {
public static final BigDecimal DEFAULT_EDGE_ADDITION_CM = new BigDecimal("2.000");
public static final String DEFAULT_CALCULATION_MODE = "ADD";
private static final BigDecimal TWO = new BigDecimal("2");
private static final BigDecimal CUBIC_CENTIMETERS_PER_CBM = new BigDecimal("1000000");
@Resource
@@ -116,7 +115,7 @@ public class CustomerDimensionConfigService {
requirePositive(rule.innerLengthCm(), "箱规内长");
requirePositive(rule.innerWidthCm(), "箱规内宽");
requirePositive(rule.innerHeightCm(), "箱规内高");
requirePositive(rule.edgeAdditionCm(), "箱规单侧加宽");
requirePositive(rule.edgeAdditionCm(), "箱规尺寸增加值");
if (rule.version() == null || rule.version() < 0) {
throw new BizException(ResultCode.BAD_REQUEST, "箱规版本号无效");
}
@@ -223,10 +222,10 @@ public class CustomerDimensionConfigService {
}
private static RuleResponse ruleResponse(CustomerDimensionConfigEntity value) {
BigDecimal edgePair = value.getEdgeAdditionCm().multiply(TWO);
BigDecimal outerLength = value.getInnerLengthCm().add(edgePair);
BigDecimal outerWidth = value.getInnerWidthCm().add(edgePair);
BigDecimal outerHeight = value.getInnerHeightCm().add(edgePair);
BigDecimal addition = value.getEdgeAdditionCm();
BigDecimal outerLength = value.getInnerLengthCm().add(addition);
BigDecimal outerWidth = value.getInnerWidthCm().add(addition);
BigDecimal outerHeight = value.getInnerHeightCm().add(addition);
BigDecimal volume = outerLength.multiply(outerWidth).multiply(outerHeight)
.divide(CUBIC_CENTIMETERS_PER_CBM)
.setScale(3, RoundingMode.HALF_UP);
@@ -13,10 +13,9 @@ import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/** Resolves measured carton dimensions to the customer's smallest containing dimension rule. */
/** Resolves dimensions to the smallest containing rule, capped at the largest configured tier. */
@Service
public class CustomerDimensionWeightingService {
private static final BigDecimal TWO = new BigDecimal("2");
private static final BigDecimal CUBIC_CENTIMETERS_PER_CBM = new BigDecimal("1000000");
private final CustomerDimensionConfigRepository repository;
@@ -62,16 +61,8 @@ public class CustomerDimensionWeightingService {
.filter(rule -> rule.contains(measured))
.findFirst()
.map(Rule::weighted)
.orElseGet(() -> actualDimensions(lengthCm, widthCm, heightCm));
}
private WeightedDimensions actualDimensions(BigDecimal lengthCm, BigDecimal widthCm,
BigDecimal heightCm) {
BigDecimal rawVolume = lengthCm.multiply(widthCm).multiply(heightCm)
.divide(CUBIC_CENTIMETERS_PER_CBM, 6, RoundingMode.HALF_UP);
return new WeightedDimensions(null, null, null, lengthCm, widthCm, heightCm,
rawVolume, rawVolume.setScale(3, RoundingMode.HALF_UP),
"ACTUAL_DIMENSION_V1");
.orElseGet(() -> rules.get(rules.size() - 1)
.weightedActualDimensions(lengthCm, widthCm, heightCm));
}
}
@@ -113,10 +104,21 @@ public class CustomerDimensionWeightingService {
}
private WeightedDimensions weighted() {
BigDecimal edgePair = edgeAdditionCm.multiply(TWO);
BigDecimal outerLength = innerLengthCm.add(edgePair);
BigDecimal outerWidth = innerWidthCm.add(edgePair);
BigDecimal outerHeight = innerHeightCm.add(edgePair);
BigDecimal outerLength = innerLengthCm.add(edgeAdditionCm);
BigDecimal outerWidth = innerWidthCm.add(edgeAdditionCm);
BigDecimal outerHeight = innerHeightCm.add(edgeAdditionCm);
BigDecimal rawVolume = outerLength.multiply(outerWidth).multiply(outerHeight)
.divide(CUBIC_CENTIMETERS_PER_CBM, 6, RoundingMode.HALF_UP);
BigDecimal unitVolume = rawVolume.setScale(3, RoundingMode.HALF_UP);
return new WeightedDimensions(boxType, version, edgeAdditionCm, outerLength, outerWidth, outerHeight,
rawVolume, unitVolume, "CUSTOMER_DIMENSION_" + boxType.name() + "_V" + version);
}
private WeightedDimensions weightedActualDimensions(BigDecimal lengthCm, BigDecimal widthCm,
BigDecimal heightCm) {
BigDecimal outerLength = lengthCm.add(edgeAdditionCm);
BigDecimal outerWidth = widthCm.add(edgeAdditionCm);
BigDecimal outerHeight = heightCm.add(edgeAdditionCm);
BigDecimal rawVolume = outerLength.multiply(outerWidth).multiply(outerHeight)
.divide(CUBIC_CENTIMETERS_PER_CBM, 6, RoundingMode.HALF_UP);
BigDecimal unitVolume = rawVolume.setScale(3, RoundingMode.HALF_UP);
@@ -183,8 +183,20 @@ public class MeasurementBatchService {
if (!itemNumbers.add(source.itemNo())) {
throw new BizException(ResultCode.VALIDATION_FAILED, "测量明细号不能重复");
}
if ((source.outerLengthCm() != null && source.outerLengthCm().signum() <= 0)
|| (source.outerWidthCm() != null && source.outerWidthCm().signum() <= 0)
|| (source.outerHeightCm() != null && source.outerHeightCm().signum() <= 0)) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"仓库修改外箱尺寸必须大于0,明细 " + source.itemNo());
}
boolean hasOuterOverride = source.outerLengthCm() != null
|| source.outerWidthCm() != null
|| source.outerHeightCm() != null;
BigDecimal baseLength = source.outerLengthCm() != null ? source.outerLengthCm() : source.lengthCm();
BigDecimal baseWidth = source.outerWidthCm() != null ? source.outerWidthCm() : source.widthCm();
BigDecimal baseHeight = source.outerHeightCm() != null ? source.outerHeightCm() : source.heightCm();
CustomerDimensionWeightingService.WeightedDimensions weighted = dimensionRules.resolve(
source.lengthCm(), source.widthCm(), source.heightCm());
baseLength, baseWidth, baseHeight);
String requestedMode = normalizeMode(source.coefficientMode());
CustomerChargeRuleEntity coefficientRule = "MULTIPLY".equals(requestedMode)
? requireCoefficientRule(command.tenantId(), customerId, source.chargeRuleId())
@@ -195,32 +207,16 @@ public class MeasurementBatchService {
boolean hasMatchedAddition = useDimensionRule && weighted.boxType() != null;
// ADD 只从匹配箱规取得加边值,外尺寸仍以本次实测尺寸为基准,
// 不能把箱规边界尺寸当成本次货物的实际外尺寸。
BigDecimal outerLength = hasMatchedAddition ? source.lengthCm().add(addition) : source.lengthCm();
BigDecimal outerWidth = hasMatchedAddition ? source.widthCm().add(addition) : source.widthCm();
BigDecimal outerHeight = hasMatchedAddition ? source.heightCm().add(addition) : source.heightCm();
boolean hasOuterOverride = source.outerLengthCm() != null
|| source.outerWidthCm() != null
|| source.outerHeightCm() != null;
if ((source.outerLengthCm() != null && source.outerLengthCm().signum() <= 0)
|| (source.outerWidthCm() != null && source.outerWidthCm().signum() <= 0)
|| (source.outerHeightCm() != null && source.outerHeightCm().signum() <= 0)) {
throw new BizException(ResultCode.VALIDATION_FAILED,
"仓库修改外箱尺寸必须大于0,明细 " + source.itemNo());
}
BigDecimal outerLength = hasMatchedAddition ? baseLength.add(addition) : baseLength;
BigDecimal outerWidth = hasMatchedAddition ? baseWidth.add(addition) : baseWidth;
BigDecimal outerHeight = hasMatchedAddition ? baseHeight.add(addition) : baseHeight;
if (coefficientRule != null) {
BigDecimal specificationCoefficient = resolveSpecificationCoefficient(
coefficientRule, source.lengthCm(), source.widthCm(), source.heightCm());
coefficientRule, baseLength, baseWidth, baseHeight);
outerLength = applyCoefficient(outerLength, specificationCoefficient);
outerWidth = applyCoefficient(outerWidth, specificationCoefficient);
outerHeight = applyCoefficient(outerHeight, specificationCoefficient);
}
if (hasOuterOverride) {
// Each warehouse-confirmed dimension is authoritative on its own.
// Unspecified dimensions keep their strategy-calculated values.
if (source.outerLengthCm() != null) outerLength = source.outerLengthCm();
if (source.outerWidthCm() != null) outerWidth = source.outerWidthCm();
if (source.outerHeightCm() != null) outerHeight = source.outerHeightCm();
}
BigDecimal unitVolumeRaw = outerLength.multiply(outerWidth).multiply(outerHeight)
.divide(new BigDecimal("1000000"), 6, RoundingMode.HALF_UP);
BigDecimal unitVolume = hasMatchedAddition && !hasOuterOverride
@@ -262,9 +258,13 @@ public class MeasurementBatchService {
: useDimensionRule ? weighted.calculationRuleVersion() : "ACTUAL_DIMENSION_V1");
item.setSourceImageFileId(source.intelligentImageFileId());
item.setOverLimitWarning(source.overLimitWarning());
item.setOuterLengthCm(outerLength.setScale(3, RoundingMode.HALF_UP));
item.setOuterWidthCm(outerWidth.setScale(3, RoundingMode.HALF_UP));
item.setOuterHeightCm(outerHeight.setScale(3, RoundingMode.HALF_UP));
// Override fields retain the operator's base values so repeated saves do not compound the strategy.
item.setOuterLengthCm((source.outerLengthCm() != null ? source.outerLengthCm() : outerLength)
.setScale(3, RoundingMode.HALF_UP));
item.setOuterWidthCm((source.outerWidthCm() != null ? source.outerWidthCm() : outerWidth)
.setScale(3, RoundingMode.HALF_UP));
item.setOuterHeightCm((source.outerHeightCm() != null ? source.outerHeightCm() : outerHeight)
.setScale(3, RoundingMode.HALF_UP));
item.setRemark(source.remark());
item.setCreatedAt(LocalDateTime.now());
return item;
@@ -2,6 +2,7 @@ package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.firstleg.dto.SmartLoadingDtos.*;
@@ -38,6 +39,17 @@ import java.util.*;
@Service
public class SmartLoadingService {
private static final BigDecimal CAPACITY_LIMIT_RATIO = new BigDecimal("1.10");
private static final Map<String, Set<String>> WORKBENCH_CHANNEL_GROUPS = Map.of(
"MATSON_CLX", Set.of("MATSON_CLX_TRUCK", "MATSON_CLX_PARCEL"),
"MATSON_MAX", Set.of("MATSON_MAX_TRUCK", "MATSON_MAX_PARCEL"),
"EXX", Set.of("EXX_TRUCK", "EXX_RAIL_NY"),
"HEDE", Set.of("HEDE_TRUCK", "HEDE_PARCEL"),
"OA_LA", Set.of("OA_LA_TRUCK", "OA_LA_FULL_TRUCK", "OA_PARCEL"),
"OA_CHICAGO", Set.of("OA_CHICAGO_RAIL"),
"OA_HOUSTON", Set.of("OA_HOUSTON_TRUCK"),
"OA_SAVANNAH", Set.of("OA_SAVANNAH_TRUCK"),
"OA_NEW_YORK_TRUCK", Set.of("OA_NEW_YORK_TRUCK")
);
@Resource private SmartLoadingRepository repository;
@Resource private OrderCargoClient orderCargoClient;
@Resource private LoadingPickService loadingPickService;
@@ -96,6 +108,57 @@ public class SmartLoadingService {
return grouped.values().stream().map(this::inboundCandidate).toList();
}
/** Global workbench candidates. A co-load group is one paging unit and can never be split across pages. */
public PageResult<InboundCandidate> inboundCandidates(InboundCandidateQuery query) {
if (query.expectedArrivalStart() != null && query.expectedArrivalEnd() != null
&& query.expectedArrivalStart().isAfter(query.expectedArrivalEnd())) {
throw validation("预计到货开始日期不能晚于结束日期");
}
Set<String> actualChannels = actualChannels(query.channelGroupCodes());
List<InboundCandidate> all = repository.globalCandidates(query.warehouseId()).stream()
.collect(java.util.stream.Collectors.groupingBy(Candidate::inboundId, LinkedHashMap::new,
java.util.stream.Collectors.toList()))
.values().stream().map(this::inboundCandidate).toList();
Map<String, GroupSnapshot> snapshots = new HashMap<>();
Map<Long, List<InboundCandidate>> grouped = new LinkedHashMap<>();
List<InboundCandidate> standalone = new ArrayList<>();
for (InboundCandidate candidate : all) {
GroupSnapshot group = snapshots.computeIfAbsent(candidate.accountId() + "|" + candidate.inboundId(),
ignored -> group(candidate.accountId(), candidate.inboundId()));
if (group == null) {
standalone.add(candidate);
} else {
grouped.computeIfAbsent(group.groupId(), ignored -> new ArrayList<>()).add(candidate);
}
}
List<InboundCandidate> units = new ArrayList<>(standalone);
for (List<InboundCandidate> members : grouped.values()) {
GroupSnapshot group = snapshots.get(members.get(0).accountId() + "|" + members.get(0).inboundId());
InboundCandidate aggregate = globalGroupCandidate(group, members);
if (aggregate != null) units.add(aggregate);
}
units = units.stream().filter(candidate -> matches(query, actualChannels, candidate))
.sorted(Comparator.comparing(InboundCandidate::expectedArrivalAt,
Comparator.nullsLast(Comparator.naturalOrder()))
.thenComparing(InboundCandidate::inboundId, Comparator.nullsLast(Comparator.naturalOrder())))
.toList();
long total = units.size();
long offset = query.page() - 1 > Long.MAX_VALUE / query.size()
? Long.MAX_VALUE : (query.page() - 1) * query.size();
long from = Math.min(total, offset);
long to = Math.min(total, from + query.size());
long pages = total == 0 ? 0 : (total + query.size() - 1) / query.size();
return new PageResult<>(units.subList((int) from, (int) to), query.page(), query.size(), total, pages);
}
public PageResult<WorkbenchContainer> containers(WorkbenchContainerQuery query) {
if (query.expectedArrivalStart() != null && query.expectedArrivalEnd() != null
&& query.expectedArrivalStart().isAfter(query.expectedArrivalEnd())) {
throw validation("预计到货开始日期不能晚于结束日期");
}
return repository.workbenchContainers(query, actualChannels(query.channelGroupCodes()));
}
private InboundCandidate inboundCandidate(List<Candidate> details) {
Candidate first = details.get(0);
int packages = details.stream().map(Candidate::availablePackageCount)
@@ -114,7 +177,93 @@ public class SmartLoadingService {
first.customerName(), goods, packages, first.unitGrossWeightKg(), first.unitNetWeightKg(),
averageVolume, volume, first.coLoadGroupId(), first.groupNo(), first.memberCount(),
first.groupTotalBoxes(), first.locked(), first.blockReason(), first.orderStatusSnapshot(),
first.expectedArrivalAt());
first.expectedArrivalAt(), List.of());
}
private InboundCandidate globalGroupCandidate(GroupSnapshot group, List<InboundCandidate> availableMembers) {
if (group == null || !"ACTIVE".equals(group.status()) || group.lockedPlanId() != null) return null;
Map<Long, InboundCandidate> byInbound = availableMembers.stream()
.collect(java.util.stream.Collectors.toMap(InboundCandidate::inboundId, value -> value, (a, b) -> a));
for (Member member : group.members()) {
InboundCandidate candidate = byInbound.get(member.inboundId());
if (candidate == null || !Objects.equals(candidate.accountId(), member.tenantId())
|| !Objects.equals(candidate.availablePackageCount(), member.fullPackageCount())) return null;
}
List<InboundCandidate> ordered = group.members().stream().map(member -> byInbound.get(member.inboundId())).toList();
InboundCandidate first = ordered.get(0);
int packages = ordered.stream().map(InboundCandidate::availablePackageCount).mapToInt(Integer::intValue).sum();
BigDecimal volume = ordered.stream().map(InboundCandidate::availableVolumeCbm)
.filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add).setScale(4, RoundingMode.HALF_UP);
BigDecimal gross = weightedAverage(ordered, InboundCandidate::unitGrossWeightKg, packages);
BigDecimal net = weightedAverage(ordered, InboundCandidate::unitNetWeightKg, packages);
BigDecimal averageVolume = packages == 0 ? BigDecimal.ZERO
: volume.divide(BigDecimal.valueOf(packages), 6, RoundingMode.HALF_UP);
List<GroupMemberCandidate> groupMembers = ordered.stream().map(member -> new GroupMemberCandidate(
member.accountId(), member.inboundId(), member.orderId(), member.orderNo(), member.inboundCode(),
member.customerName(), member.availablePackageCount(), member.orderStatusSnapshot(),
member.expectedArrivalAt())).toList();
String customers = ordered.stream().map(InboundCandidate::customerName).filter(StringUtils::hasText)
.distinct().collect(java.util.stream.Collectors.joining("、"));
String goods = ordered.stream().map(InboundCandidate::goodsSummary).filter(StringUtils::hasText)
.distinct().collect(java.util.stream.Collectors.joining("、"));
return new InboundCandidate(first.accountId(), first.inboundId(), first.orderId(), first.orderNo(),
first.inboundCode(), first.warehouseId(), first.channelCode(), first.channelName(),
first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(),
customers, goods, packages, gross, net, averageVolume, volume, group.groupId(), group.groupNo(),
group.members().size(), group.fullPackageCount(), false, null, first.orderStatusSnapshot(),
ordered.stream().map(InboundCandidate::expectedArrivalAt).filter(Objects::nonNull).min(LocalDateTime::compareTo).orElse(null),
groupMembers);
}
private static BigDecimal weightedAverage(List<InboundCandidate> members,
java.util.function.Function<InboundCandidate, BigDecimal> getter,
int packages) {
if (packages == 0) return BigDecimal.ZERO;
return members.stream().map(candidate -> unit(getter.apply(candidate)).multiply(
BigDecimal.valueOf(candidate.availablePackageCount())))
.reduce(BigDecimal.ZERO, BigDecimal::add)
.divide(BigDecimal.valueOf(packages), 6, RoundingMode.HALF_UP);
}
private static Set<String> actualChannels(List<String> groups) {
Set<String> actual = new LinkedHashSet<>();
for (String value : groups) {
String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
Set<String> mapped = WORKBENCH_CHANNEL_GROUPS.get(normalized);
if (mapped == null) throw validation("不支持的渠道分组: " + value);
actual.addAll(mapped);
}
return actual;
}
private static boolean matches(InboundCandidateQuery query, Set<String> actualChannels,
InboundCandidate candidate) {
if (!actualChannels.contains(candidate.channelCode())) return false;
if (query.customerId() != null && !Objects.equals(candidate.accountId(), query.customerId())
&& candidate.groupMembers().stream().noneMatch(
member -> Objects.equals(member.accountId(), query.customerId()))) return false;
String inboundCode = trim(query.inboundCode());
if (StringUtils.hasText(inboundCode) && candidate.groupMembers().stream()
.map(GroupMemberCandidate::inboundCode).filter(Objects::nonNull)
.noneMatch(value -> value.toLowerCase(Locale.ROOT).contains(inboundCode.toLowerCase(Locale.ROOT)))
&& (candidate.inboundCode() == null || !candidate.inboundCode().toLowerCase(Locale.ROOT)
.contains(inboundCode.toLowerCase(Locale.ROOT)))) return false;
if (query.expectedArrivalStart() != null || query.expectedArrivalEnd() != null) {
boolean arrivalMatches = candidate.groupMembers().isEmpty()
? inArrivalRange(candidate.expectedArrivalAt(), query)
: candidate.groupMembers().stream().anyMatch(member -> inArrivalRange(
member.expectedArrivalAt(), query));
if (!arrivalMatches) return false;
}
return query.orderStatus() == null || query.orderStatus() == CandidateOrderStatus.WAIT_SPLIT;
}
private static boolean inArrivalRange(LocalDateTime arrival, InboundCandidateQuery query) {
if (arrival == null) return false;
return (query.expectedArrivalStart() == null
|| !arrival.toLocalDate().isBefore(query.expectedArrivalStart()))
&& (query.expectedArrivalEnd() == null
|| !arrival.toLocalDate().isAfter(query.expectedArrivalEnd()));
}
public Plan draftByBooking(long bookingId) {