优化
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
package com.baiyue.firstleg.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** Order-owned pending-review rows for the SmartLoading read model. */
|
||||
@FeignClient(name = "order-service", contextId = "firstlegPendingReviewOrderClient",
|
||||
url = "${baiyue.clients.order.url:}", path = "/internal/order/v1/pending-review-orders")
|
||||
public interface PendingReviewOrderClient {
|
||||
@GetMapping
|
||||
ApiResponse<PageResult<Row>> page(
|
||||
@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestParam(required = false) List<String> channelCodes,
|
||||
@RequestParam(required = false) Long warehouseId,
|
||||
@RequestParam(required = false) Long customerId,
|
||||
@RequestParam(required = false) String inboundCode,
|
||||
@RequestParam(required = false) LocalDate createdStart,
|
||||
@RequestParam(required = false) LocalDate createdEnd,
|
||||
@RequestParam long page,
|
||||
@RequestParam long size);
|
||||
|
||||
record Row(Long orderId, String orderNo, String inboundCode, Long customerId,
|
||||
String customerName, Long warehouseId, String channelCode, String channelName,
|
||||
int totalPackageCount, LocalDateTime createdAt, String mainStatus) {}
|
||||
}
|
||||
+2
@@ -24,6 +24,8 @@ public class PlatformSmartLoadingController {
|
||||
public ApiResponse<Plan> assign(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assign(planId,r,u.userId(),u.username()));}
|
||||
@PostMapping("/plans/{planId}/inbound-assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
|
||||
public ApiResponse<Plan> assignInbound(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignInboundRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assignInbound(planId,r,u.userId(),u.username()));}
|
||||
@PostMapping("/plans/{planId}/pending-review-assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
|
||||
public ApiResponse<Plan> assignPendingReview(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignPendingReviewRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assignPendingReview(planId,r,u.userId(),u.username()));}
|
||||
@PostMapping("/plans/{planId}/group-assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')")
|
||||
public ApiResponse<Plan> assignGroup(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignGroupRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assignGroup(planId,r,u.userId(),u.username()));}
|
||||
@DeleteMapping("/plans/{planId}/group-assignments/{groupId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:delete')")
|
||||
|
||||
@@ -7,7 +7,7 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
|
||||
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,
|
||||
LocalDate createdStart, LocalDate createdEnd, CandidateOrderStatus orderStatus,
|
||||
@Min(1) long page, @Min(1) @Max(200) long size) {
|
||||
public InboundCandidateQuery {
|
||||
channelGroupCodes = channelGroupCodes == null ? List.of() : List.copyOf(channelGroupCodes);
|
||||
@@ -32,7 +32,7 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
|
||||
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,
|
||||
LocalDate createdStart, LocalDate createdEnd, CandidateOrderStatus orderStatus,
|
||||
@Min(1) long page, @Min(1) @Max(200) long size) {
|
||||
public WorkbenchContainerQuery {
|
||||
channelGroupCodes = channelGroupCodes == null ? List.of() : List.copyOf(channelGroupCodes);
|
||||
@@ -53,6 +53,10 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
|
||||
public record AssignInboundRequest(@NotNull @Positive Long inboundId,
|
||||
@NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedVersion,
|
||||
@NotBlank @Size(max=128) String operationId) {}
|
||||
public record AssignPendingReviewRequest(@NotNull @Positive Long customerId,
|
||||
@NotNull @Positive Long orderId,@NotBlank @Size(max=40) String inboundCode,
|
||||
@NotNull @Positive Integer packageCount,
|
||||
@NotNull @Min(0) Integer expectedVersion,@NotBlank @Size(max=128) String operationId) {}
|
||||
public record AssignGroupRequest(@NotNull @Positive Long inboundId,
|
||||
@NotNull @Min(0) Integer expectedVersion,
|
||||
@NotBlank @Size(max=128) String operationId) {}
|
||||
|
||||
+8
-4
@@ -27,9 +27,12 @@ public interface LoadingModelBridgeMapper extends BaseMapper<PreloadLoadingPlanL
|
||||
int allocatedPackages(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
|
||||
@Param("loadingPlanId") long loadingPlanId);
|
||||
|
||||
@Select("SELECT account_id tenantId,inbound_id inboundId,COALESCE(SUM(package_count),0) allocatedPackageCount "
|
||||
+ "FROM fl_loading_plan_item WHERE loading_plan_id=#{loadingPlanId} AND deleted=0 "
|
||||
+ "GROUP BY account_id,inbound_id HAVING SUM(package_count)>0")
|
||||
@Select("SELECT item.account_id tenantId,item.inbound_id inboundId,"
|
||||
+ "COALESCE(SUM(item.package_count),0) allocatedPackageCount,MAX(inbound.process_status) processStatus "
|
||||
+ "FROM fl_loading_plan_item item JOIN fl_inbound_order inbound "
|
||||
+ "ON inbound.tenant_id=item.account_id AND inbound.id=item.inbound_id AND inbound.deleted=0 "
|
||||
+ "WHERE item.loading_plan_id=#{loadingPlanId} AND item.deleted=0 "
|
||||
+ "GROUP BY item.account_id,item.inbound_id HAVING SUM(item.package_count)>0")
|
||||
List<AllocationScope> allocatedScopes(@Param("loadingPlanId") long loadingPlanId);
|
||||
|
||||
@Update("UPDATE fl_preload_plan SET updated_by=#{operatorId},updated_by_name=#{operatorName},"
|
||||
@@ -87,5 +90,6 @@ public interface LoadingModelBridgeMapper extends BaseMapper<PreloadLoadingPlanL
|
||||
record PreloadSnapshot(Long id, Long tenantId, Long inboundId, String status, Integer version) {}
|
||||
record LoadingPlanSnapshot(Long id, Long containerBookingId, String containerNo,
|
||||
String status, Integer version) {}
|
||||
record AllocationScope(Long tenantId, Long inboundId, Integer allocatedPackageCount) {}
|
||||
record AllocationScope(Long tenantId, Long inboundId, Integer allocatedPackageCount,
|
||||
String processStatus) {}
|
||||
}
|
||||
|
||||
+7
@@ -81,6 +81,10 @@ public class LoadingModelBridgeService {
|
||||
if (loadingPlan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次装柜计划不存在");
|
||||
List<AllocationScope> scopes = repository.allocatedScopes(loadingPlanId);
|
||||
if (scopes.isEmpty()) throw conflict("装柜计划尚未分配入库单货物,不能开始PDA作业");
|
||||
if ("EDITING".equals(loadingPlan.status())
|
||||
&& scopes.stream().anyMatch(scope -> !"WAIT_SPLIT".equals(scope.processStatus()))) {
|
||||
throw conflict("装柜清单仍有订单未完成审核和测量,不能开始PDA作业");
|
||||
}
|
||||
if (!"EDITING".equals(loadingPlan.status())) {
|
||||
if (Set.of("LOADING", "COMPLETED").contains(loadingPlan.status())
|
||||
&& scopes.stream().allMatch(scope -> repository.byPair(
|
||||
@@ -172,6 +176,9 @@ public class LoadingModelBridgeService {
|
||||
}
|
||||
List<AllocationScope> scopes = repository.allocatedScopes(loadingPlanId);
|
||||
if (scopes.isEmpty()) throw conflict("装柜计划尚未分配入库单货物");
|
||||
if (scopes.stream().anyMatch(scope -> !"WAIT_SPLIT".equals(scope.processStatus()))) {
|
||||
throw conflict("装柜清单仍有订单未完成审核和测量,不能确认预装");
|
||||
}
|
||||
List<Link> links = repository.byLoadingPlan(loadingPlanId);
|
||||
boolean complete = scopes.stream().allMatch(scope -> links.stream().anyMatch(link ->
|
||||
Objects.equals(link.tenantId(), scope.tenantId())
|
||||
|
||||
+10
-11
@@ -85,11 +85,11 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
|
||||
CASE WHEN current_group.id IS NULL THEN eligible.inboundId END inboundId,
|
||||
MIN(eligible.expectedArrivalAt) sortArrivalAt,MIN(eligible.inboundId) sortInboundId
|
||||
FROM (
|
||||
SELECT detail.accountId,detail.inboundId,detail.inboundCode,detail.expectedArrivalAt,
|
||||
SELECT detail.accountId,detail.inboundId,detail.inboundCode,detail.expectedArrivalAt,detail.createdAt,
|
||||
SUM(detail.availablePackageCount) availablePackageCount
|
||||
FROM (
|
||||
SELECT o.tenant_id accountId,o.id inboundId,o.inbound_code inboundCode,
|
||||
o.expected_arrival_at expectedArrivalAt,i.inbound_detail_id,
|
||||
o.expected_arrival_at expectedArrivalAt,o.created_at createdAt,i.inbound_detail_id,
|
||||
SUM(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
|
||||
@@ -111,10 +111,10 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
|
||||
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'))
|
||||
GROUP BY o.tenant_id,o.id,o.inbound_code,o.expected_arrival_at,i.inbound_detail_id
|
||||
GROUP BY o.tenant_id,o.id,o.inbound_code,o.expected_arrival_at,o.created_at,i.inbound_detail_id
|
||||
HAVING availablePackageCount>0
|
||||
) detail
|
||||
GROUP BY detail.accountId,detail.inboundId,detail.inboundCode,detail.expectedArrivalAt
|
||||
GROUP BY detail.accountId,detail.inboundId,detail.inboundCode,detail.expectedArrivalAt,detail.createdAt
|
||||
) eligible
|
||||
LEFT JOIN fl_coload_group_member current_member
|
||||
ON current_member.tenant_id=eligible.accountId AND current_member.inbound_id=eligible.inboundId
|
||||
@@ -136,10 +136,9 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
|
||||
<if test="query.inboundCode != null and query.inboundCode.trim() != ''">
|
||||
AND SUM(CASE WHEN LOWER(eligible.inboundCode) LIKE CONCAT('%',LOWER(#{query.inboundCode}),'%') THEN 1 ELSE 0 END)>0
|
||||
</if>
|
||||
<if test="query.expectedArrivalStart != null or query.expectedArrivalEnd != null">
|
||||
AND MIN(eligible.expectedArrivalAt) IS NOT NULL
|
||||
<if test="query.expectedArrivalStart != null">AND DATE(MIN(eligible.expectedArrivalAt))>=#{query.expectedArrivalStart}</if>
|
||||
<if test="query.expectedArrivalEnd != null">AND DATE(MIN(eligible.expectedArrivalAt))<=#{query.expectedArrivalEnd}</if>
|
||||
<if test="query.createdStart != null or query.createdEnd != null">
|
||||
<if test="query.createdStart != null">AND DATE(MIN(eligible.createdAt))>=#{query.createdStart}</if>
|
||||
<if test="query.createdEnd != null">AND DATE(MIN(eligible.createdAt))<=#{query.createdEnd}</if>
|
||||
</if>
|
||||
ORDER BY sortArrivalAt IS NULL,sortArrivalAt,sortInboundId
|
||||
</script>
|
||||
@@ -217,7 +216,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
|
||||
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">
|
||||
<if test="query.customerId != null or query.inboundCode != null or query.createdStart != null or query.createdEnd != 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
|
||||
@@ -225,8 +224,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
|
||||
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="query.createdStart != null">AND filter_inbound.created_at >= #{query.createdStart}</if>
|
||||
<if test="query.createdEnd != null">AND filter_inbound.created_at < DATE_ADD(#{query.createdEnd},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>
|
||||
|
||||
@@ -68,6 +68,12 @@ public class SmartLoadingRepository {
|
||||
.eq(InboundOrderEntity::getTenantId, accountId)
|
||||
.eq(InboundOrderEntity::getId, inboundId));
|
||||
}
|
||||
public InboundOrderEntity inboundForUpdate(long accountId, long inboundId) {
|
||||
return inbounds.selectOne(new LambdaQueryWrapper<InboundOrderEntity>()
|
||||
.eq(InboundOrderEntity::getTenantId, accountId)
|
||||
.eq(InboundOrderEntity::getId, inboundId)
|
||||
.last("FOR UPDATE"));
|
||||
}
|
||||
public List<LoadingPlanItemEntity> activeItemsByInbound(long planId, long accountId, long inboundId) {
|
||||
return items.selectList(new LambdaQueryWrapper<LoadingPlanItemEntity>()
|
||||
.eq(LoadingPlanItemEntity::getLoadingPlanId, planId)
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.*;
|
||||
import com.baiyue.firstleg.client.OrderCargoClient;
|
||||
import com.baiyue.firstleg.client.PendingReviewOrderClient;
|
||||
import com.baiyue.firstleg.entity.*;
|
||||
import com.baiyue.firstleg.repository.SmartLoadingRepository;
|
||||
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
|
||||
@@ -54,6 +55,8 @@ public class SmartLoadingService {
|
||||
);
|
||||
@Resource private SmartLoadingRepository repository;
|
||||
@Resource private OrderCargoClient orderCargoClient;
|
||||
@Resource private PendingReviewOrderClient pendingReviewOrders;
|
||||
@Resource private InboundOrderCreationService inboundOrderCreationService;
|
||||
@Resource private LoadingPickService loadingPickService;
|
||||
@Resource private LoadingModelBridgeService loadingModelBridge;
|
||||
@Resource private SplitPreloadService splitPreloadService;
|
||||
@@ -113,11 +116,14 @@ public class SmartLoadingService {
|
||||
/** Global workbench candidates. A co-load group is one paging unit and can never be split across pages. */
|
||||
@Transactional(readOnly = true)
|
||||
public PageResult<InboundCandidate> inboundCandidates(InboundCandidateQuery query) {
|
||||
if (query.expectedArrivalStart() != null && query.expectedArrivalEnd() != null
|
||||
&& query.expectedArrivalStart().isAfter(query.expectedArrivalEnd())) {
|
||||
throw validation("预计到货开始日期不能晚于结束日期");
|
||||
if (query.createdStart() != null && query.createdEnd() != null
|
||||
&& query.createdStart().isAfter(query.createdEnd())) {
|
||||
throw validation("创建开始日期不能晚于结束日期");
|
||||
}
|
||||
Set<String> actualChannels = actualChannels(query.channelGroupCodes());
|
||||
if (query.orderStatus() == CandidateOrderStatus.PENDING_REVIEW) {
|
||||
return pendingReviewCandidates(query, actualChannels);
|
||||
}
|
||||
if (query.orderStatus() != null && query.orderStatus() != CandidateOrderStatus.WAIT_SPLIT) {
|
||||
return new PageResult<>(List.of(), query.page(), query.size(), 0, 0);
|
||||
}
|
||||
@@ -149,10 +155,33 @@ public class SmartLoadingService {
|
||||
return new PageResult<>(records, unitPage.page(), unitPage.size(), unitPage.total(), unitPage.pages());
|
||||
}
|
||||
|
||||
private PageResult<InboundCandidate> pendingReviewCandidates(InboundCandidateQuery query,
|
||||
Set<String> actualChannels) {
|
||||
ApiResponse<PageResult<PendingReviewOrderClient.Row>> response;
|
||||
try {
|
||||
response = pendingReviewOrders.page(internalToken, List.copyOf(actualChannels), query.warehouseId(),
|
||||
query.customerId(), query.inboundCode(), query.createdStart(), query.createdEnd(),
|
||||
query.page(), query.size());
|
||||
} catch (RuntimeException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "待审核订单服务暂时不可用");
|
||||
}
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "待审核订单查询响应无效");
|
||||
}
|
||||
PageResult<PendingReviewOrderClient.Row> source = response.data();
|
||||
List<InboundCandidate> records = source.records().stream().map(row ->
|
||||
new InboundCandidate(row.customerId(), null, row.orderId(), row.orderNo(), row.inboundCode(),
|
||||
row.warehouseId(), row.channelCode(), row.channelName(), null, null, null,
|
||||
row.customerName(), null, row.totalPackageCount(), null, null, null, null,
|
||||
null, null, null, null, false, null,
|
||||
"PENDING_REVIEW", null, List.of())).toList();
|
||||
return new PageResult<>(records, source.page(), source.size(), source.total(), source.pages());
|
||||
}
|
||||
|
||||
public PageResult<WorkbenchContainer> containers(WorkbenchContainerQuery query) {
|
||||
if (query.expectedArrivalStart() != null && query.expectedArrivalEnd() != null
|
||||
&& query.expectedArrivalStart().isAfter(query.expectedArrivalEnd())) {
|
||||
throw validation("预计到货开始日期不能晚于结束日期");
|
||||
if (query.createdStart() != null && query.createdEnd() != null
|
||||
&& query.createdStart().isAfter(query.createdEnd())) {
|
||||
throw validation("创建开始日期不能晚于结束日期");
|
||||
}
|
||||
return repository.workbenchContainers(query, actualChannels(query.channelGroupCodes()));
|
||||
}
|
||||
@@ -349,6 +378,86 @@ public class SmartLoadingService {
|
||||
return assignInbound(planId, request, operatorId, operatorName, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserves declared cartons in a cabinet before order review. Review status is not an allocation
|
||||
* gate; confirmation/PDA remains blocked until the resulting inbound reaches WAIT_SPLIT.
|
||||
*/
|
||||
@Transactional
|
||||
@BusinessAudit(event = "SMART_LOADING_PENDING_REVIEW_ASSIGN", resourceType = "LOADING_PLAN",
|
||||
resourceId = "#planId", description = "待审核订单加入装柜清单")
|
||||
public Plan assignPendingReview(long planId, AssignPendingReviewRequest request,
|
||||
long operatorId, String operatorName) {
|
||||
String operation = operation(request.operationId());
|
||||
String requestHash = sha256("ASSIGN_PENDING_REVIEW|" + planId + '|' + request.customerId()
|
||||
+ '|' + request.orderId() + '|' + request.inboundCode() + '|' + request.packageCount()
|
||||
+ '|' + request.expectedVersion());
|
||||
Plan replay = replay(operation, planId, "ADD_PENDING_REVIEW_ORDER", null, requestHash);
|
||||
if (replay != null) return replay;
|
||||
|
||||
PendingReviewOrderClient.Row pending = requirePendingReviewOrder(request);
|
||||
var snapshot = loadInboundSnapshot(request.customerId(), request.orderId());
|
||||
if (!Objects.equals(snapshot.tenantId(), request.customerId())
|
||||
|| !Objects.equals(snapshot.orderId(), request.orderId())
|
||||
|| !Objects.equals(snapshot.inboundCode(), request.inboundCode())) {
|
||||
throw conflict("待审核订单快照已变化,请刷新后重试");
|
||||
}
|
||||
long inboundId = inboundOrderCreationService.prepareFromDraft(snapshot);
|
||||
LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName);
|
||||
if (!"EDITING".equals(plan.getPlanStatus())) {
|
||||
throw conflict("待审核订单只能加入编辑中的装柜清单");
|
||||
}
|
||||
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
|
||||
InboundOrderEntity lockedInbound = repository.inboundForUpdate(request.customerId(), inboundId);
|
||||
if (lockedInbound == null || !Set.of("PREPARING", "WAIT_MEASURE", "WAIT_SPLIT")
|
||||
.contains(lockedInbound.getProcessStatus())) {
|
||||
throw conflict("订单当前状态不能加入装柜清单");
|
||||
}
|
||||
if (!repository.activeItemsByInbound(request.customerId(), inboundId).isEmpty()) {
|
||||
throw conflict("该入仓码已被有效柜次占用");
|
||||
}
|
||||
|
||||
List<DeclaredCargo> declared = loadDeclaredCargo(request.customerId(), request.orderId());
|
||||
List<Candidate> candidates = declared.stream().map(value -> declaredCandidate(
|
||||
pending, snapshot, inboundId, value.cargo())).toList();
|
||||
List<InboundAllocation> allocations = proportionalAllocations(candidates, request.packageCount());
|
||||
List<LoadingPlanItemEntity> current = new ArrayList<>(repository.items(planId));
|
||||
List<LoadingPlanItemEntity> created = new ArrayList<>();
|
||||
int sequence = current.size();
|
||||
for (InboundAllocation allocation : allocations) {
|
||||
if (allocation.packageCount() == 0) continue;
|
||||
Candidate candidate = allocation.candidate();
|
||||
requireSameChannel(booking, candidate);
|
||||
requireSameCustomerScope(booking, candidate);
|
||||
checkRouteAndCapacity(booking, current, candidate, allocation.packageCount(), true);
|
||||
OrderCargoClient.CargoBinding cargo = declared.stream()
|
||||
.map(DeclaredCargo::cargo)
|
||||
.filter(value -> Objects.equals(value.cargoId(), candidate.inboundDetailId()))
|
||||
.findFirst().orElseThrow(() -> conflict("订单货物明细已变化,请刷新后重试"));
|
||||
LoadingPlanItemEntity item = snapshot(planId, candidate, cargo, allocation.packageCount(),
|
||||
operatorId, operatorName, ++sequence);
|
||||
current.add(item);
|
||||
created.add(item);
|
||||
}
|
||||
if (created.isEmpty()) throw validation("订单没有可加入装柜清单的箱数");
|
||||
splitPreloadService.ensurePendingForPreAllocation(request.customerId(), inboundId,
|
||||
operatorId, operatorName);
|
||||
touch(plan, operatorId, operatorName);
|
||||
try {
|
||||
created.forEach(repository::insert);
|
||||
} catch (DuplicateKeyException exception) {
|
||||
throw conflict("该入仓码已被并发加入其他柜次");
|
||||
}
|
||||
history(planId, null, "ADD_PENDING_REVIEW_ORDER", plan.getPlanStatus(), plan.getPlanStatus(),
|
||||
operation, json(values("version", request.expectedVersion(), "orderId", request.orderId(),
|
||||
"inboundCode", request.inboundCode())),
|
||||
json(values("requestHash", requestHash, "inboundId", inboundId,
|
||||
"packageCount", request.packageCount(), "provisional", true)), operatorId, operatorName);
|
||||
loadingModelBridge.bindIncrementalAllocation(planId, request.customerId(), inboundId,
|
||||
operatorId, operatorName, operation);
|
||||
created.forEach(item -> loadingPickService.sync(planId, item));
|
||||
return view(repository.plan(planId), booking);
|
||||
}
|
||||
|
||||
private Plan assignInbound(long planId, AssignInboundRequest request,
|
||||
long operatorId, String operatorName, boolean wholeGroupAssignment) {
|
||||
String operation = operation(request.operationId());
|
||||
@@ -902,6 +1011,85 @@ public class SmartLoadingService {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
private PendingReviewOrderClient.Row requirePendingReviewOrder(AssignPendingReviewRequest request) {
|
||||
try {
|
||||
ApiResponse<PageResult<PendingReviewOrderClient.Row>> response = pendingReviewOrders.page(
|
||||
internalToken, null, null, request.customerId(), request.inboundCode(), null, null, 1, 2);
|
||||
if (response == null || !response.successful() || response.data() == null) throw unavailable();
|
||||
return response.data().records().stream()
|
||||
.filter(row -> Objects.equals(row.orderId(), request.orderId())
|
||||
&& Objects.equals(row.customerId(), request.customerId())
|
||||
&& Objects.equals(row.inboundCode(), request.inboundCode())
|
||||
&& "PENDING_REVIEW".equals(row.mainStatus()))
|
||||
.findFirst().orElseThrow(() -> conflict("订单状态或入仓码已变化,请刷新候选后重试"));
|
||||
} catch (BizException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
private com.baiyue.firstleg.dto.OrderApprovedInboundCommand loadInboundSnapshot(long customerId,
|
||||
long orderId) {
|
||||
try {
|
||||
ApiResponse<com.baiyue.firstleg.dto.OrderApprovedInboundCommand> response =
|
||||
orderCargoClient.inboundSnapshot(internalToken, customerId, orderId);
|
||||
if (response == null || !response.successful() || response.data() == null) throw unavailable();
|
||||
return response.data();
|
||||
} catch (BizException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
private List<DeclaredCargo> loadDeclaredCargo(long customerId, long orderId) {
|
||||
try {
|
||||
ApiResponse<List<OrderCargoClient.CargoLabelBinding>> response =
|
||||
orderCargoClient.listForCartonLabels(internalToken, customerId, orderId);
|
||||
if (response == null || !response.successful() || response.data() == null
|
||||
|| response.data().isEmpty()) throw conflict("订单没有可加入装柜清单的货物");
|
||||
List<DeclaredCargo> result = new ArrayList<>();
|
||||
for (OrderCargoClient.CargoLabelBinding label : response.data()) {
|
||||
if (label.cargoId() == null || label.packageCount() == null || label.packageCount() <= 0) {
|
||||
throw conflict("订单货物箱数不完整,请先修正订单");
|
||||
}
|
||||
ApiResponse<OrderCargoClient.CargoBinding> detail = orderCargoClient.detail(
|
||||
internalToken, customerId, orderId, label.cargoId());
|
||||
if (detail == null || !detail.successful() || detail.data() == null
|
||||
|| !Objects.equals(detail.data().cargoId(), label.cargoId())
|
||||
|| !Objects.equals(detail.data().orderId(), orderId)) {
|
||||
throw conflict("订单货物明细已变化,请刷新后重试");
|
||||
}
|
||||
result.add(new DeclaredCargo(detail.data()));
|
||||
}
|
||||
return List.copyOf(result);
|
||||
} catch (BizException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
private Candidate declaredCandidate(PendingReviewOrderClient.Row pending,
|
||||
com.baiyue.firstleg.dto.OrderApprovedInboundCommand snapshot,
|
||||
long inboundId, OrderCargoClient.CargoBinding cargo) {
|
||||
int boxes = cargo.originalPackageCount() == null ? 0 : cargo.originalPackageCount();
|
||||
if (boxes <= 0) throw conflict("订单货物箱数不完整,请先修正订单");
|
||||
BigDecimal divisor = BigDecimal.valueOf(boxes);
|
||||
BigDecimal unitGross = unit(cargo.originalGrossWeightKg()).divide(divisor, 6, RoundingMode.HALF_UP);
|
||||
BigDecimal unitNet = unit(cargo.netWeightKg()).divide(divisor, 6, RoundingMode.HALF_UP);
|
||||
BigDecimal unitVolume = unit(cargo.originalVolumeCbm()).divide(divisor, 6, RoundingMode.HALF_UP);
|
||||
return new Candidate(snapshot.tenantId(), inboundId, cargo.cargoId(), snapshot.orderId(),
|
||||
snapshot.orderNo(), snapshot.inboundCode(), snapshot.warehouseId(), snapshot.channelCode(),
|
||||
snapshot.channelName(), snapshot.destinationWarehouseId(), snapshot.destinationCode(),
|
||||
snapshot.destinationName(), snapshot.customerName(), cargo.cargoName(), boxes,
|
||||
unitGross, unitNet, unitVolume, null, null, null, null, false, null,
|
||||
pending.mainStatus(), snapshot.expectedArrivalAt());
|
||||
}
|
||||
|
||||
private record DeclaredCargo(OrderCargoClient.CargoBinding cargo) {}
|
||||
private void checkRouteAndCapacity(ContainerBookingEntity b,List<LoadingPlanItemEntity> lines,Candidate c,
|
||||
int count, boolean requireSameDestination){
|
||||
requireSameChannel(b, c);
|
||||
|
||||
@@ -72,6 +72,36 @@ public class SplitPreloadService {
|
||||
ensureLoadingAllocationLifecycle(tenantId, inboundId, operatorId, operatorName, false);
|
||||
}
|
||||
|
||||
/** Creates a provisional preload lifecycle for an order selected before review/measurement. */
|
||||
@Transactional
|
||||
public void ensurePendingForPreAllocation(long tenantId, long inboundId, long operatorId,
|
||||
String operatorName) {
|
||||
InboundSnapshot inbound = requireInbound(tenantId, inboundId);
|
||||
if (!Set.of("PREPARING", "WAIT_MEASURE", "WAIT_SPLIT").contains(inbound.processStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "订单当前状态不能加入装柜清单");
|
||||
}
|
||||
PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId);
|
||||
if (plan != null) {
|
||||
if (!PreloadStatus.PENDING.equals(plan.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "预装计划已进入确认流程,不能修改智能装柜分配");
|
||||
}
|
||||
return;
|
||||
}
|
||||
plan = new PreloadPlanEntity();
|
||||
plan.setTenantId(tenantId); plan.setInboundId(inboundId); plan.setStatus(PreloadStatus.PENDING);
|
||||
plan.setVersion(0); plan.setItemsJson("[]"); plan.setRemark("订单审核前加入装柜清单自动建立");
|
||||
plan.setCreatedBy(operatorId); plan.setCreatedByName(operatorName);
|
||||
plan.setUpdatedBy(operatorId); plan.setUpdatedByName(operatorName);
|
||||
try {
|
||||
repository.createPlan(plan);
|
||||
} catch (DuplicateKeyException exception) {
|
||||
PreloadPlanEntity concurrent = repository.findPlan(tenantId, inboundId);
|
||||
if (concurrent == null || !PreloadStatus.PENDING.equals(concurrent.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "预装计划已被并发修改,请刷新后重试");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A running PDA task may restore or add an unpicked allocation after the plan-level preload
|
||||
* confirmation. A running PDA task owns the mutation lock, so CONFIRMING and CONFIRMED
|
||||
|
||||
@@ -489,9 +489,10 @@ paths:
|
||||
预计到货日期和订单状态共同作用于全局候选。每个普通入库单返回一行;同柜不可拆组作为一个
|
||||
分页单元返回,groupMembers 包含完整成员,不会跨页或因单个成员命中搜索条件而拆散。
|
||||
平均单箱毛重、净重按整票实测重量除以总箱数,平均单箱体积按剩余规格数量加权。
|
||||
只返回当前具备实际装柜候选资格的 WAIT_SPLIT 数据;PENDING_REVIEW、WAIT_MEASURE、LOADING
|
||||
是统一筛选枚举,但在候选接口中返回空页。LOADED、COMPLETED 与已锁定或成员不完整的组永不返回。
|
||||
分配时仍由目标 planId 接口复核真实渠道、仓库、容量、组锁和并发版本。
|
||||
WAIT_SPLIT 返回 Firstleg 可用箱数;PENDING_REVIEW 返回 Order 权威待审核订单并允许先加入装柜清单。
|
||||
订单审核状态只用于筛选和展示,不直接决定能否占位;已被有效柜次占用、LOADED、COMPLETED、
|
||||
已锁定或成员不完整的组永不返回。分配时仍由目标 planId 接口复核真实渠道、仓库、容量、
|
||||
组锁和并发版本;确认预装/PDA 前必须完成审核与测量并进入 WAIT_SPLIT。
|
||||
parameters:
|
||||
- name: channelGroupCodes
|
||||
in: query
|
||||
@@ -511,8 +512,8 @@ paths:
|
||||
- {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: createdStart, in: query, required: false, schema: {type: string, format: date}}
|
||||
- {name: createdEnd, 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}}
|
||||
@@ -525,7 +526,7 @@ paths:
|
||||
summary: 分页查询智能装柜工作台柜次
|
||||
description: >-
|
||||
需要 platform:firstleg:smart-loading:list 权限。与全局候选使用相同的渠道、仓库、客户、入仓码、
|
||||
预计到货日期及订单状态筛选;客户及入仓事实以柜内任一有效装柜明细命中为准,查询不会修改或拆散同柜组。
|
||||
订单创建日期及订单状态筛选;客户及入仓事实以柜内任一有效装柜明细命中为准,查询不会修改或拆散同柜组。
|
||||
只返回 WAIT_LOADING、EDITING 或 LOADING 的 ACTIVE 柜次,COMPLETED 永不返回。
|
||||
parameters:
|
||||
- name: channelGroupCodes
|
||||
@@ -540,8 +541,8 @@ paths:
|
||||
- {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: createdStart, in: query, required: false, schema: {type: string, format: date}}
|
||||
- {name: createdEnd, 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}}
|
||||
@@ -613,6 +614,22 @@ paths:
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingInboundAssignRequest'}}}
|
||||
responses:
|
||||
'200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan}
|
||||
/api/platform/v1/first-leg/smart-loading/plans/{planId}/pending-review-assignments:
|
||||
post:
|
||||
summary: 将待审核订单加入装柜清单
|
||||
description: >-
|
||||
需要 platform:firstleg:smart-loading:assign 权限。订单审核状态不作为清单占位门禁;服务端以
|
||||
customerId + orderId + inboundCode 重新核验 Order 权威待审核事实,幂等创建或复用 PREPARING
|
||||
入仓投影,并按订单申报箱数、重量和体积建立柜次占位。已被其他有效柜次占用、仓库/渠道不一致、
|
||||
超容量或订单事实变化返回业务冲突且事务零漂移。清单中存在 PREPARING/WAIT_MEASURE 订单时不得
|
||||
确认预装或启动 PDA,审核测量完成并进入 WAIT_SPLIT 后才可继续物理装柜。
|
||||
parameters:
|
||||
- {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
requestBody:
|
||||
required: true
|
||||
content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingPendingReviewAssignRequest'}}}
|
||||
responses:
|
||||
'200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan}
|
||||
/api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}:
|
||||
patch:
|
||||
summary: 调整预装计划明细箱数
|
||||
@@ -1878,6 +1895,16 @@ components:
|
||||
packageCount: {type: integer, minimum: 1, description: 该入仓码本次计划装柜总箱数}
|
||||
expectedVersion: {type: integer, minimum: 0, description: 当前 planVersion}
|
||||
operationId: {type: string, minLength: 1, maxLength: 128}
|
||||
SmartLoadingPendingReviewAssignRequest:
|
||||
type: object
|
||||
required: [customerId, orderId, inboundCode, packageCount, expectedVersion, operationId]
|
||||
properties:
|
||||
customerId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
orderId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
inboundCode: {type: string, minLength: 1, maxLength: 40}
|
||||
packageCount: {type: integer, minimum: 1, description: 待审核订单本次占用的申报箱数}
|
||||
expectedVersion: {type: integer, minimum: 0, description: 当前 planVersion}
|
||||
operationId: {type: string, minLength: 1, maxLength: 128}
|
||||
SmartLoadingAdjustRequest:
|
||||
type: object
|
||||
required: [packageCount, expectedPlanVersion, expectedItemVersion, operationId]
|
||||
@@ -1959,7 +1986,7 @@ components:
|
||||
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}
|
||||
inboundId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 待审核订单尚无入仓投影时为空;普通可装柜候选为入仓技术ID}
|
||||
orderId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
orderNo: {type: string}
|
||||
inboundCode: {type: string}
|
||||
@@ -1972,12 +1999,12 @@ components:
|
||||
destinationWarehouseCode: {type: string, nullable: true}
|
||||
destinationWarehouseName: {type: string, nullable: true}
|
||||
customerName: {type: string}
|
||||
goodsSummary: {type: string}
|
||||
goodsSummary: {type: string, nullable: true}
|
||||
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}
|
||||
unitGrossWeightKg: {type: number, minimum: 0, nullable: true}
|
||||
unitNetWeightKg: {type: number, minimum: 0, nullable: true}
|
||||
averageUnitVolumeCbm: {type: number, minimum: 0, nullable: true}
|
||||
availableVolumeCbm: {type: number, minimum: 0, nullable: true}
|
||||
coLoadGroupId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
|
||||
groupNo: {type: string, nullable: true}
|
||||
memberCount: {type: integer, minimum: 2, nullable: true}
|
||||
|
||||
+8
@@ -32,12 +32,17 @@ class PlatformSmartLoadingControllerContractTest {
|
||||
yaml.indexOf("/api/platform/v1/first-leg/loading-associations/summary:"));
|
||||
assertThat(contract).contains("name: channelGroupCodes")
|
||||
.contains("name: warehouseId")
|
||||
.contains("name: createdStart")
|
||||
.contains("name: createdEnd")
|
||||
.contains("SmartLoadingInboundCandidatePageEnvelope")
|
||||
.doesNotContain("name: expectedArrivalStart")
|
||||
.doesNotContain("name: expectedArrivalEnd")
|
||||
.doesNotContain("name: bookingId");
|
||||
}
|
||||
@Test void everyMutationDeclaresDedicatedPermission() throws Exception {
|
||||
authority("assign","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignRequest.class);
|
||||
authority("assignInbound","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignInboundRequest.class);
|
||||
authority("assignPendingReview","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignPendingReviewRequest.class);
|
||||
authority("assignGroup","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignGroupRequest.class);
|
||||
authority("removeGroup","platform:firstleg:smart-loading:delete",AuthenticatedUser.class,long.class,long.class,RemoveGroupRequest.class);
|
||||
authority("recommend","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,RecommendRequest.class);
|
||||
@@ -55,7 +60,10 @@ class PlatformSmartLoadingControllerContractTest {
|
||||
assertThat(yaml)
|
||||
.contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/assignments:")
|
||||
.contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/inbound-assignments:")
|
||||
.contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/pending-review-assignments:")
|
||||
.contains("SmartLoadingInboundAssignRequest:")
|
||||
.contains("SmartLoadingPendingReviewAssignRequest:")
|
||||
.contains("订单审核状态不作为清单占位门禁")
|
||||
.contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}:")
|
||||
.contains("SmartLoadingAssignRequest:")
|
||||
.contains("SmartLoadingAdjustRequest:")
|
||||
|
||||
+19
-6
@@ -47,7 +47,7 @@ class LoadingModelBridgeServiceTest {
|
||||
@Test
|
||||
void bindsEveryAllocatedInboundBeforeLoadingStarts() {
|
||||
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 3));
|
||||
when(repository.allocatedScopes(40)).thenReturn(List.of(new AllocationScope(10L, 20L, 8)));
|
||||
when(repository.allocatedScopes(40)).thenReturn(List.of(new AllocationScope(10L, 20L, 8, "WAIT_SPLIT")));
|
||||
when(repository.preload(10, 20)).thenReturn(new PreloadSnapshot(30L, 10L, 20L, "PENDING", 2));
|
||||
when(repository.allocatedPackages(10, 20, 40)).thenReturn(8);
|
||||
when(repository.touchPreload(30, 10, 20, 2, 7, "admin")).thenReturn(1);
|
||||
@@ -69,7 +69,7 @@ class LoadingModelBridgeServiceTest {
|
||||
var plan = new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 9);
|
||||
when(repository.loadingPlan(40)).thenReturn(plan);
|
||||
when(repository.allocatedScopes(40)).thenReturn(List.of(
|
||||
new AllocationScope(10L, 20L, 8), new AllocationScope(11L, 21L, 4)));
|
||||
new AllocationScope(10L, 20L, 8, "WAIT_SPLIT"), new AllocationScope(11L, 21L, 4, "WAIT_SPLIT")));
|
||||
when(repository.byPair(10, 20, 40)).thenReturn(new PreloadLoadingPlanLinkEntity());
|
||||
when(repository.byPair(11, 21, 40)).thenReturn(new PreloadLoadingPlanLinkEntity());
|
||||
Link first = link("PENDING", 3, "EDITING", 9, 8);
|
||||
@@ -88,7 +88,7 @@ class LoadingModelBridgeServiceTest {
|
||||
void editingReplaySkipsExistingPairAndCreatesOnlyMissingPair() {
|
||||
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 3));
|
||||
when(repository.allocatedScopes(40)).thenReturn(List.of(
|
||||
new AllocationScope(10L, 20L, 8), new AllocationScope(11L, 21L, 4)));
|
||||
new AllocationScope(10L, 20L, 8, "WAIT_SPLIT"), new AllocationScope(11L, 21L, 4, "WAIT_SPLIT")));
|
||||
when(repository.byPair(10, 20, 40)).thenReturn(new PreloadLoadingPlanLinkEntity());
|
||||
when(repository.byPair(11, 21, 40)).thenReturn(null);
|
||||
when(repository.preload(11, 21)).thenReturn(new PreloadSnapshot(31L, 11L, 21L, "PENDING", 5));
|
||||
@@ -115,7 +115,7 @@ class LoadingModelBridgeServiceTest {
|
||||
@Test
|
||||
void refusesPostHocBindingAfterLoadingStarts() {
|
||||
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "LOADING", 4));
|
||||
when(repository.allocatedScopes(40)).thenReturn(List.of(new AllocationScope(10L, 20L, 8)));
|
||||
when(repository.allocatedScopes(40)).thenReturn(List.of(new AllocationScope(10L, 20L, 8, "WAIT_SPLIT")));
|
||||
|
||||
assertThatThrownBy(() -> service.bindAllocatedBeforeLoading(40, 7, "admin", "push-1"))
|
||||
.hasMessageContaining("禁止在LOADING后补关联");
|
||||
@@ -126,7 +126,7 @@ class LoadingModelBridgeServiceTest {
|
||||
@Test
|
||||
void loadingReplayOnlyReadsAlreadyCompleteLinks() {
|
||||
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "LOADING", 4));
|
||||
when(repository.allocatedScopes(40)).thenReturn(List.of(new AllocationScope(10L, 20L, 8)));
|
||||
when(repository.allocatedScopes(40)).thenReturn(List.of(new AllocationScope(10L, 20L, 8, "WAIT_SPLIT")));
|
||||
PreloadLoadingPlanLinkEntity existing = new PreloadLoadingPlanLinkEntity();
|
||||
when(repository.byPair(10, 20, 40)).thenReturn(existing);
|
||||
Link linked = link("PENDING", 3, "LOADING", 4, 8);
|
||||
@@ -171,7 +171,7 @@ class LoadingModelBridgeServiceTest {
|
||||
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(
|
||||
40L, 50L, "C1", "EDITING", 3));
|
||||
when(repository.allocatedScopes(40)).thenReturn(List.of(
|
||||
new AllocationScope(10L, 20L, 8), new AllocationScope(11L, 21L, 4)));
|
||||
new AllocationScope(10L, 20L, 8, "WAIT_SPLIT"), new AllocationScope(11L, 21L, 4, "WAIT_SPLIT")));
|
||||
when(repository.byLoadingPlan(40)).thenReturn(List.of(
|
||||
link("PENDING", 2, "EDITING", 3, 8)));
|
||||
|
||||
@@ -179,6 +179,19 @@ class LoadingModelBridgeServiceTest {
|
||||
.hasMessageContaining("关联不完整");
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmationGateRejectsProvisionalPendingReviewAllocation() {
|
||||
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(
|
||||
40L, 50L, "C1", "EDITING", 3));
|
||||
when(repository.allocatedScopes(40)).thenReturn(List.of(
|
||||
new AllocationScope(10L, 20L, 8, "PREPARING")));
|
||||
|
||||
assertThatThrownBy(() -> service.requireAllocatedLinksForConfirmation(40))
|
||||
.hasMessageContaining("未完成审核和测量");
|
||||
|
||||
verify(repository, never()).byLoadingPlan(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void terminalGateRequiresEveryLinkedContainerPlanCompleted() {
|
||||
when(repository.byInbound(10, 20)).thenReturn(List.of(
|
||||
|
||||
+15
-15
@@ -86,35 +86,34 @@ class SmartLoadingCandidateMapperContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void globalCandidateDateFilterUsesTheSameRepresentativeArrivalAsTheReturnedUnit() throws Exception {
|
||||
void globalCandidateDateFilterUsesTheRepresentativeOrderCreationDate() throws Exception {
|
||||
Method units = SmartLoadingCandidateMapper.class.getDeclaredMethod("globalCandidateUnits",
|
||||
Page.class, InboundCandidateQuery.class, Set.class);
|
||||
String sql = String.join(" ", units.getAnnotation(Select.class).value());
|
||||
|
||||
assertThat(sql)
|
||||
.contains("MIN(eligible.expectedArrivalAt) sortArrivalAt")
|
||||
.contains("MIN(eligible.expectedArrivalAt) IS NOT NULL")
|
||||
.contains("DATE(MIN(eligible.expectedArrivalAt))>=#{query.expectedArrivalStart}")
|
||||
.contains("DATE(MIN(eligible.expectedArrivalAt))<=#{query.expectedArrivalEnd}")
|
||||
.doesNotContain("SUM(CASE WHEN eligible.expectedArrivalAt IS NOT NULL");
|
||||
.contains("o.created_at createdAt")
|
||||
.contains("DATE(MIN(eligible.createdAt))>=#{query.createdStart}")
|
||||
.contains("DATE(MIN(eligible.createdAt))<=#{query.createdEnd}")
|
||||
.doesNotContain("query.expectedArrivalStart", "query.expectedArrivalEnd");
|
||||
}
|
||||
|
||||
@Test
|
||||
void representativeArrivalControlsWhetherTheWholeGroupMatchesTheClosedDateRange() {
|
||||
List<LocalDateTime> groupArrivals = List.of(
|
||||
void representativeCreationDateControlsWhetherTheWholeGroupMatchesTheClosedDateRange() {
|
||||
List<LocalDateTime> groupCreatedTimes = List.of(
|
||||
LocalDateTime.parse("2026-09-01T09:00:00"),
|
||||
LocalDateTime.parse("2026-09-10T09:00:00"));
|
||||
|
||||
LocalDate representative = groupArrivals.stream().min(LocalDateTime::compareTo)
|
||||
LocalDate representative = groupCreatedTimes.stream().min(LocalDateTime::compareTo)
|
||||
.orElseThrow().toLocalDate();
|
||||
|
||||
assertThat(matchesClosedRange(representative,
|
||||
LocalDate.parse("2026-09-10"), LocalDate.parse("2026-09-10")))
|
||||
.as("another member matching must not admit a group whose MIN arrival is outside")
|
||||
.as("another member matching must not admit a group whose MIN creation date is outside")
|
||||
.isFalse();
|
||||
assertThat(matchesClosedRange(representative,
|
||||
LocalDate.parse("2026-09-01"), LocalDate.parse("2026-09-01")))
|
||||
.as("a matching MIN arrival admits the whole group")
|
||||
.as("a matching MIN creation date admits the whole group")
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
@@ -131,9 +130,9 @@ class SmartLoadingCandidateMapperContractTest {
|
||||
String renderedUnits = driver.createSqlSource(configuration, unitScript, Map.class)
|
||||
.getBoundSql(Map.of("query", query, "actualChannels", Set.of("MATSON_CLX_TRUCK"))).getSql();
|
||||
assertThat(renderedUnits)
|
||||
.contains("o.channel_code IN", "MIN(eligible.expectedArrivalAt) IS NOT NULL")
|
||||
.contains("DATE(MIN(eligible.expectedArrivalAt))>=", "DATE(MIN(eligible.expectedArrivalAt))<=")
|
||||
.doesNotContain("SUM(CASE WHEN eligible.expectedArrivalAt IS NOT NULL");
|
||||
.contains("o.channel_code IN")
|
||||
.contains("DATE(MIN(eligible.createdAt))>=", "DATE(MIN(eligible.createdAt))<=")
|
||||
.doesNotContain("DATE(MIN(eligible.expectedArrivalAt))");
|
||||
|
||||
Method details = SmartLoadingCandidateMapper.class.getDeclaredMethod(
|
||||
"globalCandidatesByUnits", List.class);
|
||||
@@ -158,8 +157,9 @@ class SmartLoadingCandidateMapperContractTest {
|
||||
.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.created_at")
|
||||
.contains("filter_inbound.process_status='LOADING'")
|
||||
.doesNotContain("query.expectedArrivalStart", "query.expectedArrivalEnd")
|
||||
.doesNotContain("p.plan_status IN ('EDITING','LOADING','COMPLETED')");
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.client.OrderCargoClient;
|
||||
import com.baiyue.firstleg.client.PendingReviewOrderClient;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.AssignRequest;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.AssignPendingReviewRequest;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.AdjustRequest;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate;
|
||||
import com.baiyue.firstleg.dto.SmartLoadingDtos.CandidateOrderStatus;
|
||||
@@ -68,6 +70,7 @@ import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anySet;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
@@ -81,6 +84,8 @@ import static org.mockito.Mockito.times;
|
||||
class SmartLoadingServiceTest {
|
||||
@MockBean private SmartLoadingRepository repository;
|
||||
@MockBean private OrderCargoClient cargoClient;
|
||||
@MockBean private PendingReviewOrderClient pendingReviewOrders;
|
||||
@MockBean private InboundOrderCreationService inboundOrderCreationService;
|
||||
@MockBean private LoadingPickService loadingPickService;
|
||||
@MockBean private LoadingModelBridgeService loadingModelBridge;
|
||||
@MockBean private SplitPreloadService splitPreloadService;
|
||||
@@ -207,6 +212,97 @@ class SmartLoadingServiceTest {
|
||||
verify(repository, never()).booking(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pagesPendingReviewOrdersFromTheOrderOwnedReadModelAsAssignableRows() {
|
||||
InboundCandidateQuery query = new InboundCandidateQuery(
|
||||
List.of("EXX"), 193000000000200002L, null, "EXXHK2609020361",
|
||||
LocalDate.parse("2026-09-02"), LocalDate.parse("2026-09-02"),
|
||||
CandidateOrderStatus.PENDING_REVIEW, 1, 20);
|
||||
var row = new PendingReviewOrderClient.Row(2095052002489335810L, "FL-361",
|
||||
"EXXHK2609020361", 2092797167699816450L, "客户", 193000000000200002L,
|
||||
"EXX_TRUCK", "EXX海卡", 100, LocalDateTime.parse("2026-09-02T15:31:36"),
|
||||
"PENDING_REVIEW");
|
||||
when(pendingReviewOrders.page(anyString(), anyList(), eq(193000000000200002L),
|
||||
isNull(), eq("EXXHK2609020361"), eq(LocalDate.parse("2026-09-02")),
|
||||
eq(LocalDate.parse("2026-09-02")), eq(1L), eq(20L)))
|
||||
.thenReturn(ApiResponse.success(new PageResult<>(List.of(row), 1, 20, 1, 1)));
|
||||
|
||||
var result = service.inboundCandidates(query);
|
||||
|
||||
assertThat(result.records()).singleElement().satisfies(candidate -> {
|
||||
assertThat(candidate.orderId()).isEqualTo(2095052002489335810L);
|
||||
assertThat(candidate.inboundId()).isNull();
|
||||
assertThat(candidate.inboundCode()).isEqualTo("EXXHK2609020361");
|
||||
assertThat(candidate.orderStatusSnapshot()).isEqualTo("PENDING_REVIEW");
|
||||
assertThat(candidate.blockReason()).isNull();
|
||||
assertThat(candidate.availablePackageCount()).isEqualTo(100);
|
||||
});
|
||||
verify(repository, never()).globalCandidateUnits(any(), anySet());
|
||||
verify(repository, never()).globalCandidatesByUnits(anyList());
|
||||
}
|
||||
|
||||
@Test
|
||||
void assignsPendingReviewOrderAsAProvisionalContainerAllocation() {
|
||||
long customerId = 2092797167699816450L;
|
||||
long orderId = 2095052002489335810L;
|
||||
String inboundCode = "EXXHK2609020361";
|
||||
var pending = new PendingReviewOrderClient.Row(orderId, "FL-361", inboundCode,
|
||||
customerId, "客户", 100L, "FBA", "FBA渠道", 5,
|
||||
LocalDateTime.parse("2026-09-02T15:31:36"), "PENDING_REVIEW");
|
||||
var snapshot = new com.baiyue.firstleg.dto.OrderApprovedInboundCommand(
|
||||
customerId, orderId, "FL-361", inboundCode, customerId, "客户", 100L, "义乌仓",
|
||||
null, null, null, "FIRST_LEG", "FBA", "FBA渠道", null, null, null,
|
||||
null, null, false, "FBA", 200L, "FBA1", "FBA仓", null, null, null, 5);
|
||||
var label = new OrderCargoClient.CargoLabelBinding(20L, orderId, 1, "货物", 5,
|
||||
null, null, null);
|
||||
var cargo = new OrderCargoClient.CargoBinding(20L, orderId, 1, "货物", 5,
|
||||
new BigDecimal("50"), new BigDecimal("2.5"));
|
||||
when(pendingReviewOrders.page(anyString(), isNull(), isNull(), eq(customerId),
|
||||
eq(inboundCode), isNull(), isNull(), eq(1L), eq(2L)))
|
||||
.thenReturn(ApiResponse.success(new PageResult<>(List.of(pending), 1, 2, 1, 1)));
|
||||
when(cargoClient.inboundSnapshot(anyString(), eq(customerId), eq(orderId)))
|
||||
.thenReturn(ApiResponse.success(snapshot));
|
||||
when(inboundOrderCreationService.prepareFromDraft(snapshot)).thenReturn(10L);
|
||||
when(cargoClient.listForCartonLabels(anyString(), eq(customerId), eq(orderId)))
|
||||
.thenReturn(ApiResponse.success(List.of(label)));
|
||||
when(cargoClient.detail(anyString(), eq(customerId), eq(orderId), eq(20L)))
|
||||
.thenReturn(ApiResponse.success(cargo));
|
||||
when(repository.plan(1L)).thenReturn(plan("EDITING", 0), plan("EDITING", 1));
|
||||
when(repository.booking(99L)).thenReturn(booking());
|
||||
InboundOrderEntity lockedInbound = new InboundOrderEntity();
|
||||
lockedInbound.setProcessStatus("PREPARING");
|
||||
when(repository.inboundForUpdate(customerId, 10L)).thenReturn(lockedInbound);
|
||||
when(repository.items(1L)).thenReturn(List.of());
|
||||
when(repository.touch(1L, 0, 7L, "u")).thenReturn(1);
|
||||
|
||||
Plan result = service.assignPendingReview(1L,
|
||||
new AssignPendingReviewRequest(customerId, orderId, inboundCode, 5, 0, "pending-1"),
|
||||
7L, "u");
|
||||
|
||||
assertThat(result.version()).isEqualTo(1);
|
||||
ArgumentCaptor<LoadingPlanItemEntity> item = ArgumentCaptor.forClass(LoadingPlanItemEntity.class);
|
||||
verify(repository).insert(item.capture());
|
||||
assertThat(item.getValue().getInboundId()).isEqualTo(10L);
|
||||
assertThat(item.getValue().getInboundDetailId()).isEqualTo(20L);
|
||||
assertThat(item.getValue().getPackageCount()).isEqualTo(5);
|
||||
assertThat(item.getValue().getGrossWeightKg()).isEqualByComparingTo("50");
|
||||
verify(splitPreloadService).ensurePendingForPreAllocation(customerId, 10L, 7L, "u");
|
||||
verify(loadingModelBridge).bindIncrementalAllocation(1L, customerId, 10L, 7L, "u", "pending-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsClosedWhenPendingReviewOrderSourceIsUnavailable() {
|
||||
InboundCandidateQuery query = new InboundCandidateQuery(
|
||||
List.of("EXX"), null, null, null, null, null,
|
||||
CandidateOrderStatus.PENDING_REVIEW, 1, 20);
|
||||
when(pendingReviewOrders.page(anyString(), anyList(), isNull(), isNull(), isNull(),
|
||||
isNull(), isNull(), eq(1L), eq(20L))).thenThrow(new RuntimeException("offline"));
|
||||
|
||||
assertThatThrownBy(() -> service.inboundCandidates(query))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessage("待审核订单服务暂时不可用");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pagesWorkbenchContainersWithTheSameGlobalFilters() {
|
||||
WorkbenchContainer container = new WorkbenchContainer(99L, "CB-1", "CAB-1", null,
|
||||
|
||||
@@ -69,6 +69,20 @@ class SplitPreloadServiceTest {
|
||||
assertThat(captor.getValue().getRemark()).isEqualTo("智能装柜分配自动建立");
|
||||
}
|
||||
|
||||
@Test
|
||||
void pendingReviewPreAllocationCreatesAProvisionalPreloadLifecycle() {
|
||||
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "PREPARING", 0));
|
||||
when(repository.findPlan(10, 20)).thenReturn(null);
|
||||
|
||||
service.ensurePendingForPreAllocation(10, 20, 7, "admin");
|
||||
|
||||
org.mockito.ArgumentCaptor<PreloadPlanEntity> captor =
|
||||
org.mockito.ArgumentCaptor.forClass(PreloadPlanEntity.class);
|
||||
verify(repository).createPlan(captor.capture());
|
||||
assertThat(captor.getValue().getStatus()).isEqualTo("PENDING");
|
||||
assertThat(captor.getValue().getRemark()).contains("审核前");
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadingAllocationDoesNotOverwriteConfirmedPreload() {
|
||||
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 3));
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.baiyue.order.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.dto.PendingReviewOrderDtos;
|
||||
import com.baiyue.order.service.PendingReviewOrderQueryService;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
/** Firstleg 智能装柜读取尚未审核订单的内部入口。 */
|
||||
@RestController
|
||||
@RequestMapping("/internal/order/v1/pending-review-orders")
|
||||
public class InternalPendingReviewOrderController {
|
||||
@Resource private PendingReviewOrderQueryService service;
|
||||
@Value("${baiyue.security.internal-service-token}") private String configuredToken;
|
||||
private byte[] expectedToken;
|
||||
|
||||
@PostConstruct
|
||||
void initializeToken() {
|
||||
expectedToken = configuredToken.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<PageResult<PendingReviewOrderDtos.Row>> page(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@Valid @ModelAttribute PendingReviewOrderDtos.Query query) {
|
||||
requireToken(token);
|
||||
return ApiResponse.success(service.page(query));
|
||||
}
|
||||
|
||||
private void requireToken(String token) {
|
||||
byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8);
|
||||
if (!MessageDigest.isEqual(expectedToken, supplied)) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.baiyue.order.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** Order 向履约服务提供的待审核订单权威只读契约。 */
|
||||
public final class PendingReviewOrderDtos {
|
||||
private PendingReviewOrderDtos() {
|
||||
}
|
||||
|
||||
public record Query(
|
||||
List<@Size(max = 64) String> channelCodes,
|
||||
@Min(1) Long warehouseId,
|
||||
@Min(1) Long customerId,
|
||||
@Size(max = 40) String inboundCode,
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate createdStart,
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate createdEnd,
|
||||
@Min(1) long page,
|
||||
@Min(1) @Max(200) long size
|
||||
) {
|
||||
@AssertTrue(message = "创建日期起始日不能晚于结束日")
|
||||
public boolean isCreatedRangeValid() {
|
||||
return createdStart == null || createdEnd == null || !createdStart.isAfter(createdEnd);
|
||||
}
|
||||
}
|
||||
|
||||
public record Row(
|
||||
@Schema(type = "string", pattern = "^[1-9][0-9]*$") Long orderId,
|
||||
String orderNo,
|
||||
String inboundCode,
|
||||
@Schema(type = "string", pattern = "^[1-9][0-9]*$") Long customerId,
|
||||
String customerName,
|
||||
@Schema(type = "string", pattern = "^[1-9][0-9]*$") Long warehouseId,
|
||||
String channelCode,
|
||||
String channelName,
|
||||
int totalPackageCount,
|
||||
LocalDateTime createdAt,
|
||||
String mainStatus
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -210,6 +210,26 @@ public class OrderRepository {
|
||||
return PageResult.from(orderMapper.selectPage(new Page<>(query.page(), query.size()), wrapper));
|
||||
}
|
||||
|
||||
/** Order 权威待审核页;只允许查询未删除的 SUBMITTED 头程订单。 */
|
||||
public PageResult<OrderEntity> pendingReviewPage(List<String> channelCodes, Long warehouseId,
|
||||
Long customerId, String inboundCode,
|
||||
LocalDateTime createdFrom,
|
||||
LocalDateTime createdBefore,
|
||||
long page, long size) {
|
||||
LambdaQueryWrapper<OrderEntity> wrapper = new LambdaQueryWrapper<OrderEntity>()
|
||||
.eq(OrderEntity::getDeleted, false)
|
||||
.eq(OrderEntity::getStatus, OrderStatus.SUBMITTED)
|
||||
.in(channelCodes != null && !channelCodes.isEmpty(), OrderEntity::getChannelCode, channelCodes)
|
||||
.eq(warehouseId != null, OrderEntity::getReceivingWarehouseId, warehouseId)
|
||||
.eq(customerId != null, OrderEntity::getCustomerId, customerId)
|
||||
.eq(StringUtils.hasText(inboundCode), OrderEntity::getInboundCode, inboundCode)
|
||||
.ge(createdFrom != null, OrderEntity::getCreatedAt, createdFrom)
|
||||
.lt(createdBefore != null, OrderEntity::getCreatedAt, createdBefore)
|
||||
.orderByDesc(OrderEntity::getCreatedAt)
|
||||
.orderByDesc(OrderEntity::getId);
|
||||
return PageResult.from(orderMapper.selectPage(new Page<>(page, size), wrapper));
|
||||
}
|
||||
|
||||
public PageResult<OrderEntity> merchantInboundPage(long tenantId, MerchantInboundOrderQuery query) {
|
||||
com.baiyue.order.entity.OrderStatus effectiveStatus = query.operationStatus() == null
|
||||
? query.status() : query.operationStatus();
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.baiyue.order.service;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.dto.PendingReviewOrderDtos;
|
||||
import com.baiyue.order.entity.OrderCargoEntity;
|
||||
import com.baiyue.order.repository.OrderRepository;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** 为 Firstleg 智能装柜提供 Order 权威待审核订单页。 */
|
||||
@Service
|
||||
public class PendingReviewOrderQueryService {
|
||||
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
|
||||
@Resource private OrderRepository orders;
|
||||
|
||||
public PageResult<PendingReviewOrderDtos.Row> page(PendingReviewOrderDtos.Query query) {
|
||||
if (query == null || query.page() < 1 || query.size() < 1 || query.size() > 200
|
||||
|| !query.isCreatedRangeValid()) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "待审核订单查询参数不正确");
|
||||
}
|
||||
List<String> channelCodes = normalizeChannelCodes(query.channelCodes());
|
||||
LocalDateTime createdFrom = query.createdStart() == null ? null
|
||||
: query.createdStart().atStartOfDay(BUSINESS_ZONE).toLocalDateTime();
|
||||
LocalDateTime createdBefore = query.createdEnd() == null ? null
|
||||
: query.createdEnd().plusDays(1).atStartOfDay(BUSINESS_ZONE).toLocalDateTime();
|
||||
var source = orders.pendingReviewPage(channelCodes, query.warehouseId(), query.customerId(),
|
||||
normalize(query.inboundCode()), createdFrom, createdBefore, query.page(), query.size());
|
||||
List<Long> orderIds = source.records().stream().map(order -> order.getId()).toList();
|
||||
Map<Long, Integer> packageCounts = orders.findCargoByOrderIds(null, orderIds).stream()
|
||||
.collect(Collectors.groupingBy(OrderCargoEntity::getOrderId,
|
||||
Collectors.summingInt(cargo -> cargo.getPackageCount() == null ? 0 : cargo.getPackageCount())));
|
||||
var rows = source.records().stream().map(order -> new PendingReviewOrderDtos.Row(
|
||||
order.getId(), order.getOrderNo(), order.getInboundCode(), order.getCustomerId(),
|
||||
order.getCustomerName(), order.getReceivingWarehouseId(), order.getChannelCode(),
|
||||
order.getChannelName(), packageCounts.getOrDefault(order.getId(), 0), order.getCreatedAt(),
|
||||
"PENDING_REVIEW")).toList();
|
||||
return new PageResult<>(rows, source.page(), source.size(), source.total(), source.pages());
|
||||
}
|
||||
|
||||
private static List<String> normalizeChannelCodes(List<String> values) {
|
||||
if (values == null || values.isEmpty()) return List.of();
|
||||
LinkedHashSet<String> normalized = new LinkedHashSet<>();
|
||||
for (String value : values) {
|
||||
if (!StringUtils.hasText(value) || value.trim().length() > 64) {
|
||||
throw new BizException(ResultCode.VALIDATION_FAILED, "渠道编码不能为空且不能超过64个字符");
|
||||
}
|
||||
normalized.add(value.trim());
|
||||
}
|
||||
return List.copyOf(normalized);
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
return StringUtils.hasText(value) ? value.trim() : null;
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,34 @@ info:
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
/internal/order/v1/pending-review-orders:
|
||||
get:
|
||||
security: []
|
||||
summary: Firstleg 分页读取 Order 权威待审核订单
|
||||
description: >-
|
||||
仅接受内部服务令牌。只返回 ord_order 中未删除且 status=SUBMITTED 的头程订单,
|
||||
mainStatus 固定为 PENDING_REVIEW。createdStart/createdEnd 按 Asia/Shanghai 自然日闭区间过滤
|
||||
ord_order.created_at,稳定按 created_at DESC、id DESC 分页;不读取或修复 Firstleg 投影。
|
||||
parameters:
|
||||
- name: X-Internal-Service-Token
|
||||
in: header
|
||||
required: true
|
||||
schema: {type: string}
|
||||
- {name: channelCodes, in: query, required: false, style: form, explode: true, schema: {type: array, items: {type: string, maxLength: 64}}}
|
||||
- {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: createdStart, in: query, required: false, schema: {type: string, format: date}}
|
||||
- {name: createdEnd, in: query, required: false, schema: {type: string, format: date}}
|
||||
- {name: page, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
||||
- {name: size, in: query, required: true, schema: {type: integer, format: int64, minimum: 1, maximum: 200}}
|
||||
responses:
|
||||
'200':
|
||||
description: Order 权威待审核订单页
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/PendingReviewOrderPageEnvelope'}
|
||||
'403': {description: 内部服务令牌无效}
|
||||
/internal/exports/v1/DOMESTIC_WAREHOUSE_ORDER_LIST/batches:
|
||||
post:
|
||||
security: []
|
||||
@@ -1001,6 +1029,35 @@ components:
|
||||
description: 前端外层行标识;同一批次内必须唯一,用于幂等重试和结果关联
|
||||
draft:
|
||||
$ref: '#/components/schemas/FirstLegOrderDraft'
|
||||
PendingReviewOrderPageEnvelope:
|
||||
type: object
|
||||
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/PendingReviewOrderRow'}}
|
||||
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}
|
||||
PendingReviewOrderRow:
|
||||
type: object
|
||||
required: [orderId, orderNo, inboundCode, customerId, customerName, warehouseId, channelCode, channelName, totalPackageCount, createdAt, mainStatus]
|
||||
properties:
|
||||
orderId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
orderNo: {type: string}
|
||||
inboundCode: {type: string}
|
||||
customerId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
customerName: {type: string}
|
||||
warehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
channelCode: {type: string}
|
||||
channelName: {type: string}
|
||||
totalPackageCount: {type: integer, minimum: 0}
|
||||
createdAt: {type: string, format: date-time}
|
||||
mainStatus: {type: string, enum: [PENDING_REVIEW]}
|
||||
FirstLegBatchDraftResponse:
|
||||
type: object
|
||||
required: [total, succeeded, failed, items]
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.baiyue.order.controller;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.dto.PendingReviewOrderDtos;
|
||||
import com.baiyue.order.service.PendingReviewOrderQueryService;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig(InternalPendingReviewOrderController.class)
|
||||
@TestPropertySource(properties = "baiyue.security.internal-service-token=0123456789abcdef0123456789abcdef")
|
||||
class InternalPendingReviewOrderControllerTest {
|
||||
@MockBean private PendingReviewOrderQueryService service;
|
||||
@Resource private InternalPendingReviewOrderController controller;
|
||||
|
||||
@Test
|
||||
void acceptsOnlyTheConfiguredInternalServiceToken() {
|
||||
var query = new PendingReviewOrderDtos.Query(List.of("SEA-US"), null, null, null, null, null, 1, 20);
|
||||
var page = new PageResult<PendingReviewOrderDtos.Row>(List.of(), 1, 20, 0, 0);
|
||||
when(service.page(query)).thenReturn(page);
|
||||
|
||||
assertThatThrownBy(() -> controller.page(null, query)).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> controller.page("wrong", query)).isInstanceOf(BizException.class);
|
||||
assertThat(controller.page("0123456789abcdef0123456789abcdef", query).data()).isSameAs(page);
|
||||
verify(service).page(query);
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.order.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PendingReviewOrderOpenApiContractTest {
|
||||
@Test
|
||||
void documentsInternalPendingReviewPageAndStringIds() throws Exception {
|
||||
String yaml = Files.readString(Path.of("src/main/resources/openapi/order-v1.yaml"));
|
||||
assertThat(yaml)
|
||||
.contains("/internal/order/v1/pending-review-orders:")
|
||||
.contains("channelCodes")
|
||||
.contains("createdStart")
|
||||
.contains("createdEnd")
|
||||
.contains("PendingReviewOrderRow:")
|
||||
.contains("mainStatus: {type: string, enum: [PENDING_REVIEW]}")
|
||||
.contains("orderId: {type: string, pattern: '^[1-9][0-9]*$'}")
|
||||
.contains("warehouseId: {type: string, pattern: '^[1-9][0-9]*$'}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.baiyue.order.repository;
|
||||
|
||||
import com.baiyue.order.entity.OrderEntity;
|
||||
import com.baiyue.order.mapper.*;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig(OrderRepository.class)
|
||||
class PendingReviewOrderRepositoryTest {
|
||||
@MockBean private OrderMapper orderMapper;
|
||||
@MockBean private OrderCargoMapper cargoMapper;
|
||||
@MockBean private OrderPartyMapper partyMapper;
|
||||
@MockBean private OrderInternalNoteMapper noteMapper;
|
||||
@MockBean private OrderStatusHistoryMapper historyMapper;
|
||||
@MockBean private OutboxEventMapper outboxMapper;
|
||||
@MockBean private OrderChildPurgeMapper childPurgeMapper;
|
||||
@MockBean private OrderIdempotencyRecordMapper idempotencyMapper;
|
||||
@MockBean private InboundCodeSequenceMapper inboundCodeSequenceMapper;
|
||||
@Resource private OrderRepository repository;
|
||||
|
||||
@Test
|
||||
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||
void buildsSubmittedOnlyStablePageWithEverySupportedFilter() throws Exception {
|
||||
when(orderMapper.selectPage(any(Page.class), any(Wrapper.class))).thenReturn(new Page<>());
|
||||
repository.pendingReviewPage(List.of("SEA-US", "AIR-US"), 11L, 22L, "RK-1",
|
||||
LocalDateTime.of(2026, 9, 1, 0, 0), LocalDateTime.of(2026, 9, 4, 0, 0), 3, 40);
|
||||
|
||||
ArgumentCaptor<Page<OrderEntity>> page = ArgumentCaptor.forClass(Page.class);
|
||||
ArgumentCaptor<Wrapper<OrderEntity>> wrapper = ArgumentCaptor.forClass(Wrapper.class);
|
||||
verify(orderMapper).selectPage(page.capture(), wrapper.capture());
|
||||
assertThat(page.getValue().getCurrent()).isEqualTo(3);
|
||||
assertThat(page.getValue().getSize()).isEqualTo(40);
|
||||
assertThat(wrapper.getValue().getExpression().getNormal()).hasSizeGreaterThanOrEqualTo(15);
|
||||
assertThat(wrapper.getValue().getExpression().getOrderBy()).hasSize(2);
|
||||
String source = Files.readString(Path.of("src/main/java/com/baiyue/order/repository/OrderRepository.java"));
|
||||
assertThat(source)
|
||||
.contains(".eq(OrderEntity::getDeleted, false)")
|
||||
.contains(".eq(OrderEntity::getStatus, OrderStatus.SUBMITTED)")
|
||||
.contains(".in(channelCodes != null && !channelCodes.isEmpty(), OrderEntity::getChannelCode, channelCodes)")
|
||||
.contains(".lt(createdBefore != null, OrderEntity::getCreatedAt, createdBefore)")
|
||||
.contains(".orderByDesc(OrderEntity::getCreatedAt)")
|
||||
.contains(".orderByDesc(OrderEntity::getId)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.baiyue.order.service;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.dto.PendingReviewOrderDtos;
|
||||
import com.baiyue.order.entity.OrderCargoEntity;
|
||||
import com.baiyue.order.entity.OrderEntity;
|
||||
import com.baiyue.order.repository.OrderRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class PendingReviewOrderQueryServiceTest {
|
||||
private final OrderRepository repository = mock(OrderRepository.class);
|
||||
private PendingReviewOrderQueryService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new PendingReviewOrderQueryService();
|
||||
ReflectionTestUtils.setField(service, "orders", repository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertsShanghaiNaturalDatesToClosedDayRangeAndNormalizesFilters() {
|
||||
when(repository.pendingReviewPage(any(), any(), any(), any(), any(), any(), anyLong(), anyLong()))
|
||||
.thenReturn(new PageResult<>(List.of(), 2, 20, 0, 0));
|
||||
|
||||
service.page(new PendingReviewOrderDtos.Query(List.of(" SEA-US ", "SEA-US"), 11L, 22L,
|
||||
" RK-1 ", LocalDate.of(2026, 9, 1), LocalDate.of(2026, 9, 3), 2, 20));
|
||||
|
||||
@SuppressWarnings("unchecked") ArgumentCaptor<List<String>> channels = ArgumentCaptor.forClass(List.class);
|
||||
verify(repository).pendingReviewPage(channels.capture(), eq(11L), eq(22L), eq("RK-1"),
|
||||
eq(LocalDateTime.of(2026, 9, 1, 0, 0)),
|
||||
eq(LocalDateTime.of(2026, 9, 4, 0, 0)), eq(2L), eq(20L));
|
||||
assertThat(channels.getValue()).containsExactly("SEA-US");
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsAuthoritativeOrderFieldsAndSumsPackageCounts() {
|
||||
OrderEntity order = new OrderEntity();
|
||||
order.setId(101L); order.setOrderNo("FL101"); order.setInboundCode("RK101");
|
||||
order.setCustomerId(22L); order.setCustomerName("测试客户"); order.setReceivingWarehouseId(33L);
|
||||
order.setChannelCode("SEA-US"); order.setChannelName("美线");
|
||||
order.setCreatedAt(LocalDateTime.of(2026, 9, 3, 10, 30));
|
||||
OrderCargoEntity first = cargo(101L, 2);
|
||||
OrderCargoEntity second = cargo(101L, 3);
|
||||
when(repository.pendingReviewPage(any(), any(), any(), any(), any(), any(), anyLong(), anyLong()))
|
||||
.thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1));
|
||||
when(repository.findCargoByOrderIds(null, List.of(101L))).thenReturn(List.of(first, second));
|
||||
|
||||
var result = service.page(new PendingReviewOrderDtos.Query(null, null, null, " ", null, null, 1, 20));
|
||||
|
||||
assertThat(result.records()).singleElement().satisfies(row -> {
|
||||
assertThat(row.orderId()).isEqualTo(101L);
|
||||
assertThat(row.warehouseId()).isEqualTo(33L);
|
||||
assertThat(row.totalPackageCount()).isEqualTo(5);
|
||||
assertThat(row.mainStatus()).isEqualTo("PENDING_REVIEW");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsReversedDatesAndBlankChannelValues() {
|
||||
assertThatThrownBy(() -> service.page(new PendingReviewOrderDtos.Query(null, null, null, null,
|
||||
LocalDate.of(2026, 9, 4), LocalDate.of(2026, 9, 3), 1, 20))).isInstanceOf(BizException.class);
|
||||
assertThatThrownBy(() -> service.page(new PendingReviewOrderDtos.Query(List.of(" "), null, null,
|
||||
null, null, null, 1, 20))).isInstanceOf(BizException.class);
|
||||
}
|
||||
|
||||
private static OrderCargoEntity cargo(long orderId, int packageCount) {
|
||||
OrderCargoEntity cargo = new OrderCargoEntity();
|
||||
cargo.setOrderId(orderId); cargo.setPackageCount(packageCount);
|
||||
return cargo;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user