feat: add inbound hold lifecycle

This commit is contained in:
2026-09-07 11:20:10 +08:00
parent e52ecb9315
commit 0c8f6fe2b3
48 changed files with 1280 additions and 16 deletions
@@ -10,9 +10,12 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.InboundOrderDetail;
import com.baiyue.firstleg.dto.InboundOrderQuery;
import com.baiyue.firstleg.dto.InboundOrderSummary;
import com.baiyue.firstleg.dto.InboundHoldDtos.CommandRequest;
import com.baiyue.firstleg.dto.InboundHoldDtos.Detail;
import com.baiyue.firstleg.service.FirstlegScopeGuard;
import com.baiyue.firstleg.service.InboundCartonLabelPrintService;
import com.baiyue.firstleg.service.InboundOrderQueryService;
import com.baiyue.firstleg.service.InboundHoldService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -39,6 +42,7 @@ import java.util.List;
@RequestMapping("/api/platform/inbounds")
public class PlatformInboundOrderController {
@Resource private InboundOrderQueryService service;
@Resource private InboundHoldService holdService;
@Resource private InboundCartonLabelPrintService cartonLabelPrintService;
@Operation(summary = "分页查询入库单",
@@ -68,6 +72,42 @@ public class PlatformInboundOrderController {
return ApiResponse.success(service.detail(tenantId, inboundId));
}
@Operation(summary = "查询入库单扣货快照与不可变历史")
@GetMapping("/{inboundId}/hold")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:list')")
public ApiResponse<Detail> holdDetail(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId) {
requirePlatformTenant(user, tenantId);
return ApiResponse.success(holdService.detail(tenantId, inboundId));
}
@Operation(summary = "扣货入库单")
@PostMapping("/{inboundId}/hold")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:hold')")
public ApiResponse<Detail> hold(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @org.springframework.web.bind.annotation.RequestBody CommandRequest request) {
requirePlatformTenant(user, tenantId);
return ApiResponse.success(holdService.hold(tenantId, inboundId, user.userId(), user.username(),
idempotencyKey, request));
}
@Operation(summary = "解除入库单扣货")
@PostMapping("/{inboundId}/hold/release")
@PreAuthorize("hasAuthority('platform:firstleg:inbound:hold')")
public ApiResponse<Detail> releaseHold(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId,
@PathVariable long inboundId,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @org.springframework.web.bind.annotation.RequestBody CommandRequest request) {
requirePlatformTenant(user, tenantId);
return ApiResponse.success(holdService.release(tenantId, inboundId, user.userId(), user.username(),
idempotencyKey, request));
}
@Operation(summary = "生成入仓箱唛 PDF",
description = "首次调用按订单货物明细冻结整单箱号;重复打印及指定箱下标补打复用原箱号映射。")
@PostMapping("/{inboundId}/carton-label-pdf")
@@ -0,0 +1,42 @@
package com.baiyue.firstleg.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
/** Platform inbound hold command and immutable history contracts. */
public final class InboundHoldDtos {
private InboundHoldDtos() { }
public record CommandRequest(
@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 1000) String reason,
@Positive Long evidenceFileId
) { }
public record HistoryItem(
Long id,
String action,
String reason,
Long evidenceFileId,
String operatorName,
LocalDateTime operatedAt
) { }
public record Detail(
Long inboundId,
Long tenantId,
boolean holdFlag,
String currentHoldReason,
Integer version,
boolean canHold,
boolean canRelease,
String actionBlockReason,
List<HistoryItem> history
) { }
}
@@ -12,6 +12,7 @@ import com.baiyue.firstleg.loading.domain.LoadingEvidenceType;
import com.baiyue.firstleg.loading.entity.CabinetLoadingEvidenceEntity;
import com.baiyue.firstleg.loading.mapper.CabinetLoadingEvidenceMapper;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.service.InboundHoldGuard;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException;
@@ -41,10 +42,12 @@ public class CabinetLoadingEvidenceService {
@Resource private LoadingPickRepository loadingPicks;
@Resource private LoadingEvidenceFileClient files;
@Resource private ObjectMapper objectMapper;
@Resource private InboundHoldGuard inboundHoldGuard;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Transactional
public EvidenceList upsert(long taskId, String key, UpsertRequest request, long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
return upsert(taskId, key, request, operatorId, operatorName, hash(request));
}
@@ -91,6 +94,7 @@ public class CabinetLoadingEvidenceService {
public EvidenceList upload(long taskId, long warehouseId, String key, int expectedVersion,
String evidenceType, String sealNo, String remark, MultipartFile multipart,
long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
Scope scope = scope(taskId, warehouseId, operatorId, true);
requireKey(key);
String requestHash = uploadHash(warehouseId, expectedVersion, evidenceType, sealNo, remark, multipart);
@@ -128,6 +132,7 @@ public class CabinetLoadingEvidenceService {
@Transactional
public EvidenceList voidEvidence(long taskId, long evidenceId, VoidRequest request,
long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
Scope scope = scope(taskId, request.warehouseId(), operatorId, true);
CabinetLoadingEvidenceEntity value = mapper.byContainer(scope.containerBookingId()).stream()
.filter(item -> Objects.equals(item.getId(), evidenceId)).findFirst()
@@ -16,6 +16,7 @@ import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.loading.repository.LoadingRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.service.FbaPricingService;
import com.baiyue.firstleg.service.InboundHoldGuard;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
@@ -43,6 +44,7 @@ public class LoadingDeliveryService {
@Resource private CabinetLoadingEvidenceService cabinetEvidenceService;
@Resource private FbaPricingService pricingService;
@Resource private ObjectMapper objectMapper;
@Resource private InboundHoldGuard inboundHoldGuard;
@Transactional
public LoadingActionResponse completeLoading(long tenantId, long inboundId, long operatorId,
@@ -54,6 +56,7 @@ public class LoadingDeliveryService {
long operatorId, String operatorName, String key,
CompleteLoadingRequest request,
boolean acceptedPdaCompletion) {
inboundHoldGuard.requireInboundNotHeld(tenantId, inboundId);
String hash = hash(request);
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, COMPLETE_LOADING,
key, hash, acceptedPdaCompletion);
@@ -106,6 +109,7 @@ public class LoadingDeliveryService {
* every external/business hard gate must pass before the PDA task is mutated.
*/
public void requirePdaCompletionReady(long tenantId, long inboundId) {
inboundHoldGuard.requireInboundNotHeld(tenantId, inboundId);
Snapshot inbound = requireInbound(tenantId, inboundId);
if (!"LOADING".equals(inbound.processStatus())) {
throw new BizException(ResultCode.CONFLICT, "PDA确认装柜完成前订单必须处于LOADING");
@@ -144,6 +148,7 @@ public class LoadingDeliveryService {
@Transactional
public PickingResponse startPicking(long tenantId, long inboundId, long operatorId, String operatorName,
String key, PickingRequest request) {
inboundHoldGuard.requireInboundNotHeld(tenantId, inboundId);
String hash = hash(request);
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, "START_PICKING", key, hash);
if (replay != null) return pickResponse(tenantId, inboundId);
@@ -171,6 +176,7 @@ public class LoadingDeliveryService {
@Transactional
public PickingResponse completePicking(long tenantId, long inboundId, long operatorId, String operatorName,
String key, PickingRequest request) {
inboundHoldGuard.requireInboundNotHeld(tenantId, inboundId);
String hash = hash(request);
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, "COMPLETE_PICKING", key, hash);
if (replay != null) return pickResponse(tenantId, inboundId);
@@ -0,0 +1,72 @@
package com.baiyue.firstleg.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
/** Authoritative fail-closed hold and physical-work predicates. */
@Mapper
public interface InboundHoldGateMapper {
@Select("""
SELECT COUNT(*)
FROM fl_inbound_order inbound
WHERE inbound.tenant_id=#{tenantId} AND inbound.id=#{inboundId} AND inbound.deleted=0
AND inbound.hold_flag=1
""")
int countHeldInbound(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
@Select("""
SELECT COUNT(*)
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=#{planId} AND item.deleted=0 AND inbound.hold_flag=1
""")
int countHeldByPlan(@Param("planId") long planId);
@Select("""
SELECT COUNT(*)
FROM fl_loading_pick_task task
JOIN fl_loading_plan_item item ON item.loading_plan_id=task.loading_plan_id AND item.deleted=0
JOIN fl_inbound_order inbound
ON inbound.tenant_id=item.account_id AND inbound.id=item.inbound_id AND inbound.deleted=0
WHERE task.id=#{taskId} AND inbound.hold_flag=1
""")
int countHeldByTask(@Param("taskId") long taskId);
@Select("""
SELECT COUNT(*)
FROM fl_loading_plan plan
JOIN fl_loading_plan_item item ON item.loading_plan_id=plan.id AND item.deleted=0
JOIN fl_inbound_order inbound
ON inbound.tenant_id=item.account_id AND inbound.id=item.inbound_id AND inbound.deleted=0
WHERE plan.container_booking_id=#{containerBookingId} AND inbound.hold_flag=1
""")
int countHeldByContainer(@Param("containerBookingId") long containerBookingId);
@Select("""
SELECT CASE WHEN
inbound.process_status IN ('LOADED','DELIVERED')
OR EXISTS (
SELECT 1 FROM fl_loading_plan_item plan_item
JOIN fl_loading_pick_item pick_item ON pick_item.loading_plan_item_id=plan_item.id
WHERE plan_item.account_id=inbound.tenant_id AND plan_item.inbound_id=inbound.id
AND plan_item.deleted=0 AND pick_item.picked_package_count>0
)
OR EXISTS (
SELECT 1 FROM fl_loading_plan_item plan_item
JOIN fl_loading_pick_item pick_item ON pick_item.loading_plan_item_id=plan_item.id
JOIN fl_loading_work_pallet_carton carton ON carton.pick_item_id=pick_item.id
WHERE plan_item.account_id=inbound.tenant_id AND plan_item.inbound_id=inbound.id
AND plan_item.deleted=0
)
OR EXISTS (
SELECT 1 FROM fl_outbound_dispatch dispatch
WHERE dispatch.tenant_id=inbound.tenant_id AND dispatch.inbound_id=inbound.id
AND dispatch.status='WAITING_FOR_LOADING'
) THEN 1 ELSE 0 END
FROM fl_inbound_order inbound
WHERE inbound.tenant_id=#{tenantId} AND inbound.id=#{inboundId} AND inbound.deleted=0
""")
Integer hasPhysicalWork(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId);
}
@@ -24,6 +24,19 @@ public interface InboundOrderMapper extends BaseMapper<InboundOrderEntity> {
@Param("detained") boolean detained, @Param("reason") String reason,
@Param("sourceVersion") long sourceVersion, @Param("operatorId") long operatorId);
@Update("""
UPDATE fl_inbound_order
SET hold_flag=#{holdFlag}, current_hold_reason=#{reason},
updated_by=#{operatorId}, version=version+1
WHERE id=#{inboundId} AND tenant_id=#{tenantId} AND deleted=0
AND version=#{expectedVersion} AND hold_flag=#{expectedHoldFlag}
""")
int updateManualHold(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId,
@Param("expectedVersion") int expectedVersion,
@Param("expectedHoldFlag") boolean expectedHoldFlag,
@Param("holdFlag") boolean holdFlag, @Param("reason") String reason,
@Param("operatorId") long operatorId);
@Update("""
UPDATE fl_inbound_order
SET process_status = CASE WHEN fulfillment_type='ONE_CLICK_DROPSHIP'
@@ -49,6 +49,23 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
List<Long> occupiedOrderIds(@Param("query") InboundCandidateQuery query,
@Param("actualChannels") Set<String> actualChannels);
@Select("""
<script>
SELECT DISTINCT inbound.order_id
FROM fl_inbound_order inbound
WHERE inbound.deleted=0 AND inbound.hold_flag=1 AND inbound.order_id IS NOT NULL
AND inbound.channel_code IN
<foreach collection="actualChannels" item="channel" open="(" separator="," close=")">#{channel}</foreach>
<if test="query.warehouseId != null">AND inbound.warehouse_id=#{query.warehouseId}</if>
<if test="query.customerId != null">AND inbound.tenant_id=#{query.customerId}</if>
<if test="query.inboundCode != null and query.inboundCode.trim() != ''">
AND inbound.inbound_code=#{query.inboundCode}
</if>
</script>
""")
List<Long> heldOrderIds(@Param("query") InboundCandidateQuery query,
@Param("actualChannels") Set<String> actualChannels);
@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,
@@ -105,6 +122,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
batch.total_volume_cbm,source.total_package_count
) i ON i.tenant_id=o.tenant_id AND i.inbound_id=o.id AND i.measurement_id=m.id
WHERE (#{warehouseId} IS NULL OR o.warehouse_id=#{warehouseId}) AND o.process_status='WAIT_SPLIT'
AND COALESCE(o.hold_flag,0)=0
AND o.actual_gross_weight_kg IS NOT NULL AND o.actual_net_weight_kg IS NOT NULL
AND (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT'))
AND i.package_count>COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
@@ -148,6 +166,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
AND split_item.parent_inbound_id=o.id),0) availablePackageCount
FROM fl_inbound_order o
WHERE (#{query.warehouseId} IS NULL OR o.warehouse_id=#{query.warehouseId})
AND COALESCE(o.hold_flag,0)=0
AND o.channel_code IN
<foreach collection="actualChannels" item="channel" open="(" separator="," close=")">#{channel}</foreach>
AND (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT'))
@@ -242,6 +261,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
LEFT JOIN fl_coload_group current_group
ON current_group.id=current_member.group_id AND current_group.group_status='ACTIVE'
WHERE o.process_status='WAIT_SPLIT'
AND COALESCE(o.hold_flag,0)=0
AND o.actual_gross_weight_kg IS NOT NULL AND o.actual_net_weight_kg IS NOT NULL
AND (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT'))
AND (
@@ -282,7 +302,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
ON current_member.tenant_id=o.tenant_id AND current_member.inbound_id=o.id AND current_member.active_flag=1
LEFT JOIN fl_coload_group current_group
ON current_group.id=current_member.group_id AND current_group.group_status IN ('ACTIVE','LOCKED')
WHERE o.process_status IN ('WAIT_MEASURE','MEASURING','MEASURE_PENDING','MEASURE_REJECTED',
WHERE COALESCE(o.hold_flag,0)=0
AND o.process_status IN ('WAIT_MEASURE','MEASURING','MEASURE_PENDING','MEASURE_REJECTED',
'WAIT_PRICING','FEE_CONFIRMING','INBOUNDING','LOADING')
AND (
<foreach collection="units" item="unit" separator=" OR ">
@@ -388,6 +409,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
batch.total_volume_cbm,source.total_package_count
) i ON i.tenant_id=o.tenant_id AND i.inbound_id=o.id AND i.measurement_id=m.id
WHERE o.id=#{inboundId}
AND COALESCE(o.hold_flag,0)=0
AND (o.process_status='WAIT_SPLIT' OR (o.process_status='LOADING' AND EXISTS (
SELECT 1 FROM fl_loading_plan_item same_plan_item
WHERE same_plan_item.loading_plan_id=#{planId}
@@ -13,7 +13,7 @@ public interface OutboundDispatchReadMapper {
@Select("""
SELECT DISTINCT inbound.order_id orderId,inbound.order_no orderNo,inbound.id inboundId,
inbound.inbound_code inboundCode,inbound.tenant_id tenantId,inbound.customer_id customerId,
inbound.customer_name customerName,inbound.process_status inboundStatus,
inbound.customer_name customerName,inbound.process_status inboundStatus,inbound.hold_flag holdFlag,
booking.id containerBookingId,booking.cabinet_no cabinetNo,booking.container_no containerNo,
plan.id loadingPlanId,plan.plan_status loadingPlanStatus
FROM fl_loading_plan_item item
@@ -29,7 +29,7 @@ public interface OutboundDispatchReadMapper {
@Select("""
SELECT inbound.order_id orderId,inbound.order_no orderNo,inbound.id inboundId,
inbound.inbound_code inboundCode,inbound.tenant_id tenantId,inbound.customer_id customerId,
inbound.customer_name customerName,inbound.process_status inboundStatus,
inbound.customer_name customerName,inbound.process_status inboundStatus,inbound.hold_flag holdFlag,
booking.id containerBookingId,booking.cabinet_no cabinetNo,booking.container_no containerNo,
plan.id loadingPlanId,plan.plan_status loadingPlanStatus
FROM fl_loading_plan_item item
@@ -178,7 +178,7 @@ public interface OutboundDispatchReadMapper {
@Param("truckAppointmentId") Long truckAppointmentId);
record Binding(Long orderId, String orderNo, Long inboundId, String inboundCode, Long tenantId,
Long customerId, String customerName, String inboundStatus, Long containerBookingId,
Long customerId, String customerName, String inboundStatus, Boolean holdFlag, Long containerBookingId,
String cabinetNo, String containerNo, Long loadingPlanId, String loadingPlanStatus) {}
record IdStatus(Long id, String status) {}
record OceanBinding(Long id, String status, Long customerId) {}
@@ -179,6 +179,8 @@ public class OutboundDispatchService {
OutboundDispatchReadMapper.OceanBinding ocean, OutboundDispatchReadMapper.Evidence evidence,
List<GateItem> containerContributorGates) {
List<GateItem> items = new ArrayList<>();
items.add(item("INBOUND_NOT_HELD", "入库单未扣货", !Boolean.TRUE.equals(binding.holdFlag()),
Boolean.TRUE.equals(binding.holdFlag()) ? "HELD" : "RELEASED", "RELEASED"));
items.add(item("INBOUND_LOADED", "入库单装柜完成", "LOADED".equals(binding.inboundStatus()),
binding.inboundStatus(), "LOADED"));
items.add(item("LOADING_PLAN_COMPLETED", "柜次装柜计划完成", "COMPLETED".equals(binding.loadingPlanStatus()),
@@ -60,7 +60,9 @@ public class SmartLoadingRepository {
return candidates.planColoadMembers(planId);
}
public List<Long> occupiedOrderIds(InboundCandidateQuery query, Set<String> actualChannels) {
return candidates.occupiedOrderIds(query, actualChannels);
return java.util.stream.Stream.concat(candidates.occupiedOrderIds(query, actualChannels).stream(),
candidates.heldOrderIds(query, actualChannels).stream())
.distinct().toList();
}
public Candidate candidate(long planId, long inboundId, long detailId) {
return candidates.candidate(planId, inboundId, detailId);
@@ -0,0 +1,39 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.mapper.InboundHoldGateMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
/** Central fail-closed guard used by every physical fulfillment write path. */
@Service
@RequiredArgsConstructor
public class InboundHoldGuard {
private final InboundHoldGateMapper mapper;
public void requireInboundNotHeld(long tenantId, long inboundId) {
if (mapper.countHeldInbound(tenantId, inboundId) > 0) throw held();
}
public void requirePlanNotHeld(long planId) {
if (mapper.countHeldByPlan(planId) > 0) throw held();
}
public void requireTaskNotHeld(long taskId) {
if (mapper.countHeldByTask(taskId) > 0) throw held();
}
public void requireContainerNotHeld(long containerBookingId) {
if (mapper.countHeldByContainer(containerBookingId) > 0) throw held();
}
public boolean hasPhysicalWork(long tenantId, long inboundId) {
Integer value = mapper.hasPhysicalWork(tenantId, inboundId);
return value == null || value != 0;
}
private static BizException held() {
return new BizException(ResultCode.CONFLICT, "入库单已扣货,当前履约操作已阻断");
}
}
@@ -0,0 +1,121 @@
package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.InboundHoldDtos.CommandRequest;
import com.baiyue.firstleg.dto.InboundHoldDtos.Detail;
import com.baiyue.firstleg.dto.InboundHoldDtos.HistoryItem;
import com.baiyue.firstleg.entity.InboundHoldRecordEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.mapper.InboundHoldRecordMapper;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/** Platform-owned inbound hold snapshot and immutable command history. */
@Service
@RequiredArgsConstructor
public class InboundHoldService {
private static final String HOLD = "HOLD";
private static final String RELEASE = "RELEASE";
private final InboundOrderMapper inbounds;
private final InboundHoldRecordMapper history;
private final InboundIdempotencyService idempotency;
private final InboundHoldGuard guard;
@Transactional(readOnly = true)
public Detail detail(long tenantId, long inboundId) {
InboundOrderEntity inbound = owned(tenantId, inboundId);
return view(inbound);
}
@Transactional
public Detail hold(long tenantId, long inboundId, long operatorId, String operatorName,
String idempotencyKey, CommandRequest request) {
return change(tenantId, inboundId, operatorId, operatorName, idempotencyKey, request, true);
}
@Transactional
public Detail release(long tenantId, long inboundId, long operatorId, String operatorName,
String idempotencyKey, CommandRequest request) {
return change(tenantId, inboundId, operatorId, operatorName, idempotencyKey, request, false);
}
private Detail change(long tenantId, long inboundId, long operatorId, String operatorName,
String idempotencyKey, CommandRequest request, boolean targetHold) {
String action = targetHold ? HOLD : RELEASE;
String reason = request.reason().trim();
String canonical = action + '|' + tenantId + '|' + inboundId + '|' + request.expectedVersion()
+ '|' + reason + '|' + (request.evidenceFileId() == null ? "" : request.evidenceFileId());
InboundIdempotencyService.Context command = idempotency.begin(
tenantId, operatorId, inboundId, "INBOUND_" + action, idempotencyKey, canonical);
if (command.replayed()) return detail(tenantId, inboundId);
InboundOrderEntity inbound = inbounds.selectActiveByIdForUpdate(inboundId);
if (inbound == null || inbound.getTenantId() == null || inbound.getTenantId() != tenantId) {
throw new BizException(ResultCode.NOT_FOUND, "入库单不存在或不属于当前客户");
}
boolean currentHold = Boolean.TRUE.equals(inbound.getHoldFlag());
if (currentHold == targetHold) {
throw conflict(targetHold ? "入库单已处于扣货状态" : "入库单当前未扣货");
}
if (inbound.getVersion() == null || !inbound.getVersion().equals(request.expectedVersion())) {
throw conflict("入库单版本已变化,请刷新后重试");
}
if (guard.hasPhysicalWork(tenantId, inboundId)) {
throw conflict("入库单已发生实物拣货、装柜或出仓,禁止手工扣货或解除");
}
String currentReason = targetHold ? reason : null;
if (inbounds.updateManualHold(tenantId, inboundId, request.expectedVersion(), currentHold,
targetHold, currentReason, operatorId) != 1) {
throw conflict("入库单状态或版本已变化,请刷新后重试");
}
InboundHoldRecordEntity record = new InboundHoldRecordEntity();
record.setId(IdWorker.getId());
record.setTenantId(tenantId);
record.setInboundId(inboundId);
record.setHoldAction(action);
record.setReason(reason);
record.setEvidenceFileId(request.evidenceFileId());
record.setOperatorId(operatorId);
record.setOperatorName(operatorName);
history.insert(record);
idempotency.complete(command, inboundId);
return detail(tenantId, inboundId);
}
private InboundOrderEntity owned(long tenantId, long inboundId) {
InboundOrderEntity inbound = inbounds.selectById(inboundId);
if (inbound == null || inbound.getTenantId() == null || inbound.getTenantId() != tenantId
|| Boolean.TRUE.equals(inbound.getDeleted())) {
throw new BizException(ResultCode.NOT_FOUND, "入库单不存在或不属于当前客户");
}
return inbound;
}
private Detail view(InboundOrderEntity inbound) {
boolean physical = guard.hasPhysicalWork(inbound.getTenantId(), inbound.getId());
boolean held = Boolean.TRUE.equals(inbound.getHoldFlag());
String blockReason = physical ? "入库单已发生实物拣货、装柜或出仓" : null;
List<HistoryItem> records = history.selectList(new LambdaQueryWrapper<InboundHoldRecordEntity>()
.eq(InboundHoldRecordEntity::getTenantId, inbound.getTenantId())
.eq(InboundHoldRecordEntity::getInboundId, inbound.getId())
.orderByDesc(InboundHoldRecordEntity::getOperatedAt)
.orderByDesc(InboundHoldRecordEntity::getId))
.stream().map(row -> new HistoryItem(row.getId(), row.getHoldAction(), row.getReason(),
row.getEvidenceFileId(), row.getOperatorName(), row.getOperatedAt())).toList();
return new Detail(inbound.getId(), inbound.getTenantId(), held,
held ? inbound.getCurrentHoldReason() : null, inbound.getVersion(), !held && !physical,
held && !physical, blockReason, records);
}
private static BizException conflict(String message) {
return new BizException(ResultCode.CONFLICT, message);
}
}
@@ -70,6 +70,8 @@ public class LoadingListService {
@Lazy
private LoadingPickService loadingPickService;
@Resource
private InboundHoldGuard inboundHoldGuard;
@Resource
private LoadingCompletedOutboxService loadingCompletedOutboxService;
@Resource
private ObjectMapper objectMapper;
@@ -491,6 +493,7 @@ public class LoadingListService {
public PreloadConfirmationResponse confirmPreload(long planId, long operatorId, String operatorName,
String idempotencyKey,
ConfirmPreloadRequest request) {
inboundHoldGuard.requirePlanNotHeld(planId);
String requestHash = sha256("CONFIRM_PRELOAD|" + planId + '|' + request.expectedVersion());
String operationId = operationId("CONFIRM_PRELOAD", planId, idempotencyKey);
HistoryRow replay = repository.history(operationId);
@@ -104,6 +104,8 @@ public class LoadingPickService {
private PdaPickSettlementService settlementService;
@Resource
private InboundPalletAllocationRepository palletAllocations;
@Resource
private InboundHoldGuard inboundHoldGuard;
/** Opens the field task after planning and ocean release; the real container number may be filled later. */
@Transactional
@@ -112,6 +114,7 @@ public class LoadingPickService {
if (booking == null) return;
var plan = smartLoadingRepository.planByBooking(containerBookingId);
if (plan == null || !"EDITING".equals(plan.getPlanStatus())) return;
inboundHoldGuard.requirePlanNotHeld(plan.getId());
var links = loadingModelBridge.byLoadingPlan(plan.getId());
if (links.isEmpty() || links.stream().anyMatch(link -> !"CONFIRMED".equals(link.preloadStatus()))) return;
var allocation = oceanBookingRepository.allocationByContainer(containerBookingId);
@@ -124,6 +127,7 @@ public class LoadingPickService {
}
public List<Link> requirePreloadConfirmationReady(long loadingPlanId) {
inboundHoldGuard.requirePlanNotHeld(loadingPlanId);
List<Link> links = loadingModelBridge.requireAllocatedLinksForConfirmation(loadingPlanId);
for (Link link : links) {
if (!Set.of("PENDING", "CONFIRMING").contains(link.preloadStatus())) {
@@ -145,6 +149,7 @@ public class LoadingPickService {
@Transactional
public void begin(long planId, String containerNo, long operatorId, String operatorName,
List<LoadingPlanItemEntity> planItems) {
inboundHoldGuard.requirePlanNotHeld(planId);
LoadingPickTaskEntity task = repository.taskByPlan(planId);
if (task == null) {
task = new LoadingPickTaskEntity();
@@ -242,6 +247,7 @@ public class LoadingPickService {
@Transactional
public Task claim(long taskId, ClaimRequest request, long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
String operationId = operation(request.operationId());
String requestHash = hash("CLAIM|" + taskId + '|' + request.expectedAssignmentVersion());
Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId);
@@ -271,6 +277,7 @@ public class LoadingPickService {
@Transactional
public Task assign(long taskId, AssignRequest request, long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
String operationId = operation(request.operationId());
String assigneeName = request.assigneeName().trim();
String requestHash = hash("ASSIGN|" + taskId + '|' + request.assigneeId() + '|'
@@ -294,6 +301,7 @@ public class LoadingPickService {
@Transactional
public Task pick(long taskId, long itemId, PickRequest request, long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
String operationId = request.operationId().trim();
String requestHash = hash(taskId + "|" + itemId + "|" + request.warehouseId()
+ "|" + request.pickedPackageCount());
@@ -338,6 +346,7 @@ public class LoadingPickService {
@Transactional
public Task partial(long taskId, long itemId, PartialPickRequest request,
long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
String operationId = request.operationId().trim();
String requestHash = hash("PARTIAL|" + taskId + "|" + itemId + "|" + request.warehouseId()
+ "|" + request.finalPickedPackageCount());
@@ -368,6 +377,7 @@ public class LoadingPickService {
@Transactional
public Task skip(long taskId, long itemId, SkipRequest request, long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
String operationId = request.operationId().trim();
String reason = trim(request.reason());
String requestHash = hash("SKIP|" + taskId + "|" + itemId + "|" + request.warehouseId()
@@ -405,6 +415,7 @@ public class LoadingPickService {
@Transactional
public Task batch(long taskId, String idempotencyKey, BatchRequest request,
long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
String operationId = operation(idempotencyKey);
String requestHash = batchRequestHash(taskId, request);
Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId);
@@ -451,6 +462,7 @@ public class LoadingPickService {
@Transactional
public Task review(long taskId, ReviewRequest request, long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
return complete(taskId, new CompleteLoadingRequest(request.expectedTaskVersion(), request.operationId(),
request.remark()), operatorId, operatorName);
}
@@ -461,6 +473,7 @@ public class LoadingPickService {
*/
@Transactional
public Task complete(long taskId, CompleteLoadingRequest request, long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
String operationId = operation(request.operationId());
String remark = trim(request.remark());
String requestHash = hash("COMPLETE|" + taskId + '|' + request.expectedTaskVersion() + '|' + remark);
@@ -29,9 +29,11 @@ import java.util.Objects;
public class LoadingWorkPalletService {
@Resource private LoadingWorkPalletMapper pallets;
@Resource private LoadingWorkPalletCartonMapper cartons;
@Resource private InboundHoldGuard inboundHoldGuard;
@Transactional
public Pallet open(long taskId, OpenRequest request, long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
TaskContext context = requireWorkTask(taskId, request.warehouseId(), operatorId);
String code = request.palletCode().trim();
String operationId = request.operationId().trim();
@@ -68,6 +70,7 @@ public class LoadingWorkPalletService {
@Transactional
public Pallet scan(long taskId, long palletId, ScanRequest request,
long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
requireWorkTask(taskId, request.warehouseId(), operatorId);
LoadingWorkPalletEntity target = requireOpenPallet(taskId, palletId, request.warehouseId());
String operationId = request.operationId().trim();
@@ -114,6 +117,7 @@ public class LoadingWorkPalletService {
@Transactional
public Pallet seal(long taskId, long palletId, SealRequest request,
long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
requireWorkTask(taskId, request.warehouseId(), operatorId);
LoadingWorkPalletEntity replay = pallets.selectBySealOperation(request.operationId().trim());
if (replay != null) {
@@ -58,6 +58,7 @@ public class SmartLoadingService {
@Resource private PendingReviewOrderClient pendingReviewOrders;
@Resource private InboundOrderCreationService inboundOrderCreationService;
@Resource private LoadingPickService loadingPickService;
@Resource private InboundHoldGuard inboundHoldGuard;
@Resource private LoadingModelBridgeService loadingModelBridge;
@Resource private SplitPreloadService splitPreloadService;
@Resource private ColoadGroupAssignmentPort coloadGroups;
@@ -432,6 +433,7 @@ public class SmartLoadingService {
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
Candidate candidate = repository.candidate(planId, request.inboundId(), request.inboundDetailId());
if (candidate == null) throw conflict("入库货物不存在或尚未完成入库");
inboundHoldGuard.requireInboundNotHeld(candidate.accountId(), candidate.inboundId());
if (!wholeGroupAssignment) requireStandalone(candidate.accountId(), candidate.inboundId());
requireSameCustomerScope(booking, candidate);
var task = loadingPickService.mutationSnapshot(planId);
@@ -513,6 +515,7 @@ public class SmartLoadingService {
throw conflict("待审核订单快照已变化,请刷新后重试");
}
long inboundId = inboundOrderCreationService.prepareFromDraft(snapshot);
inboundHoldGuard.requireInboundNotHeld(request.customerId(), inboundId);
LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName);
if (!"EDITING".equals(plan.getPlanStatus())) {
throw conflict("待审核订单只能加入编辑中的装柜清单");
@@ -583,6 +586,9 @@ public class SmartLoadingService {
if (replay != null) return replay;
InboundOrderEntity projection = repository.inbound(request.inboundId());
if (projection != null) {
inboundHoldGuard.requireInboundNotHeld(projection.getTenantId(), projection.getId());
}
if (!wholeGroupAssignment && projection != null && Set.of("PREPARING", "WAIT_MEASURE",
"MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", "WAIT_PRICING",
"FEE_CONFIRMING", "INBOUNDING").contains(projection.getProcessStatus())) {
@@ -597,6 +603,7 @@ public class SmartLoadingService {
: List.copyOf(frozenDetails);
if (details.isEmpty()) throw conflict("入仓码不存在、已分配完或尚未完成入库");
Candidate first = details.get(0);
inboundHoldGuard.requireInboundNotHeld(first.accountId(), first.inboundId());
if (!wholeGroupAssignment) requireStandalone(first.accountId(), first.inboundId());
if (!repository.activeItemsByInbound(planId, first.accountId(), request.inboundId()).isEmpty()) {
throw conflict("该入仓码已在当前柜次中,请先调整或移除现有分配");
@@ -728,6 +735,7 @@ public class SmartLoadingService {
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
InboundOrderEntity selected = repository.inbound(request.inboundId());
if (selected == null) throw conflict("同柜不可拆组成员当前不可分配");
inboundHoldGuard.requireInboundNotHeld(selected.getTenantId(), selected.getId());
GroupSnapshot discovered = requiredGroup(selected.getTenantId(), selected.getId());
Map<Long, List<Candidate>> memberCandidates = requireWholeGroupCandidates(
planId, booking, discovered, repository.items(planId));
@@ -851,6 +859,7 @@ public class SmartLoadingService {
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion(), operatorId, operatorName);
LoadingPlanItemEntity item = repository.item(planId, itemId);
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在");
inboundHoldGuard.requireInboundNotHeld(item.getAccountId(), item.getInboundId());
requireStandalone(item.getAccountId(), item.getInboundId());
if (!Objects.equals(item.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突");
LoadingPickService.MutationSnapshot pickSnapshot = loadingPickService.mutationSnapshot(planId);
@@ -42,7 +42,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order (
destination_code VARCHAR(32) NULL COMMENT '目的仓编码,例如FBA仓编码',
destination_name VARCHAR(128) NULL COMMENT '目的仓名称快照',
hold_flag TINYINT(1) NOT NULL DEFAULT 0 COMMENT '当前是否扣货',
current_hold_reason VARCHAR(500) NULL COMMENT '当前扣货理由快照',
current_hold_reason VARCHAR(1000) NULL COMMENT '当前扣货理由快照',
last_overseas_hold_version BIGINT UNSIGNED NULL COMMENT '海外暂扣事件最新聚合版本,用于阻止乱序覆盖',
total_package_count INT UNSIGNED NULL COMMENT '确认后的总箱数',
actual_gross_weight_kg DECIMAL(18,3) NULL COMMENT '已确认称重图片含托盘毛重汇总kg',
@@ -6,6 +6,44 @@ info:
security:
- bearerAuth: []
paths:
/api/platform/inbounds/{inboundId}/hold:
get:
summary: 查询入库单当前扣货快照与不可变历史
description: 需要 platform:firstleg:inbound:list;canHold/canRelease 由服务端按实物作业事实计算,前端不得推导。
parameters:
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
responses:
'200': {description: 成功时 data 为 InboundHoldDetail;客户归属不匹配时响应体 code=A0404}
post:
summary: 扣货入库单
description: 需要 platform:firstleg:inbound:hold;同事务写当前快照和不可变历史,不改变 Order 主状态。
parameters:
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/InboundHoldCommand'}
responses:
'200': {description: 成功或完全同参幂等重放时 data 为 InboundHoldDetail;版本、状态、异参重放或实物作业冲突时响应体 code=A0409}
/api/platform/inbounds/{inboundId}/hold/release:
post:
summary: 解除入库单扣货
description: 需要 platform:firstleg:inbound:hold;解除原因进入不可变历史,当前原因快照清空,入库聚合 version 递增 1。
parameters:
- {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/InboundHoldCommand'}
responses:
'200': {description: 成功或完全同参幂等重放时 data 为 InboundHoldDetail;版本、状态、异参重放或实物作业冲突时响应体 code=A0409}
/api/merchant/v1/first-leg/inbounds/{inboundId}/carton-label-pdf:
post:
summary: 客户端生成本客户入库单箱唛 PDF
@@ -1218,6 +1256,38 @@ components:
required: true
schema: {type: string, pattern: '^[1-9][0-9]*$'}
schemas:
InboundHoldCommand:
type: object
required: [expectedVersion, reason]
properties:
expectedVersion: {type: integer, minimum: 0, description: 当前入库聚合 version;成功后递增 1}
reason: {type: string, minLength: 1, maxLength: 1000}
evidenceFileId: {type: integer, format: int64, minimum: 1, nullable: true, description: 可选的既有证据文件引用;本接口不上传文件}
InboundHoldHistoryItem:
type: object
required: [id, action, reason, operatedAt]
properties:
id: {type: integer, format: int64}
action: {type: string, enum: [HOLD, RELEASE]}
reason: {type: string, maxLength: 1000}
evidenceFileId: {type: integer, format: int64, nullable: true}
operatorName: {type: string, nullable: true}
operatedAt: {type: string, format: date-time}
InboundHoldDetail:
type: object
required: [inboundId, tenantId, holdFlag, version, canHold, canRelease, history]
properties:
inboundId: {type: integer, format: int64}
tenantId: {type: integer, format: int64}
holdFlag: {type: boolean, description: 当前权威扣货状态}
currentHoldReason: {type: string, maxLength: 1000, nullable: true, description: 未扣货时固定为 null}
version: {type: integer, minimum: 0}
canHold: {type: boolean}
canRelease: {type: boolean}
actionBlockReason: {type: string, nullable: true}
history:
type: array
items: {$ref: '#/components/schemas/InboundHoldHistoryItem'}
OrderChannelChangeValidationRequest:
type: object
additionalProperties: false
@@ -0,0 +1,87 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.InboundHoldDtos.CommandRequest;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestHeader;
import org.yaml.snakeyaml.Yaml;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class InboundHoldContractTest {
@Test
void controllerUsesIndependentWritePermissionAndRequiredIdempotencyKey() throws Exception {
Method detail = PlatformInboundOrderController.class.getDeclaredMethod(
"holdDetail", AuthenticatedUser.class, long.class, long.class);
Method hold = commandMethod("hold");
Method release = commandMethod("releaseHold");
assertThat(detail.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:firstleg:inbound:list')");
assertThat(hold.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:firstleg:inbound:hold')");
assertThat(release.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:firstleg:inbound:hold')");
assertThat(hold.getParameters()[3].getAnnotation(RequestHeader.class).value())
.isEqualTo("Idempotency-Key");
assertThat(hold.getParameters()[3].getAnnotation(RequestHeader.class).required()).isTrue();
assertThat(release.getParameters()[3].getAnnotation(RequestHeader.class).required()).isTrue();
}
@Test
void openApiFreezesHoldPathsFieldsAndValidationWithoutLegacyAliases() throws Exception {
String source = new ClassPathResource("openapi/firstleg-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
Map<String, Object> document = stringMap(new Yaml().load(source));
Map<String, Object> paths = stringMap(document.get("paths"));
assertThat(paths.keySet()).contains(
"/api/platform/inbounds/{inboundId}/hold",
"/api/platform/inbounds/{inboundId}/hold/release");
Map<String, Object> schemas = stringMap(stringMap(document.get("components")).get("schemas"));
Map<String, Object> command = stringMap(schemas.get("InboundHoldCommand"));
assertThat(stringList(command.get("required"))).containsExactly("expectedVersion", "reason");
assertThat(stringMap(command.get("properties")).keySet())
.containsExactlyInAnyOrder("expectedVersion", "reason", "evidenceFileId");
assertThat(stringMap(stringMap(command.get("properties")).get("expectedVersion")))
.containsEntry("minimum", 0);
assertThat(stringMap(stringMap(command.get("properties")).get("reason")))
.containsEntry("minLength", 1).containsEntry("maxLength", 1000);
Map<String, Object> detail = stringMap(schemas.get("InboundHoldDetail"));
assertThat(stringMap(detail.get("properties")).keySet()).containsExactlyInAnyOrder(
"inboundId", "tenantId", "holdFlag", "currentHoldReason", "version",
"canHold", "canRelease", "actionBlockReason", "history");
assertThat(stringMap(stringMap(detail.get("properties")).get("holdFlag")))
.containsEntry("type", "boolean").doesNotContainKey("nullable");
Map<String, Object> history = stringMap(schemas.get("InboundHoldHistoryItem"));
assertThat(stringMap(history.get("properties")).keySet()).containsExactlyInAnyOrder(
"id", "action", "reason", "evidenceFileId", "operatorName", "operatedAt");
assertThat(source).contains("platform:firstleg:inbound:hold")
.doesNotContain("hold" + "Cargo:")
.doesNotContain("hold" + "Reason:");
}
private static Method commandMethod(String name) throws NoSuchMethodException {
return PlatformInboundOrderController.class.getDeclaredMethod(name, AuthenticatedUser.class,
long.class, long.class, String.class, CommandRequest.class);
}
@SuppressWarnings("unchecked")
private static Map<String, Object> stringMap(Object value) {
return (Map<String, Object>) value;
}
@SuppressWarnings("unchecked")
private static List<String> stringList(Object value) {
return (List<String>) value;
}
}
@@ -8,6 +8,7 @@ import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.loading.entity.CabinetLoadingEvidenceEntity;
import com.baiyue.firstleg.loading.mapper.CabinetLoadingEvidenceMapper;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.service.InboundHoldGuard;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach;
@@ -35,6 +36,7 @@ class CabinetLoadingEvidenceServiceTest {
@MockBean LoadingPickRepository loadingPicks;
@MockBean LoadingEvidenceFileClient files;
@MockBean ObjectMapper objectMapper;
@MockBean InboundHoldGuard inboundHoldGuard;
@Resource CabinetLoadingEvidenceService service;
@BeforeEach
@@ -7,6 +7,7 @@ import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper.InboundProgress;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.service.FbaPricingService;
import com.baiyue.firstleg.service.InboundHoldGuard;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
@@ -30,6 +31,7 @@ class LoadingDeliveryServiceTest {
@MockBean CabinetLoadingEvidenceService cabinetEvidence;
@MockBean FbaPricingService pricingService;
@MockBean ObjectMapper objectMapper;
@MockBean InboundHoldGuard inboundHoldGuard;
@Resource LoadingDeliveryService service;
@Test
@@ -266,7 +266,7 @@ class OutboundDispatchServiceTest {
private static OutboundDispatchReadMapper.Binding binding(long inboundId, String inboundCode) {
return new OutboundDispatchReadMapper.Binding(10L, "O-10", inboundId, inboundCode, 7L, 8L, "客户A",
"LOADED", 22L, "CAB-22", "C-22", 44L, "COMPLETED");
"LOADED", false, 22L, "CAB-22", "C-22", 44L, "COMPLETED");
}
private static OutboundDispatchEntity state(long id, long inboundId, String status, int version) {
@@ -12,6 +12,24 @@ import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("入库审核SQL契约")
class InboundWorkflowSqlContractTest {
@Test
@DisplayName("扣货快照、不可变历史和幂等记录使用冻结字段与长度")
void shouldPersistInboundHoldSnapshotHistoryAndIdempotency() throws Exception {
String schema = new ClassPathResource("db/phase2-inbound-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(schema)
.contains("hold_flag TINYINT(1) NOT NULL DEFAULT 0")
.contains("current_hold_reason VARCHAR(1000) NULL")
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_hold_record")
.contains("hold_action VARCHAR(16) NOT NULL")
.contains("reason VARCHAR(1000) NOT NULL")
.contains("evidence_file_id BIGINT UNSIGNED NULL")
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_idempotency")
.contains("idempotency_key VARCHAR(128) NOT NULL")
.contains("UNIQUE KEY uk_inbound_idempotency");
}
@Test
@DisplayName("基础表和增量迁移包含审核历史、幂等、Outbox和放行索引")
void shouldProvideWorkflowPersistenceBoundaries() throws Exception {
@@ -0,0 +1,40 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.mapper.InboundHoldGateMapper;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class InboundHoldGuardTest {
private final InboundHoldGateMapper mapper = mock(InboundHoldGateMapper.class);
private final InboundHoldGuard guard = new InboundHoldGuard(mapper);
@Test
void blocksInboundPlanTaskAndContainerScopes() {
when(mapper.countHeldInbound(7L, 11L)).thenReturn(1);
when(mapper.countHeldByPlan(22L)).thenReturn(1);
when(mapper.countHeldByTask(33L)).thenReturn(1);
when(mapper.countHeldByContainer(44L)).thenReturn(1);
for (Runnable action : new Runnable[] {
() -> guard.requireInboundNotHeld(7L, 11L),
() -> guard.requirePlanNotHeld(22L),
() -> guard.requireTaskNotHeld(33L),
() -> guard.requireContainerNotHeld(44L)
}) {
assertThatThrownBy(action::run).isInstanceOfSatisfying(BizException.class,
error -> assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT));
}
}
@Test
void treatsMissingPhysicalWorkProjectionAsFailClosed() {
when(mapper.hasPhysicalWork(7L, 11L)).thenReturn(null);
assertThat(guard.hasPhysicalWork(7L, 11L)).isTrue();
}
}
@@ -0,0 +1,135 @@
package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.InboundHoldDtos.CommandRequest;
import com.baiyue.firstleg.entity.InboundHoldRecordEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.mapper.InboundHoldRecordMapper;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
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.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class InboundHoldServiceTest {
@Mock InboundOrderMapper inbounds;
@Mock InboundHoldRecordMapper history;
@Mock InboundIdempotencyService idempotency;
@Mock InboundHoldGuard guard;
@InjectMocks InboundHoldService service;
@Test
void holdsOwnedInboundWithOptimisticVersionAndImmutableHistory() {
InboundOrderEntity before = inbound(false, null, 3);
InboundOrderEntity after = inbound(true, "海关抽查", 4);
when(idempotency.begin(7L, 9L, 11L, "INBOUND_HOLD", "hold-key",
"HOLD|7|11|3|海关抽查|88")).thenReturn(new InboundIdempotencyService.Context(7L, 101L, false));
when(inbounds.selectActiveByIdForUpdate(11L)).thenReturn(before);
when(inbounds.updateManualHold(7L, 11L, 3, false, true, "海关抽查", 9L)).thenReturn(1);
when(inbounds.selectById(11L)).thenReturn(after);
when(history.selectList(any(Wrapper.class))).thenReturn(List.of());
var result = service.hold(7L, 11L, 9L, "平台人员", "hold-key",
new CommandRequest(3, " 海关抽查 ", 88L));
assertThat(result.holdFlag()).isTrue();
assertThat(result.currentHoldReason()).isEqualTo("海关抽查");
assertThat(result.version()).isEqualTo(4);
assertThat(result.canRelease()).isTrue();
ArgumentCaptor<InboundHoldRecordEntity> record = ArgumentCaptor.forClass(InboundHoldRecordEntity.class);
verify(history).insert(record.capture());
assertThat(record.getValue().getHoldAction()).isEqualTo("HOLD");
assertThat(record.getValue().getReason()).isEqualTo("海关抽查");
assertThat(record.getValue().getEvidenceFileId()).isEqualTo(88L);
verify(idempotency).complete(new InboundIdempotencyService.Context(7L, 101L, false), 11L);
}
@Test
void replaysSameCompletedCommandWithoutWritingAgain() {
when(idempotency.begin(anyLong(), anyLong(), anyLong(), eq("INBOUND_HOLD"), anyString(), anyString()))
.thenReturn(new InboundIdempotencyService.Context(7L, 101L, true));
when(inbounds.selectById(11L)).thenReturn(inbound(true, "检查", 4));
when(history.selectList(any(Wrapper.class))).thenReturn(List.of());
assertThat(service.hold(7L, 11L, 9L, "平台人员", "hold-key",
new CommandRequest(3, "检查", null)).holdFlag()).isTrue();
verify(inbounds, never()).updateManualHold(anyLong(), anyLong(), anyInt(), anyBoolean(),
anyBoolean(), any(), anyLong());
verify(history, never()).insert(any(InboundHoldRecordEntity.class));
}
@Test
void rejectsCrossCustomerInboundAsNotFound() {
when(idempotency.begin(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()))
.thenReturn(new InboundIdempotencyService.Context(7L, 101L, false));
InboundOrderEntity other = inbound(false, null, 3);
other.setTenantId(8L);
when(inbounds.selectActiveByIdForUpdate(11L)).thenReturn(other);
assertThatThrownBy(() -> service.hold(7L, 11L, 9L, "平台人员", "hold-key",
new CommandRequest(3, "检查", null)))
.isInstanceOfSatisfying(BizException.class,
error -> assertThat(error.getResultCode()).isEqualTo(ResultCode.NOT_FOUND));
}
@Test
void rejectsManualActionAfterPhysicalPicking() {
when(idempotency.begin(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()))
.thenReturn(new InboundIdempotencyService.Context(7L, 101L, false));
when(inbounds.selectActiveByIdForUpdate(11L)).thenReturn(inbound(false, null, 3));
when(guard.hasPhysicalWork(7L, 11L)).thenReturn(true);
assertThatThrownBy(() -> service.hold(7L, 11L, 9L, "平台人员", "hold-key",
new CommandRequest(3, "检查", null)))
.isInstanceOfSatisfying(BizException.class,
error -> assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT));
verify(inbounds, never()).updateManualHold(anyLong(), anyLong(), anyInt(), anyBoolean(),
anyBoolean(), any(), anyLong());
}
@Test
void releasesAndClearsCurrentReasonButKeepsReleaseHistoryReason() {
when(idempotency.begin(anyLong(), anyLong(), anyLong(), eq("INBOUND_RELEASE"), anyString(), anyString()))
.thenReturn(new InboundIdempotencyService.Context(7L, 102L, false));
when(inbounds.selectActiveByIdForUpdate(11L)).thenReturn(inbound(true, "旧原因", 4));
when(inbounds.updateManualHold(7L, 11L, 4, true, false, null, 9L)).thenReturn(1);
when(inbounds.selectById(11L)).thenReturn(inbound(false, null, 5));
when(history.selectList(any(Wrapper.class))).thenReturn(List.of());
var result = service.release(7L, 11L, 9L, "平台人员", "release-key",
new CommandRequest(4, "复核通过", null));
assertThat(result.holdFlag()).isFalse();
assertThat(result.currentHoldReason()).isNull();
ArgumentCaptor<InboundHoldRecordEntity> record = ArgumentCaptor.forClass(InboundHoldRecordEntity.class);
verify(history).insert(record.capture());
assertThat(record.getValue().getHoldAction()).isEqualTo("RELEASE");
assertThat(record.getValue().getReason()).isEqualTo("复核通过");
}
private static InboundOrderEntity inbound(boolean held, String reason, int version) {
InboundOrderEntity value = new InboundOrderEntity();
value.setId(11L);
value.setTenantId(7L);
value.setHoldFlag(held);
value.setCurrentHoldReason(reason);
value.setVersion(version);
value.setDeleted(false);
value.setProcessStatus("WAIT_SPLIT");
return value;
}
}
@@ -89,7 +89,7 @@ class InboundOrderCreationServiceTest {
base.containerMode(), base.customsInspection(), base.destinationType(), base.destinationWarehouseId(),
base.destinationCode(), base.destinationName(), base.publicRemark(), base.expressCompanyName(),
base.expressTrackingNo(), base.totalPackageCount(), base.pickupMethod(), base.requestedPickupAt(),
"ONE_CLICK_DROPSHIP");
"ONE_CLICK_DROPSHIP", base.lxCode());
when(orders.selectOne(any(Wrapper.class))).thenReturn(null);
when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L));
@@ -62,6 +62,8 @@ class LoadingListServiceTest {
private LoadingDocumentFileClient documentFiles;
@MockBean
private LoadingCompletedOutboxService loadingCompletedOutboxService;
@MockBean
private InboundHoldGuard inboundHoldGuard;
@Resource
private LoadingListService service;
@@ -101,6 +101,8 @@ class LoadingPickServiceTest {
private PdaPickSettlementService settlementService;
@MockBean
private InboundPalletAllocationRepository palletAllocations;
@MockBean
private InboundHoldGuard inboundHoldGuard;
@Resource
private LoadingPickService service;
@@ -26,6 +26,7 @@ import static org.mockito.Mockito.when;
class LoadingWorkPalletServiceTest {
@MockBean private LoadingWorkPalletMapper pallets;
@MockBean private LoadingWorkPalletCartonMapper cartons;
@MockBean private InboundHoldGuard inboundHoldGuard;
@Resource private LoadingWorkPalletService service;
@Test
@@ -42,6 +42,7 @@ class SmartLoadingProvisionalAdjustmentTest {
@Mock private PendingReviewOrderClient pendingReviewOrders;
@Mock private InboundOrderCreationService inboundOrderCreationService;
@Mock private LoadingPickService loadingPickService;
@Mock private InboundHoldGuard inboundHoldGuard;
@Mock private LoadingModelBridgeService loadingModelBridge;
@Mock private SplitPreloadService splitPreloadService;
@Mock private ColoadGroupAssignmentPort coloadGroups;
@@ -89,6 +89,7 @@ class SmartLoadingServiceTest {
@MockBean private PendingReviewOrderClient pendingReviewOrders;
@MockBean private InboundOrderCreationService inboundOrderCreationService;
@MockBean private LoadingPickService loadingPickService;
@MockBean private InboundHoldGuard inboundHoldGuard;
@MockBean private LoadingModelBridgeService loadingModelBridge;
@MockBean private SplitPreloadService splitPreloadService;
@MockBean private ColoadGroupAssignmentPort coloadGroups;