feat: add inbound hold lifecycle
This commit is contained in:
+40
@@ -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
|
||||
) { }
|
||||
}
|
||||
+5
@@ -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}
|
||||
|
||||
+3
-3
@@ -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) {}
|
||||
|
||||
+2
@@ -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;
|
||||
}
|
||||
}
|
||||
+2
@@ -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
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
+18
@@ -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;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user