优化
This commit is contained in:
+4
-2
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -62,6 +62,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 >= #{query.expectedArrivalStart}</if>
|
||||
<if test="query.expectedArrivalEnd != null">AND filter_inbound.expected_arrival_at < 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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -483,16 +483,72 @@ paths:
|
||||
'403': {description: 无智能装柜查询权限}
|
||||
/api/platform/v1/first-leg/smart-loading/inbound-candidates:
|
||||
get:
|
||||
summary: 按入仓码查询智能装柜候选
|
||||
summary: 全局分页查询智能装柜候选
|
||||
description: >-
|
||||
需要 platform:firstleg:smart-loading:list 权限。每个入库单/入仓码只返回一行;
|
||||
需要 platform:firstleg:smart-loading:list 权限。查询不绑定柜次;渠道、仓库、客户、入仓码、
|
||||
预计到货日期和订单状态共同作用于全局候选。每个普通入库单返回一行;同柜不可拆组作为一个
|
||||
分页单元返回,groupMembers 包含完整成员,不会跨页或因单个成员命中搜索条件而拆散。
|
||||
平均单箱毛重、净重按整票实测重量除以总箱数,平均单箱体积按剩余规格数量加权。
|
||||
响应携带订单状态快照 orderStatusSnapshot 与预计到货时间 expectedArrivalAt,供平台筛选候选入仓码;
|
||||
这些数据用于数量级计划,不表示具体箱号已核验归属。
|
||||
只返回当前具备实际装柜候选资格的 WAIT_SPLIT 数据;PENDING_REVIEW、WAIT_MEASURE、LOADING
|
||||
是统一筛选枚举,但在候选接口中返回空页。LOADED、COMPLETED 与已锁定或成员不完整的组永不返回。
|
||||
分配时仍由目标 planId 接口复核真实渠道、仓库、容量、组锁和并发版本。
|
||||
parameters:
|
||||
- {name: bookingId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: warehouseId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
responses: {'200': {description: 成功,data 为入仓码级候选列表}}
|
||||
- name: channelGroupCodes
|
||||
in: query
|
||||
required: true
|
||||
description: >-
|
||||
至少一项,可重复传参。分组到实际渠道:MATSON_CLX=[MATSON_CLX_TRUCK,MATSON_CLX_PARCEL];
|
||||
MATSON_MAX=[MATSON_MAX_TRUCK,MATSON_MAX_PARCEL];EXX=[EXX_TRUCK,EXX_RAIL_NY];
|
||||
HEDE=[HEDE_TRUCK,HEDE_PARCEL];OA_LA=[OA_LA_TRUCK,OA_LA_FULL_TRUCK,OA_PARCEL];
|
||||
OA_CHICAGO=[OA_CHICAGO_RAIL];OA_HOUSTON=[OA_HOUSTON_TRUCK];
|
||||
OA_SAVANNAH=[OA_SAVANNAH_TRUCK];OA_NEW_YORK_TRUCK=[OA_NEW_YORK_TRUCK]。
|
||||
schema:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 9
|
||||
uniqueItems: true
|
||||
items: {type: string, enum: [MATSON_CLX, MATSON_MAX, EXX, HEDE, OA_LA, OA_CHICAGO, OA_HOUSTON, OA_SAVANNAH, OA_NEW_YORK_TRUCK]}
|
||||
- {name: warehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: customerId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: inboundCode, in: query, required: false, schema: {type: string, maxLength: 40}}
|
||||
- {name: expectedArrivalStart, in: query, required: false, schema: {type: string, format: date}}
|
||||
- {name: expectedArrivalEnd, in: query, required: false, schema: {type: string, format: date}}
|
||||
- {name: orderStatus, in: query, required: false, schema: {type: string, enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING]}}
|
||||
- {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}}
|
||||
- {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}}
|
||||
responses:
|
||||
'200':
|
||||
description: 成功,data 为标准分页;total/pages 按普通入库单或完整同柜组分页单元统计
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingInboundCandidatePageEnvelope'}}}
|
||||
/api/platform/v1/first-leg/smart-loading/containers:
|
||||
get:
|
||||
summary: 分页查询智能装柜工作台柜次
|
||||
description: >-
|
||||
需要 platform:firstleg:smart-loading:list 权限。与全局候选使用相同的渠道、仓库、客户、入仓码、
|
||||
预计到货日期及订单状态筛选;客户及入仓事实以柜内任一有效装柜明细命中为准,查询不会修改或拆散同柜组。
|
||||
只返回 WAIT_LOADING、EDITING 或 LOADING 的 ACTIVE 柜次,COMPLETED 永不返回。
|
||||
parameters:
|
||||
- name: channelGroupCodes
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 9
|
||||
uniqueItems: true
|
||||
items: {type: string, enum: [MATSON_CLX, MATSON_MAX, EXX, HEDE, OA_LA, OA_CHICAGO, OA_HOUSTON, OA_SAVANNAH, OA_NEW_YORK_TRUCK]}
|
||||
- {name: warehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: customerId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: inboundCode, in: query, required: false, schema: {type: string, maxLength: 40}}
|
||||
- {name: expectedArrivalStart, in: query, required: false, schema: {type: string, format: date}}
|
||||
- {name: expectedArrivalEnd, in: query, required: false, schema: {type: string, format: date}}
|
||||
- {name: orderStatus, in: query, required: false, schema: {type: string, enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING]}}
|
||||
- {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}}
|
||||
- {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}}
|
||||
responses:
|
||||
'200':
|
||||
description: 成功,柜次按更新时间、技术ID倒序稳定分页
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingWorkbenchContainerPageEnvelope'}}}
|
||||
/api/platform/v1/first-leg/loading-associations/summary:
|
||||
get:
|
||||
summary: 查询入仓片段的装柜与国内PDA关联摘要
|
||||
@@ -1203,10 +1259,10 @@ components:
|
||||
type: object
|
||||
properties:
|
||||
records: {type: array, items: {$ref: '#/components/schemas/UnifiedInboundSummary'}}
|
||||
page: {type: integer, format: int64, minimum: 1}
|
||||
size: {type: integer, format: int64, minimum: 1}
|
||||
total: {type: integer, format: int64, minimum: 0}
|
||||
pages: {type: integer, format: int64, minimum: 0}
|
||||
page: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
size: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
total: {type: string, pattern: '^[0-9]+$'}
|
||||
pages: {type: string, pattern: '^[0-9]+$'}
|
||||
UnifiedInboundListEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
@@ -1880,6 +1936,103 @@ components:
|
||||
candidateInboundCount: {type: integer, format: int64, minimum: 0, description: 当前可分配的唯一入仓码数量}
|
||||
availablePackageCount: {type: integer, format: int64, minimum: 0, description: 当前候选规格剩余可分配箱数合计}
|
||||
availableContainerCount: {type: integer, format: int64, minimum: 0, description: 当前 WAIT_LOADING、EDITING 或 LOADING 可编排柜次数量}
|
||||
SmartLoadingInboundCandidatePageEnvelope:
|
||||
type: object
|
||||
required: [code, message, data]
|
||||
properties:
|
||||
code: {type: string, example: '200'}
|
||||
message: {type: string}
|
||||
data:
|
||||
type: object
|
||||
required: [records, page, size, total, pages]
|
||||
properties:
|
||||
records: {type: array, items: {$ref: '#/components/schemas/SmartLoadingInboundCandidate'}}
|
||||
page: {type: integer, format: int64, minimum: 1}
|
||||
size: {type: integer, format: int64, minimum: 1}
|
||||
total: {type: integer, format: int64, minimum: 0}
|
||||
pages: {type: integer, format: int64, minimum: 0}
|
||||
SmartLoadingInboundCandidate:
|
||||
type: object
|
||||
required: [accountId, customerId, inboundId, orderId, orderNo, inboundCode, warehouseId, channelCode,
|
||||
channelName, customerName, goodsSummary, availablePackageCount, unitGrossWeightKg,
|
||||
unitNetWeightKg, averageUnitVolumeCbm, availableVolumeCbm, locked, groupMembers]
|
||||
properties:
|
||||
accountId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
customerId: {type: string, pattern: '^[1-9][0-9]*$', description: 与 accountId 相同的客户技术关联ID}
|
||||
inboundId: {type: string, pattern: '^[1-9][0-9]*$', description: 普通候选ID;组聚合行中为可用于整组分配的稳定代表成员ID}
|
||||
orderId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
orderNo: {type: string}
|
||||
inboundCode: {type: string}
|
||||
orderStatusSnapshot: {type: string, nullable: true}
|
||||
expectedArrivalAt: {type: string, format: date-time, nullable: true}
|
||||
warehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
channelCode: {type: string}
|
||||
channelName: {type: string}
|
||||
destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
|
||||
destinationWarehouseCode: {type: string, nullable: true}
|
||||
destinationWarehouseName: {type: string, nullable: true}
|
||||
customerName: {type: string}
|
||||
goodsSummary: {type: string}
|
||||
availablePackageCount: {type: integer, minimum: 1, description: 普通行剩余箱数;组行完整成员箱数合计}
|
||||
unitGrossWeightKg: {type: number, minimum: 0}
|
||||
unitNetWeightKg: {type: number, minimum: 0}
|
||||
averageUnitVolumeCbm: {type: number, minimum: 0}
|
||||
availableVolumeCbm: {type: number, minimum: 0}
|
||||
coLoadGroupId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
|
||||
groupNo: {type: string, nullable: true}
|
||||
memberCount: {type: integer, minimum: 2, nullable: true}
|
||||
groupTotalBoxes: {type: integer, minimum: 1, nullable: true}
|
||||
locked: {type: boolean}
|
||||
blockReason: {type: string, nullable: true}
|
||||
groupMembers:
|
||||
type: array
|
||||
description: 同柜组聚合行的完整成员;普通候选为空数组
|
||||
items:
|
||||
type: object
|
||||
required: [accountId, inboundId, orderId, orderNo, inboundCode, customerName, availablePackageCount]
|
||||
properties:
|
||||
accountId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
inboundId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
orderId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
orderNo: {type: string}
|
||||
inboundCode: {type: string}
|
||||
customerName: {type: string}
|
||||
availablePackageCount: {type: integer, minimum: 1}
|
||||
orderStatusSnapshot: {type: string, nullable: true}
|
||||
expectedArrivalAt: {type: string, format: date-time, nullable: true}
|
||||
SmartLoadingWorkbenchContainerPageEnvelope:
|
||||
type: object
|
||||
required: [code, message, data]
|
||||
properties:
|
||||
code: {type: string, example: '200'}
|
||||
message: {type: string}
|
||||
data:
|
||||
type: object
|
||||
required: [records, page, size, total, pages]
|
||||
properties:
|
||||
records: {type: array, items: {$ref: '#/components/schemas/SmartLoadingWorkbenchContainer'}}
|
||||
page: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
size: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
total: {type: string, pattern: '^[0-9]+$'}
|
||||
pages: {type: string, pattern: '^[0-9]+$'}
|
||||
SmartLoadingWorkbenchContainer:
|
||||
type: object
|
||||
required: [bookingId, bookingNo, cabinetNo, warehouseId, warehouseName, channelCode,
|
||||
channelName, containerType, planStatus, version]
|
||||
properties:
|
||||
bookingId: {type: string, pattern: '^[1-9][0-9]*$', description: containerBookingId 技术关联}
|
||||
bookingNo: {type: string, description: 货柜登记业务号}
|
||||
cabinetNo: {type: string, description: 稳定柜次业务号}
|
||||
containerNo: {type: string, nullable: true, description: 真实集装箱号,允许在装柜前后补}
|
||||
warehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
warehouseName: {type: string}
|
||||
channelCode: {type: string}
|
||||
channelName: {type: string}
|
||||
containerType: {type: string}
|
||||
planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
|
||||
planNo: {type: string, nullable: true}
|
||||
planStatus: {type: string, enum: [WAIT_LOADING, EDITING, LOADING]}
|
||||
version: {type: integer, minimum: 0, description: 柜次版本}
|
||||
SmartLoadingPlan:
|
||||
type: object
|
||||
required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, items]
|
||||
|
||||
+19
-7
@@ -4,6 +4,7 @@ import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import java.lang.reflect.Method;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -12,16 +13,27 @@ class PlatformSmartLoadingControllerContractTest {
|
||||
authority("draftByBooking","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class);
|
||||
authority("detail","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class);
|
||||
authority("candidates","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class,long.class);
|
||||
authority("inboundCandidates","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class,long.class);
|
||||
authority("inboundCandidates","platform:firstleg:smart-loading:list",AuthenticatedUser.class,InboundCandidateQuery.class);
|
||||
authority("containers","platform:firstleg:smart-loading:list",AuthenticatedUser.class,WorkbenchContainerQuery.class);
|
||||
authority("channels","platform:firstleg:smart-loading:list",AuthenticatedUser.class,long.class);
|
||||
}
|
||||
@Test void openApiRequiresBookingAndWarehouseForCandidates() throws Exception {
|
||||
@Test void openApiFreezesFilteredWorkbenchContainers() throws Exception {
|
||||
String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml"));
|
||||
assertThat(yaml).contains("/api/platform/v1/first-leg/smart-loading/candidates:")
|
||||
.contains("/api/platform/v1/first-leg/smart-loading/channels:")
|
||||
.contains("零候选或零可用柜次的有效渠道仍返回")
|
||||
.contains("name: bookingId, in: query, required: true")
|
||||
.contains("name: warehouseId, in: query, required: true");
|
||||
assertThat(yaml).contains("/api/platform/v1/first-leg/smart-loading/containers:")
|
||||
.contains("SmartLoadingWorkbenchContainerPageEnvelope:")
|
||||
.contains("只返回 WAIT_LOADING、EDITING 或 LOADING");
|
||||
}
|
||||
@Test void openApiFreezesGlobalPagedInboundCandidates() throws Exception {
|
||||
Method method=PlatformSmartLoadingController.class.getDeclaredMethod(
|
||||
"inboundCandidates",AuthenticatedUser.class,InboundCandidateQuery.class);
|
||||
assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly("/inbound-candidates");
|
||||
String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml"));
|
||||
String contract=yaml.substring(yaml.indexOf("/api/platform/v1/first-leg/smart-loading/inbound-candidates:"),
|
||||
yaml.indexOf("/api/platform/v1/first-leg/loading-associations/summary:"));
|
||||
assertThat(contract).contains("name: channelGroupCodes")
|
||||
.contains("name: warehouseId")
|
||||
.contains("SmartLoadingInboundCandidatePageEnvelope")
|
||||
.doesNotContain("name: bookingId");
|
||||
}
|
||||
@Test void everyMutationDeclaresDedicatedPermission() throws Exception {
|
||||
authority("assign","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignRequest.class);
|
||||
|
||||
+32
@@ -6,6 +6,9 @@ import org.apache.ibatis.annotations.Select;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Set;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainerQuery;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -49,6 +52,35 @@ class SmartLoadingCandidateMapperContractTest {
|
||||
.isLessThan(sql.indexOf("o.order_status_snapshot orderStatusSnapshot"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void globalCandidateQueryHasOptionalWarehouseAndNoContainerJoin() throws Exception {
|
||||
Method list = SmartLoadingCandidateMapper.class.getDeclaredMethod("globalCandidates", Long.class);
|
||||
String sql = String.join(" ", list.getAnnotation(Select.class).value());
|
||||
|
||||
assertThat(sql)
|
||||
.contains("#{warehouseId} IS NULL OR o.warehouse_id=#{warehouseId}")
|
||||
.contains("o.process_status='WAIT_SPLIT'")
|
||||
.doesNotContain("fl_container_booking")
|
||||
.doesNotContain("bookingId");
|
||||
}
|
||||
|
||||
@Test
|
||||
void workbenchContainerQueryExcludesCompletedAndMatchesRelatedInboundFacts() throws Exception {
|
||||
Method list = SmartLoadingCandidateMapper.class.getDeclaredMethod("workbenchContainers",
|
||||
Page.class, WorkbenchContainerQuery.class, Set.class, String.class);
|
||||
String sql = String.join(" ", list.getAnnotation(Select.class).value());
|
||||
|
||||
assertThat(sql)
|
||||
.contains("p.plan_status IN ('EDITING','LOADING')")
|
||||
.contains("b.channel_code IN")
|
||||
.contains("fl_loading_plan_item filter_item")
|
||||
.contains("fl_inbound_order filter_inbound")
|
||||
.contains("filter_item.inbound_code LIKE")
|
||||
.contains("filter_inbound.expected_arrival_at")
|
||||
.contains("filter_inbound.process_status='LOADING'")
|
||||
.doesNotContain("p.plan_status IN ('EDITING','LOADING','COMPLETED')");
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailUsesSameBatchRule() throws Exception {
|
||||
Method detail = SmartLoadingCandidateMapper.class.getDeclaredMethod(
|
||||
|
||||
@@ -7,7 +7,12 @@ import com.baiyue.firstleg.client.OrderCargoClient;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.AssignRequest;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.AdjustRequest;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.CandidateOrderStatus;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.DeleteRequest;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.GroupMemberCandidate;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.InboundCandidateQuery;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainer;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainerQuery;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.Plan;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.RecommendRequest;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.WarehouseRemarkRequest;
|
||||
@@ -39,6 +44,7 @@ import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.sql.SQLException;
|
||||
@@ -144,6 +150,89 @@ class SmartLoadingServiceTest {
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void pagesGlobalCandidatesWithoutBindingAContainer() {
|
||||
Candidate clx = withChannel(candidate(9L, 10L, 20L, 30L, 4),
|
||||
"MATSON_CLX_TRUCK", "美森正班海卡");
|
||||
Candidate max = withChannel(candidate(19L, 11L, 21L, 31L, 6),
|
||||
"MATSON_MAX_TRUCK", "美森加班海卡");
|
||||
when(repository.globalCandidates(null)).thenReturn(List.of(clx, max));
|
||||
|
||||
var result = service.inboundCandidates(new InboundCandidateQuery(
|
||||
List.of("MATSON_CLX"), null, null, null, null, null, null, 1, 20));
|
||||
|
||||
assertThat(result.total()).isEqualTo(1);
|
||||
assertThat(result.pages()).isEqualTo(1);
|
||||
assertThat(result.records()).singleElement().satisfies(candidate -> {
|
||||
assertThat(candidate.inboundId()).isEqualTo(10L);
|
||||
assertThat(candidate.channelCode()).isEqualTo("MATSON_CLX_TRUCK");
|
||||
});
|
||||
verify(repository).globalCandidates(null);
|
||||
verify(repository, never()).booking(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pagesWorkbenchContainersWithTheSameGlobalFilters() {
|
||||
WorkbenchContainer container = new WorkbenchContainer(99L, "CB-1", "CAB-1", null,
|
||||
100L, "义乌仓", "MATSON_CLX_TRUCK", "美森正班海卡", "40HC",
|
||||
1L, "LP-1", "EDITING", 2);
|
||||
WorkbenchContainerQuery query = new WorkbenchContainerQuery(List.of("MATSON_CLX"), 100L,
|
||||
9L, "IN-10", LocalDate.parse("2026-09-01"), LocalDate.parse("2026-09-30"),
|
||||
CandidateOrderStatus.WAIT_SPLIT, 1, 20);
|
||||
when(repository.workbenchContainers(eq(query), any())).thenReturn(
|
||||
new com.baiyue.common.core.api.PageResult<>(List.of(container), 1, 20, 1, 1));
|
||||
|
||||
var result = service.containers(query);
|
||||
|
||||
assertThat(result.records()).containsExactly(container);
|
||||
@SuppressWarnings("unchecked") ArgumentCaptor<java.util.Set<String>> channels =
|
||||
ArgumentCaptor.forClass(java.util.Set.class);
|
||||
verify(repository).workbenchContainers(eq(query), channels.capture());
|
||||
assertThat(channels.getValue()).containsExactlyInAnyOrder(
|
||||
"MATSON_CLX_TRUCK", "MATSON_CLX_PARCEL");
|
||||
}
|
||||
|
||||
@Test
|
||||
void treatsWholeColoadGroupAsOneStablePagingUnit() {
|
||||
Candidate first = withArrival(withChannel(candidate(9L, 10L, 20L, 30L, 4),
|
||||
"MATSON_CLX_TRUCK", "美森正班海卡"), "2026-09-01T09:00:00");
|
||||
Candidate second = withArrival(withChannel(candidate(19L, 11L, 21L, 31L, 6),
|
||||
"MATSON_CLX_TRUCK", "美森正班海卡"), "2026-09-10T09:00:00");
|
||||
GroupSnapshot group = group("ACTIVE", null,
|
||||
new Member(9L, 30L, 10L, "I10", 4), new Member(19L, 31L, 11L, "I11", 6));
|
||||
when(repository.globalCandidates(100L)).thenReturn(List.of(first, second));
|
||||
when(coloadGroups.findByInbound(9L, 10L)).thenReturn(group);
|
||||
when(coloadGroups.findByInbound(19L, 11L)).thenReturn(group);
|
||||
|
||||
var result = service.inboundCandidates(new InboundCandidateQuery(
|
||||
List.of("MATSON_CLX"), 100L, null, null, LocalDate.parse("2026-09-10"),
|
||||
LocalDate.parse("2026-09-10"), null, 1, 1));
|
||||
|
||||
assertThat(result.total()).isEqualTo(1);
|
||||
assertThat(result.records()).singleElement().satisfies(candidate -> {
|
||||
assertThat(candidate.coLoadGroupId()).isEqualTo(700L);
|
||||
assertThat(candidate.availablePackageCount()).isEqualTo(10);
|
||||
assertThat(candidate.groupMembers()).extracting(GroupMemberCandidate::inboundId)
|
||||
.containsExactly(10L, 11L);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void excludesIncompleteColoadGroupInsteadOfPublishingPartialMember() {
|
||||
Candidate first = withChannel(candidate(9L, 10L, 20L, 30L, 4),
|
||||
"MATSON_CLX_TRUCK", "美森正班海卡");
|
||||
GroupSnapshot group = group("ACTIVE", null,
|
||||
new Member(9L, 30L, 10L, "I10", 4), new Member(19L, 31L, 11L, "I11", 6));
|
||||
when(repository.globalCandidates(100L)).thenReturn(List.of(first));
|
||||
when(coloadGroups.findByInbound(9L, 10L)).thenReturn(group);
|
||||
|
||||
var result = service.inboundCandidates(new InboundCandidateQuery(
|
||||
List.of("MATSON_CLX"), 100L, null, null, null, null, null, 1, 20));
|
||||
|
||||
assertThat(result.records()).isEmpty();
|
||||
assertThat(result.total()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void channelDirectoryKeepsActiveZeroStatChannelAndAggregatesCandidateAndContainerFacts() {
|
||||
ChannelRow maxTruck = channel("MATSON_MAX_TRUCK", "美森加班海卡");
|
||||
@@ -1124,6 +1213,16 @@ class SmartLoadingServiceTest {
|
||||
value.orderStatusSnapshot(), value.expectedArrivalAt());
|
||||
}
|
||||
|
||||
private static Candidate withArrival(Candidate value, String expectedArrivalAt) {
|
||||
return new Candidate(value.accountId(), value.inboundId(), value.inboundDetailId(), value.orderId(),
|
||||
value.orderNo(), value.inboundCode(), value.warehouseId(), value.channelCode(), value.channelName(),
|
||||
value.destinationWarehouseId(), value.destinationWarehouseCode(), value.destinationWarehouseName(),
|
||||
value.customerName(), value.goodsName(), value.availablePackageCount(), value.unitGrossWeightKg(),
|
||||
value.unitNetWeightKg(), value.unitVolumeCbm(), value.coLoadGroupId(), value.groupNo(),
|
||||
value.memberCount(), value.groupTotalBoxes(), value.locked(), value.blockReason(),
|
||||
value.orderStatusSnapshot(), LocalDateTime.parse(expectedArrivalAt));
|
||||
}
|
||||
|
||||
private static ChannelRow channel(String channelCode, String channelName) {
|
||||
ChannelRow value = new ChannelRow();
|
||||
value.setChannelCode(channelCode); value.setChannelName(channelName);
|
||||
|
||||
Reference in New Issue
Block a user