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);