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
@@ -2560,7 +2560,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order (
destination_code VARCHAR(32) NULL COMMENT '目的仓编码,例如FBA仓编码', destination_code VARCHAR(32) NULL COMMENT '目的仓编码,例如FBA仓编码',
destination_name VARCHAR(128) NULL COMMENT '目的仓名称快照', destination_name VARCHAR(128) NULL COMMENT '目的仓名称快照',
hold_flag TINYINT(1) NOT NULL DEFAULT 0 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 '海外暂扣事件最新聚合版本,用于阻止乱序覆盖', last_overseas_hold_version BIGINT UNSIGNED NULL COMMENT '海外暂扣事件最新聚合版本,用于阻止乱序覆盖',
total_package_count INT UNSIGNED NULL COMMENT '确认后的总箱数', total_package_count INT UNSIGNED NULL COMMENT '确认后的总箱数',
actual_gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认后的实际总毛重kg', actual_gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认后的实际总毛重kg',
@@ -17914,6 +17914,92 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab
PREPARE lx_inbound_ddl FROM @ddl; EXECUTE lx_inbound_ddl; DEALLOCATE PREPARE lx_inbound_ddl; PREPARE lx_inbound_ddl FROM @ddl; EXECUTE lx_inbound_ddl; DEALLOCATE PREPARE lx_inbound_ddl;
SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_outbound_order' AND column_name='lx_codes'), 'SELECT 1', 'ALTER TABLE baiyue_lastleg.ll_outbound_order ADD COLUMN lx_codes VARCHAR(1000) NULL COMMENT ''关联一件代发领星入库单代码汇总'' AFTER outbound_type')); SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_lastleg' AND table_name='ll_outbound_order' AND column_name='lx_codes'), 'SELECT 1', 'ALTER TABLE baiyue_lastleg.ll_outbound_order ADD COLUMN lx_codes VARCHAR(1000) NULL COMMENT ''关联一件代发领星入库单代码汇总'' AFTER outbound_type'));
PREPARE lx_outbound_ddl FROM @ddl; EXECUTE lx_outbound_ddl; DEALLOCATE PREPARE lx_outbound_ddl; PREPARE lx_outbound_ddl FROM @ddl; EXECUTE lx_outbound_ddl; DEALLOCATE PREPARE lx_outbound_ddl;
ALTER TABLE baiyue_firstleg.fl_inbound_order
MODIFY COLUMN current_hold_reason VARCHAR(1000) NULL COMMENT '当前扣货理由快照';
USE baiyue_iam;
DROP PROCEDURE IF EXISTS migrate_inbound_hold_permission;
DELIMITER $$
CREATE PROCEDURE migrate_inbound_hold_permission()
BEGIN
IF EXISTS (
SELECT 1
FROM iam_permission
WHERE (id = 5102032 AND (tenant_id <> 0
OR app_code <> 'PLATFORM'
OR permission_type <> 'BUTTON'
OR permission_code <> 'platform:firstleg:inbound:hold'))
OR (permission_code = 'platform:firstleg:inbound:hold'
AND (id <> 5102032 OR tenant_id <> 0 OR app_code <> 'PLATFORM'))
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='入库单扣货权限初始化失败:权限ID、编码或类型冲突';
END IF;
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code,
permission_name, route_path, http_method, sort_no, status, built_in, deleted)
VALUES
(5102032, 0, 'PLATFORM', 'BUTTON', 5102030, 'platform:firstleg:inbound:hold',
'扣货与解除扣货', NULL, 'POST', 20, 'ACTIVE', 1, 0)
ON DUPLICATE KEY UPDATE
permission_name=VALUES(permission_name), parent_id=VALUES(parent_id),
route_path=VALUES(route_path), http_method=VALUES(http_method),
sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0;
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3)
FROM iam_role role
JOIN iam_permission permission
ON permission.tenant_id=0
AND permission.app_code='PLATFORM'
AND permission.permission_code='platform:firstleg:inbound:hold'
WHERE role.tenant_id=0
AND role.app_code='PLATFORM'
AND role.role_code='PLATFORM_ADMIN'
AND role.status='ACTIVE'
AND role.deleted=0
AND permission.status='ACTIVE'
AND permission.deleted=0;
IF (SELECT COUNT(*)
FROM iam_permission
WHERE id=5102032
AND tenant_id=0
AND app_code='PLATFORM'
AND permission_type='BUTTON'
AND parent_id=5102030
AND permission_code='platform:firstleg:inbound:hold'
AND http_method='POST'
AND status='ACTIVE'
AND built_in=1
AND deleted=0) <> 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='入库单扣货权限初始化失败:权限资源不完整';
END IF;
IF (SELECT COUNT(*)
FROM iam_role_permission grant_row
JOIN iam_role role
ON role.id=grant_row.role_id
AND role.tenant_id=grant_row.tenant_id
JOIN iam_permission permission
ON permission.id=grant_row.permission_id
AND permission.tenant_id=grant_row.tenant_id
WHERE role.tenant_id=0
AND role.app_code='PLATFORM'
AND role.role_code='PLATFORM_ADMIN'
AND role.status='ACTIVE'
AND role.deleted=0
AND permission.permission_code='platform:firstleg:inbound:hold'
AND permission.status='ACTIVE'
AND permission.deleted=0) <> 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='入库单扣货权限初始化失败:平台超级管理员授权不完整';
END IF;
END$$
DELIMITER ;
CALL migrate_inbound_hold_permission();
DROP PROCEDURE IF EXISTS migrate_inbound_hold_permission;
DROP PROCEDURE IF EXISTS assert_wallet_event_schema; DROP PROCEDURE IF EXISTS assert_wallet_event_schema;
DELIMITER $$ DELIMITER $$
CREATE PROCEDURE assert_wallet_event_schema() CREATE PROCEDURE assert_wallet_event_schema()
@@ -17936,4 +18022,4 @@ DROP PROCEDURE IF EXISTS assert_wallet_event_schema;
CALL baiyue_firstleg.assert_unified_final_schema(); CALL baiyue_firstleg.assert_unified_final_schema();
DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema;
SELECT 'PASS' AS migration_status, SELECT 'PASS' AS migration_status,
'统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、客户资金账户、公共资源、可靠审计及IAM安全事件结构校验通过' AS message; '统一增量全部执行完成;父子订单、入库扣货、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、客户资金账户、公共资源、可靠审计及IAM安全事件结构校验通过' AS message;
@@ -10,9 +10,12 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.InboundOrderDetail; import com.baiyue.firstleg.dto.InboundOrderDetail;
import com.baiyue.firstleg.dto.InboundOrderQuery; import com.baiyue.firstleg.dto.InboundOrderQuery;
import com.baiyue.firstleg.dto.InboundOrderSummary; 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.FirstlegScopeGuard;
import com.baiyue.firstleg.service.InboundCartonLabelPrintService; import com.baiyue.firstleg.service.InboundCartonLabelPrintService;
import com.baiyue.firstleg.service.InboundOrderQueryService; 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.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
@@ -39,6 +42,7 @@ import java.util.List;
@RequestMapping("/api/platform/inbounds") @RequestMapping("/api/platform/inbounds")
public class PlatformInboundOrderController { public class PlatformInboundOrderController {
@Resource private InboundOrderQueryService service; @Resource private InboundOrderQueryService service;
@Resource private InboundHoldService holdService;
@Resource private InboundCartonLabelPrintService cartonLabelPrintService; @Resource private InboundCartonLabelPrintService cartonLabelPrintService;
@Operation(summary = "分页查询入库单", @Operation(summary = "分页查询入库单",
@@ -68,6 +72,42 @@ public class PlatformInboundOrderController {
return ApiResponse.success(service.detail(tenantId, inboundId)); 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", @Operation(summary = "生成入仓箱唛 PDF",
description = "首次调用按订单货物明细冻结整单箱号;重复打印及指定箱下标补打复用原箱号映射。") description = "首次调用按订单货物明细冻结整单箱号;重复打印及指定箱下标补打复用原箱号映射。")
@PostMapping("/{inboundId}/carton-label-pdf") @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.entity.CabinetLoadingEvidenceEntity;
import com.baiyue.firstleg.loading.mapper.CabinetLoadingEvidenceMapper; import com.baiyue.firstleg.loading.mapper.CabinetLoadingEvidenceMapper;
import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.service.InboundHoldGuard;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import feign.FeignException; import feign.FeignException;
@@ -41,10 +42,12 @@ public class CabinetLoadingEvidenceService {
@Resource private LoadingPickRepository loadingPicks; @Resource private LoadingPickRepository loadingPicks;
@Resource private LoadingEvidenceFileClient files; @Resource private LoadingEvidenceFileClient files;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
@Resource private InboundHoldGuard inboundHoldGuard;
@Value("${baiyue.security.internal-service-token:}") private String internalToken; @Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Transactional @Transactional
public EvidenceList upsert(long taskId, String key, UpsertRequest request, long operatorId, String operatorName) { 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)); 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, public EvidenceList upload(long taskId, long warehouseId, String key, int expectedVersion,
String evidenceType, String sealNo, String remark, MultipartFile multipart, String evidenceType, String sealNo, String remark, MultipartFile multipart,
long operatorId, String operatorName) { long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
Scope scope = scope(taskId, warehouseId, operatorId, true); Scope scope = scope(taskId, warehouseId, operatorId, true);
requireKey(key); requireKey(key);
String requestHash = uploadHash(warehouseId, expectedVersion, evidenceType, sealNo, remark, multipart); String requestHash = uploadHash(warehouseId, expectedVersion, evidenceType, sealNo, remark, multipart);
@@ -128,6 +132,7 @@ public class CabinetLoadingEvidenceService {
@Transactional @Transactional
public EvidenceList voidEvidence(long taskId, long evidenceId, VoidRequest request, public EvidenceList voidEvidence(long taskId, long evidenceId, VoidRequest request,
long operatorId, String operatorName) { long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
Scope scope = scope(taskId, request.warehouseId(), operatorId, true); Scope scope = scope(taskId, request.warehouseId(), operatorId, true);
CabinetLoadingEvidenceEntity value = mapper.byContainer(scope.containerBookingId()).stream() CabinetLoadingEvidenceEntity value = mapper.byContainer(scope.containerBookingId()).stream()
.filter(item -> Objects.equals(item.getId(), evidenceId)).findFirst() .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.loading.repository.LoadingRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.service.FbaPricingService; import com.baiyue.firstleg.service.FbaPricingService;
import com.baiyue.firstleg.service.InboundHoldGuard;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -43,6 +44,7 @@ public class LoadingDeliveryService {
@Resource private CabinetLoadingEvidenceService cabinetEvidenceService; @Resource private CabinetLoadingEvidenceService cabinetEvidenceService;
@Resource private FbaPricingService pricingService; @Resource private FbaPricingService pricingService;
@Resource private ObjectMapper objectMapper; @Resource private ObjectMapper objectMapper;
@Resource private InboundHoldGuard inboundHoldGuard;
@Transactional @Transactional
public LoadingActionResponse completeLoading(long tenantId, long inboundId, long operatorId, public LoadingActionResponse completeLoading(long tenantId, long inboundId, long operatorId,
@@ -54,6 +56,7 @@ public class LoadingDeliveryService {
long operatorId, String operatorName, String key, long operatorId, String operatorName, String key,
CompleteLoadingRequest request, CompleteLoadingRequest request,
boolean acceptedPdaCompletion) { boolean acceptedPdaCompletion) {
inboundHoldGuard.requireInboundNotHeld(tenantId, inboundId);
String hash = hash(request); String hash = hash(request);
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, COMPLETE_LOADING, LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, COMPLETE_LOADING,
key, hash, acceptedPdaCompletion); key, hash, acceptedPdaCompletion);
@@ -106,6 +109,7 @@ public class LoadingDeliveryService {
* every external/business hard gate must pass before the PDA task is mutated. * every external/business hard gate must pass before the PDA task is mutated.
*/ */
public void requirePdaCompletionReady(long tenantId, long inboundId) { public void requirePdaCompletionReady(long tenantId, long inboundId) {
inboundHoldGuard.requireInboundNotHeld(tenantId, inboundId);
Snapshot inbound = requireInbound(tenantId, inboundId); Snapshot inbound = requireInbound(tenantId, inboundId);
if (!"LOADING".equals(inbound.processStatus())) { if (!"LOADING".equals(inbound.processStatus())) {
throw new BizException(ResultCode.CONFLICT, "PDA确认装柜完成前订单必须处于LOADING"); throw new BizException(ResultCode.CONFLICT, "PDA确认装柜完成前订单必须处于LOADING");
@@ -144,6 +148,7 @@ public class LoadingDeliveryService {
@Transactional @Transactional
public PickingResponse startPicking(long tenantId, long inboundId, long operatorId, String operatorName, public PickingResponse startPicking(long tenantId, long inboundId, long operatorId, String operatorName,
String key, PickingRequest request) { String key, PickingRequest request) {
inboundHoldGuard.requireInboundNotHeld(tenantId, inboundId);
String hash = hash(request); String hash = hash(request);
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, "START_PICKING", key, hash); LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, "START_PICKING", key, hash);
if (replay != null) return pickResponse(tenantId, inboundId); if (replay != null) return pickResponse(tenantId, inboundId);
@@ -171,6 +176,7 @@ public class LoadingDeliveryService {
@Transactional @Transactional
public PickingResponse completePicking(long tenantId, long inboundId, long operatorId, String operatorName, public PickingResponse completePicking(long tenantId, long inboundId, long operatorId, String operatorName,
String key, PickingRequest request) { String key, PickingRequest request) {
inboundHoldGuard.requireInboundNotHeld(tenantId, inboundId);
String hash = hash(request); String hash = hash(request);
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, "COMPLETE_PICKING", key, hash); LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, "COMPLETE_PICKING", key, hash);
if (replay != null) return pickResponse(tenantId, inboundId); 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("detained") boolean detained, @Param("reason") String reason,
@Param("sourceVersion") long sourceVersion, @Param("operatorId") long operatorId); @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("""
UPDATE fl_inbound_order UPDATE fl_inbound_order
SET process_status = CASE WHEN fulfillment_type='ONE_CLICK_DROPSHIP' 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, List<Long> occupiedOrderIds(@Param("query") InboundCandidateQuery query,
@Param("actualChannels") Set<String> actualChannels); @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("""
SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId, SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId,
o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode, o.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 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 ) 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' 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.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 (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 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 AND split_item.parent_inbound_id=o.id),0) availablePackageCount
FROM fl_inbound_order o FROM fl_inbound_order o
WHERE (#{query.warehouseId} IS NULL OR o.warehouse_id=#{query.warehouseId}) WHERE (#{query.warehouseId} IS NULL OR o.warehouse_id=#{query.warehouseId})
AND COALESCE(o.hold_flag,0)=0
AND o.channel_code IN AND o.channel_code IN
<foreach collection="actualChannels" item="channel" open="(" separator="," close=")">#{channel}</foreach> <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')) 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 LEFT JOIN fl_coload_group current_group
ON current_group.id=current_member.group_id AND current_group.group_status='ACTIVE' ON current_group.id=current_member.group_id AND current_group.group_status='ACTIVE'
WHERE o.process_status='WAIT_SPLIT' 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.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 (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT'))
AND ( 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 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 LEFT JOIN fl_coload_group current_group
ON current_group.id=current_member.group_id AND current_group.group_status IN ('ACTIVE','LOCKED') 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') 'WAIT_PRICING','FEE_CONFIRMING','INBOUNDING','LOADING')
AND ( AND (
<foreach collection="units" item="unit" separator=" OR "> <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 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 ) i ON i.tenant_id=o.tenant_id AND i.inbound_id=o.id AND i.measurement_id=m.id
WHERE o.id=#{inboundId} WHERE o.id=#{inboundId}
AND COALESCE(o.hold_flag,0)=0
AND (o.process_status='WAIT_SPLIT' OR (o.process_status='LOADING' AND EXISTS ( AND (o.process_status='WAIT_SPLIT' OR (o.process_status='LOADING' AND EXISTS (
SELECT 1 FROM fl_loading_plan_item same_plan_item SELECT 1 FROM fl_loading_plan_item same_plan_item
WHERE same_plan_item.loading_plan_id=#{planId} WHERE same_plan_item.loading_plan_id=#{planId}
@@ -13,7 +13,7 @@ public interface OutboundDispatchReadMapper {
@Select(""" @Select("""
SELECT DISTINCT inbound.order_id orderId,inbound.order_no orderNo,inbound.id inboundId, 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.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, booking.id containerBookingId,booking.cabinet_no cabinetNo,booking.container_no containerNo,
plan.id loadingPlanId,plan.plan_status loadingPlanStatus plan.id loadingPlanId,plan.plan_status loadingPlanStatus
FROM fl_loading_plan_item item FROM fl_loading_plan_item item
@@ -29,7 +29,7 @@ public interface OutboundDispatchReadMapper {
@Select(""" @Select("""
SELECT inbound.order_id orderId,inbound.order_no orderNo,inbound.id inboundId, 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.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, booking.id containerBookingId,booking.cabinet_no cabinetNo,booking.container_no containerNo,
plan.id loadingPlanId,plan.plan_status loadingPlanStatus plan.id loadingPlanId,plan.plan_status loadingPlanStatus
FROM fl_loading_plan_item item FROM fl_loading_plan_item item
@@ -178,7 +178,7 @@ public interface OutboundDispatchReadMapper {
@Param("truckAppointmentId") Long truckAppointmentId); @Param("truckAppointmentId") Long truckAppointmentId);
record Binding(Long orderId, String orderNo, Long inboundId, String inboundCode, Long tenantId, 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) {} String cabinetNo, String containerNo, Long loadingPlanId, String loadingPlanStatus) {}
record IdStatus(Long id, String status) {} record IdStatus(Long id, String status) {}
record OceanBinding(Long id, String status, Long customerId) {} record OceanBinding(Long id, String status, Long customerId) {}
@@ -179,6 +179,8 @@ public class OutboundDispatchService {
OutboundDispatchReadMapper.OceanBinding ocean, OutboundDispatchReadMapper.Evidence evidence, OutboundDispatchReadMapper.OceanBinding ocean, OutboundDispatchReadMapper.Evidence evidence,
List<GateItem> containerContributorGates) { List<GateItem> containerContributorGates) {
List<GateItem> items = new ArrayList<>(); 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()), items.add(item("INBOUND_LOADED", "入库单装柜完成", "LOADED".equals(binding.inboundStatus()),
binding.inboundStatus(), "LOADED")); binding.inboundStatus(), "LOADED"));
items.add(item("LOADING_PLAN_COMPLETED", "柜次装柜计划完成", "COMPLETED".equals(binding.loadingPlanStatus()), items.add(item("LOADING_PLAN_COMPLETED", "柜次装柜计划完成", "COMPLETED".equals(binding.loadingPlanStatus()),
@@ -60,7 +60,9 @@ public class SmartLoadingRepository {
return candidates.planColoadMembers(planId); return candidates.planColoadMembers(planId);
} }
public List<Long> occupiedOrderIds(InboundCandidateQuery query, Set<String> actualChannels) { 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) { public Candidate candidate(long planId, long inboundId, long detailId) {
return candidates.candidate(planId, inboundId, 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 @Lazy
private LoadingPickService loadingPickService; private LoadingPickService loadingPickService;
@Resource @Resource
private InboundHoldGuard inboundHoldGuard;
@Resource
private LoadingCompletedOutboxService loadingCompletedOutboxService; private LoadingCompletedOutboxService loadingCompletedOutboxService;
@Resource @Resource
private ObjectMapper objectMapper; private ObjectMapper objectMapper;
@@ -491,6 +493,7 @@ public class LoadingListService {
public PreloadConfirmationResponse confirmPreload(long planId, long operatorId, String operatorName, public PreloadConfirmationResponse confirmPreload(long planId, long operatorId, String operatorName,
String idempotencyKey, String idempotencyKey,
ConfirmPreloadRequest request) { ConfirmPreloadRequest request) {
inboundHoldGuard.requirePlanNotHeld(planId);
String requestHash = sha256("CONFIRM_PRELOAD|" + planId + '|' + request.expectedVersion()); String requestHash = sha256("CONFIRM_PRELOAD|" + planId + '|' + request.expectedVersion());
String operationId = operationId("CONFIRM_PRELOAD", planId, idempotencyKey); String operationId = operationId("CONFIRM_PRELOAD", planId, idempotencyKey);
HistoryRow replay = repository.history(operationId); HistoryRow replay = repository.history(operationId);
@@ -104,6 +104,8 @@ public class LoadingPickService {
private PdaPickSettlementService settlementService; private PdaPickSettlementService settlementService;
@Resource @Resource
private InboundPalletAllocationRepository palletAllocations; private InboundPalletAllocationRepository palletAllocations;
@Resource
private InboundHoldGuard inboundHoldGuard;
/** Opens the field task after planning and ocean release; the real container number may be filled later. */ /** Opens the field task after planning and ocean release; the real container number may be filled later. */
@Transactional @Transactional
@@ -112,6 +114,7 @@ public class LoadingPickService {
if (booking == null) return; if (booking == null) return;
var plan = smartLoadingRepository.planByBooking(containerBookingId); var plan = smartLoadingRepository.planByBooking(containerBookingId);
if (plan == null || !"EDITING".equals(plan.getPlanStatus())) return; if (plan == null || !"EDITING".equals(plan.getPlanStatus())) return;
inboundHoldGuard.requirePlanNotHeld(plan.getId());
var links = loadingModelBridge.byLoadingPlan(plan.getId()); var links = loadingModelBridge.byLoadingPlan(plan.getId());
if (links.isEmpty() || links.stream().anyMatch(link -> !"CONFIRMED".equals(link.preloadStatus()))) return; if (links.isEmpty() || links.stream().anyMatch(link -> !"CONFIRMED".equals(link.preloadStatus()))) return;
var allocation = oceanBookingRepository.allocationByContainer(containerBookingId); var allocation = oceanBookingRepository.allocationByContainer(containerBookingId);
@@ -124,6 +127,7 @@ public class LoadingPickService {
} }
public List<Link> requirePreloadConfirmationReady(long loadingPlanId) { public List<Link> requirePreloadConfirmationReady(long loadingPlanId) {
inboundHoldGuard.requirePlanNotHeld(loadingPlanId);
List<Link> links = loadingModelBridge.requireAllocatedLinksForConfirmation(loadingPlanId); List<Link> links = loadingModelBridge.requireAllocatedLinksForConfirmation(loadingPlanId);
for (Link link : links) { for (Link link : links) {
if (!Set.of("PENDING", "CONFIRMING").contains(link.preloadStatus())) { if (!Set.of("PENDING", "CONFIRMING").contains(link.preloadStatus())) {
@@ -145,6 +149,7 @@ public class LoadingPickService {
@Transactional @Transactional
public void begin(long planId, String containerNo, long operatorId, String operatorName, public void begin(long planId, String containerNo, long operatorId, String operatorName,
List<LoadingPlanItemEntity> planItems) { List<LoadingPlanItemEntity> planItems) {
inboundHoldGuard.requirePlanNotHeld(planId);
LoadingPickTaskEntity task = repository.taskByPlan(planId); LoadingPickTaskEntity task = repository.taskByPlan(planId);
if (task == null) { if (task == null) {
task = new LoadingPickTaskEntity(); task = new LoadingPickTaskEntity();
@@ -242,6 +247,7 @@ public class LoadingPickService {
@Transactional @Transactional
public Task claim(long taskId, ClaimRequest request, long operatorId, String operatorName) { public Task claim(long taskId, ClaimRequest request, long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
String operationId = operation(request.operationId()); String operationId = operation(request.operationId());
String requestHash = hash("CLAIM|" + taskId + '|' + request.expectedAssignmentVersion()); String requestHash = hash("CLAIM|" + taskId + '|' + request.expectedAssignmentVersion());
Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId); Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId);
@@ -271,6 +277,7 @@ public class LoadingPickService {
@Transactional @Transactional
public Task assign(long taskId, AssignRequest request, long operatorId, String operatorName) { public Task assign(long taskId, AssignRequest request, long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
String operationId = operation(request.operationId()); String operationId = operation(request.operationId());
String assigneeName = request.assigneeName().trim(); String assigneeName = request.assigneeName().trim();
String requestHash = hash("ASSIGN|" + taskId + '|' + request.assigneeId() + '|' String requestHash = hash("ASSIGN|" + taskId + '|' + request.assigneeId() + '|'
@@ -294,6 +301,7 @@ public class LoadingPickService {
@Transactional @Transactional
public Task pick(long taskId, long itemId, PickRequest request, long operatorId, String operatorName) { public Task pick(long taskId, long itemId, PickRequest request, long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
String operationId = request.operationId().trim(); String operationId = request.operationId().trim();
String requestHash = hash(taskId + "|" + itemId + "|" + request.warehouseId() String requestHash = hash(taskId + "|" + itemId + "|" + request.warehouseId()
+ "|" + request.pickedPackageCount()); + "|" + request.pickedPackageCount());
@@ -338,6 +346,7 @@ public class LoadingPickService {
@Transactional @Transactional
public Task partial(long taskId, long itemId, PartialPickRequest request, public Task partial(long taskId, long itemId, PartialPickRequest request,
long operatorId, String operatorName) { long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
String operationId = request.operationId().trim(); String operationId = request.operationId().trim();
String requestHash = hash("PARTIAL|" + taskId + "|" + itemId + "|" + request.warehouseId() String requestHash = hash("PARTIAL|" + taskId + "|" + itemId + "|" + request.warehouseId()
+ "|" + request.finalPickedPackageCount()); + "|" + request.finalPickedPackageCount());
@@ -368,6 +377,7 @@ public class LoadingPickService {
@Transactional @Transactional
public Task skip(long taskId, long itemId, SkipRequest request, long operatorId, String operatorName) { public Task skip(long taskId, long itemId, SkipRequest request, long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
String operationId = request.operationId().trim(); String operationId = request.operationId().trim();
String reason = trim(request.reason()); String reason = trim(request.reason());
String requestHash = hash("SKIP|" + taskId + "|" + itemId + "|" + request.warehouseId() String requestHash = hash("SKIP|" + taskId + "|" + itemId + "|" + request.warehouseId()
@@ -405,6 +415,7 @@ public class LoadingPickService {
@Transactional @Transactional
public Task batch(long taskId, String idempotencyKey, BatchRequest request, public Task batch(long taskId, String idempotencyKey, BatchRequest request,
long operatorId, String operatorName) { long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
String operationId = operation(idempotencyKey); String operationId = operation(idempotencyKey);
String requestHash = batchRequestHash(taskId, request); String requestHash = batchRequestHash(taskId, request);
Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId); Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId);
@@ -451,6 +462,7 @@ public class LoadingPickService {
@Transactional @Transactional
public Task review(long taskId, ReviewRequest request, long operatorId, String operatorName) { public Task review(long taskId, ReviewRequest request, long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
return complete(taskId, new CompleteLoadingRequest(request.expectedTaskVersion(), request.operationId(), return complete(taskId, new CompleteLoadingRequest(request.expectedTaskVersion(), request.operationId(),
request.remark()), operatorId, operatorName); request.remark()), operatorId, operatorName);
} }
@@ -461,6 +473,7 @@ public class LoadingPickService {
*/ */
@Transactional @Transactional
public Task complete(long taskId, CompleteLoadingRequest request, long operatorId, String operatorName) { public Task complete(long taskId, CompleteLoadingRequest request, long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
String operationId = operation(request.operationId()); String operationId = operation(request.operationId());
String remark = trim(request.remark()); String remark = trim(request.remark());
String requestHash = hash("COMPLETE|" + taskId + '|' + request.expectedTaskVersion() + '|' + remark); String requestHash = hash("COMPLETE|" + taskId + '|' + request.expectedTaskVersion() + '|' + remark);
@@ -29,9 +29,11 @@ import java.util.Objects;
public class LoadingWorkPalletService { public class LoadingWorkPalletService {
@Resource private LoadingWorkPalletMapper pallets; @Resource private LoadingWorkPalletMapper pallets;
@Resource private LoadingWorkPalletCartonMapper cartons; @Resource private LoadingWorkPalletCartonMapper cartons;
@Resource private InboundHoldGuard inboundHoldGuard;
@Transactional @Transactional
public Pallet open(long taskId, OpenRequest request, long operatorId, String operatorName) { public Pallet open(long taskId, OpenRequest request, long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
TaskContext context = requireWorkTask(taskId, request.warehouseId(), operatorId); TaskContext context = requireWorkTask(taskId, request.warehouseId(), operatorId);
String code = request.palletCode().trim(); String code = request.palletCode().trim();
String operationId = request.operationId().trim(); String operationId = request.operationId().trim();
@@ -68,6 +70,7 @@ public class LoadingWorkPalletService {
@Transactional @Transactional
public Pallet scan(long taskId, long palletId, ScanRequest request, public Pallet scan(long taskId, long palletId, ScanRequest request,
long operatorId, String operatorName) { long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
requireWorkTask(taskId, request.warehouseId(), operatorId); requireWorkTask(taskId, request.warehouseId(), operatorId);
LoadingWorkPalletEntity target = requireOpenPallet(taskId, palletId, request.warehouseId()); LoadingWorkPalletEntity target = requireOpenPallet(taskId, palletId, request.warehouseId());
String operationId = request.operationId().trim(); String operationId = request.operationId().trim();
@@ -114,6 +117,7 @@ public class LoadingWorkPalletService {
@Transactional @Transactional
public Pallet seal(long taskId, long palletId, SealRequest request, public Pallet seal(long taskId, long palletId, SealRequest request,
long operatorId, String operatorName) { long operatorId, String operatorName) {
inboundHoldGuard.requireTaskNotHeld(taskId);
requireWorkTask(taskId, request.warehouseId(), operatorId); requireWorkTask(taskId, request.warehouseId(), operatorId);
LoadingWorkPalletEntity replay = pallets.selectBySealOperation(request.operationId().trim()); LoadingWorkPalletEntity replay = pallets.selectBySealOperation(request.operationId().trim());
if (replay != null) { if (replay != null) {
@@ -58,6 +58,7 @@ public class SmartLoadingService {
@Resource private PendingReviewOrderClient pendingReviewOrders; @Resource private PendingReviewOrderClient pendingReviewOrders;
@Resource private InboundOrderCreationService inboundOrderCreationService; @Resource private InboundOrderCreationService inboundOrderCreationService;
@Resource private LoadingPickService loadingPickService; @Resource private LoadingPickService loadingPickService;
@Resource private InboundHoldGuard inboundHoldGuard;
@Resource private LoadingModelBridgeService loadingModelBridge; @Resource private LoadingModelBridgeService loadingModelBridge;
@Resource private SplitPreloadService splitPreloadService; @Resource private SplitPreloadService splitPreloadService;
@Resource private ColoadGroupAssignmentPort coloadGroups; @Resource private ColoadGroupAssignmentPort coloadGroups;
@@ -432,6 +433,7 @@ public class SmartLoadingService {
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
Candidate candidate = repository.candidate(planId, request.inboundId(), request.inboundDetailId()); Candidate candidate = repository.candidate(planId, request.inboundId(), request.inboundDetailId());
if (candidate == null) throw conflict("入库货物不存在或尚未完成入库"); if (candidate == null) throw conflict("入库货物不存在或尚未完成入库");
inboundHoldGuard.requireInboundNotHeld(candidate.accountId(), candidate.inboundId());
if (!wholeGroupAssignment) requireStandalone(candidate.accountId(), candidate.inboundId()); if (!wholeGroupAssignment) requireStandalone(candidate.accountId(), candidate.inboundId());
requireSameCustomerScope(booking, candidate); requireSameCustomerScope(booking, candidate);
var task = loadingPickService.mutationSnapshot(planId); var task = loadingPickService.mutationSnapshot(planId);
@@ -513,6 +515,7 @@ public class SmartLoadingService {
throw conflict("待审核订单快照已变化,请刷新后重试"); throw conflict("待审核订单快照已变化,请刷新后重试");
} }
long inboundId = inboundOrderCreationService.prepareFromDraft(snapshot); long inboundId = inboundOrderCreationService.prepareFromDraft(snapshot);
inboundHoldGuard.requireInboundNotHeld(request.customerId(), inboundId);
LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName); LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName);
if (!"EDITING".equals(plan.getPlanStatus())) { if (!"EDITING".equals(plan.getPlanStatus())) {
throw conflict("待审核订单只能加入编辑中的装柜清单"); throw conflict("待审核订单只能加入编辑中的装柜清单");
@@ -583,6 +586,9 @@ public class SmartLoadingService {
if (replay != null) return replay; if (replay != null) return replay;
InboundOrderEntity projection = repository.inbound(request.inboundId()); InboundOrderEntity projection = repository.inbound(request.inboundId());
if (projection != null) {
inboundHoldGuard.requireInboundNotHeld(projection.getTenantId(), projection.getId());
}
if (!wholeGroupAssignment && projection != null && Set.of("PREPARING", "WAIT_MEASURE", if (!wholeGroupAssignment && projection != null && Set.of("PREPARING", "WAIT_MEASURE",
"MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", "WAIT_PRICING", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", "WAIT_PRICING",
"FEE_CONFIRMING", "INBOUNDING").contains(projection.getProcessStatus())) { "FEE_CONFIRMING", "INBOUNDING").contains(projection.getProcessStatus())) {
@@ -597,6 +603,7 @@ public class SmartLoadingService {
: List.copyOf(frozenDetails); : List.copyOf(frozenDetails);
if (details.isEmpty()) throw conflict("入仓码不存在、已分配完或尚未完成入库"); if (details.isEmpty()) throw conflict("入仓码不存在、已分配完或尚未完成入库");
Candidate first = details.get(0); Candidate first = details.get(0);
inboundHoldGuard.requireInboundNotHeld(first.accountId(), first.inboundId());
if (!wholeGroupAssignment) requireStandalone(first.accountId(), first.inboundId()); if (!wholeGroupAssignment) requireStandalone(first.accountId(), first.inboundId());
if (!repository.activeItemsByInbound(planId, first.accountId(), request.inboundId()).isEmpty()) { if (!repository.activeItemsByInbound(planId, first.accountId(), request.inboundId()).isEmpty()) {
throw conflict("该入仓码已在当前柜次中,请先调整或移除现有分配"); throw conflict("该入仓码已在当前柜次中,请先调整或移除现有分配");
@@ -728,6 +735,7 @@ public class SmartLoadingService {
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
InboundOrderEntity selected = repository.inbound(request.inboundId()); InboundOrderEntity selected = repository.inbound(request.inboundId());
if (selected == null) throw conflict("同柜不可拆组成员当前不可分配"); if (selected == null) throw conflict("同柜不可拆组成员当前不可分配");
inboundHoldGuard.requireInboundNotHeld(selected.getTenantId(), selected.getId());
GroupSnapshot discovered = requiredGroup(selected.getTenantId(), selected.getId()); GroupSnapshot discovered = requiredGroup(selected.getTenantId(), selected.getId());
Map<Long, List<Candidate>> memberCandidates = requireWholeGroupCandidates( Map<Long, List<Candidate>> memberCandidates = requireWholeGroupCandidates(
planId, booking, discovered, repository.items(planId)); planId, booking, discovered, repository.items(planId));
@@ -851,6 +859,7 @@ public class SmartLoadingService {
LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion(), operatorId, operatorName); LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion(), operatorId, operatorName);
LoadingPlanItemEntity item = repository.item(planId, itemId); LoadingPlanItemEntity item = repository.item(planId, itemId);
if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在"); if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在");
inboundHoldGuard.requireInboundNotHeld(item.getAccountId(), item.getInboundId());
requireStandalone(item.getAccountId(), item.getInboundId()); requireStandalone(item.getAccountId(), item.getInboundId());
if (!Objects.equals(item.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突"); if (!Objects.equals(item.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突");
LoadingPickService.MutationSnapshot pickSnapshot = loadingPickService.mutationSnapshot(planId); 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_code VARCHAR(32) NULL COMMENT '目的仓编码,例如FBA仓编码',
destination_name VARCHAR(128) NULL COMMENT '目的仓名称快照', destination_name VARCHAR(128) NULL COMMENT '目的仓名称快照',
hold_flag TINYINT(1) NOT NULL DEFAULT 0 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 '海外暂扣事件最新聚合版本,用于阻止乱序覆盖', last_overseas_hold_version BIGINT UNSIGNED NULL COMMENT '海外暂扣事件最新聚合版本,用于阻止乱序覆盖',
total_package_count INT UNSIGNED NULL COMMENT '确认后的总箱数', total_package_count INT UNSIGNED NULL COMMENT '确认后的总箱数',
actual_gross_weight_kg DECIMAL(18,3) NULL COMMENT '已确认称重图片含托盘毛重汇总kg', actual_gross_weight_kg DECIMAL(18,3) NULL COMMENT '已确认称重图片含托盘毛重汇总kg',
@@ -6,6 +6,44 @@ info:
security: security:
- bearerAuth: [] - bearerAuth: []
paths: 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: /api/merchant/v1/first-leg/inbounds/{inboundId}/carton-label-pdf:
post: post:
summary: 客户端生成本客户入库单箱唛 PDF summary: 客户端生成本客户入库单箱唛 PDF
@@ -1218,6 +1256,38 @@ components:
required: true required: true
schema: {type: string, pattern: '^[1-9][0-9]*$'} schema: {type: string, pattern: '^[1-9][0-9]*$'}
schemas: 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: OrderChannelChangeValidationRequest:
type: object type: object
additionalProperties: false additionalProperties: false
@@ -0,0 +1,87 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.InboundHoldDtos.CommandRequest;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestHeader;
import org.yaml.snakeyaml.Yaml;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class InboundHoldContractTest {
@Test
void controllerUsesIndependentWritePermissionAndRequiredIdempotencyKey() throws Exception {
Method detail = PlatformInboundOrderController.class.getDeclaredMethod(
"holdDetail", AuthenticatedUser.class, long.class, long.class);
Method hold = commandMethod("hold");
Method release = commandMethod("releaseHold");
assertThat(detail.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:firstleg:inbound:list')");
assertThat(hold.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:firstleg:inbound:hold')");
assertThat(release.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:firstleg:inbound:hold')");
assertThat(hold.getParameters()[3].getAnnotation(RequestHeader.class).value())
.isEqualTo("Idempotency-Key");
assertThat(hold.getParameters()[3].getAnnotation(RequestHeader.class).required()).isTrue();
assertThat(release.getParameters()[3].getAnnotation(RequestHeader.class).required()).isTrue();
}
@Test
void openApiFreezesHoldPathsFieldsAndValidationWithoutLegacyAliases() throws Exception {
String source = new ClassPathResource("openapi/firstleg-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
Map<String, Object> document = stringMap(new Yaml().load(source));
Map<String, Object> paths = stringMap(document.get("paths"));
assertThat(paths.keySet()).contains(
"/api/platform/inbounds/{inboundId}/hold",
"/api/platform/inbounds/{inboundId}/hold/release");
Map<String, Object> schemas = stringMap(stringMap(document.get("components")).get("schemas"));
Map<String, Object> command = stringMap(schemas.get("InboundHoldCommand"));
assertThat(stringList(command.get("required"))).containsExactly("expectedVersion", "reason");
assertThat(stringMap(command.get("properties")).keySet())
.containsExactlyInAnyOrder("expectedVersion", "reason", "evidenceFileId");
assertThat(stringMap(stringMap(command.get("properties")).get("expectedVersion")))
.containsEntry("minimum", 0);
assertThat(stringMap(stringMap(command.get("properties")).get("reason")))
.containsEntry("minLength", 1).containsEntry("maxLength", 1000);
Map<String, Object> detail = stringMap(schemas.get("InboundHoldDetail"));
assertThat(stringMap(detail.get("properties")).keySet()).containsExactlyInAnyOrder(
"inboundId", "tenantId", "holdFlag", "currentHoldReason", "version",
"canHold", "canRelease", "actionBlockReason", "history");
assertThat(stringMap(stringMap(detail.get("properties")).get("holdFlag")))
.containsEntry("type", "boolean").doesNotContainKey("nullable");
Map<String, Object> history = stringMap(schemas.get("InboundHoldHistoryItem"));
assertThat(stringMap(history.get("properties")).keySet()).containsExactlyInAnyOrder(
"id", "action", "reason", "evidenceFileId", "operatorName", "operatedAt");
assertThat(source).contains("platform:firstleg:inbound:hold")
.doesNotContain("hold" + "Cargo:")
.doesNotContain("hold" + "Reason:");
}
private static Method commandMethod(String name) throws NoSuchMethodException {
return PlatformInboundOrderController.class.getDeclaredMethod(name, AuthenticatedUser.class,
long.class, long.class, String.class, CommandRequest.class);
}
@SuppressWarnings("unchecked")
private static Map<String, Object> stringMap(Object value) {
return (Map<String, Object>) value;
}
@SuppressWarnings("unchecked")
private static List<String> stringList(Object value) {
return (List<String>) value;
}
}
@@ -8,6 +8,7 @@ import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.loading.entity.CabinetLoadingEvidenceEntity; import com.baiyue.firstleg.loading.entity.CabinetLoadingEvidenceEntity;
import com.baiyue.firstleg.loading.mapper.CabinetLoadingEvidenceMapper; import com.baiyue.firstleg.loading.mapper.CabinetLoadingEvidenceMapper;
import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.service.InboundHoldGuard;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@@ -35,6 +36,7 @@ class CabinetLoadingEvidenceServiceTest {
@MockBean LoadingPickRepository loadingPicks; @MockBean LoadingPickRepository loadingPicks;
@MockBean LoadingEvidenceFileClient files; @MockBean LoadingEvidenceFileClient files;
@MockBean ObjectMapper objectMapper; @MockBean ObjectMapper objectMapper;
@MockBean InboundHoldGuard inboundHoldGuard;
@Resource CabinetLoadingEvidenceService service; @Resource CabinetLoadingEvidenceService service;
@BeforeEach @BeforeEach
@@ -7,6 +7,7 @@ import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper.InboundProgress; import com.baiyue.firstleg.mapper.LoadingPickItemMapper.InboundProgress;
import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.service.FbaPricingService; import com.baiyue.firstleg.service.FbaPricingService;
import com.baiyue.firstleg.service.InboundHoldGuard;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -30,6 +31,7 @@ class LoadingDeliveryServiceTest {
@MockBean CabinetLoadingEvidenceService cabinetEvidence; @MockBean CabinetLoadingEvidenceService cabinetEvidence;
@MockBean FbaPricingService pricingService; @MockBean FbaPricingService pricingService;
@MockBean ObjectMapper objectMapper; @MockBean ObjectMapper objectMapper;
@MockBean InboundHoldGuard inboundHoldGuard;
@Resource LoadingDeliveryService service; @Resource LoadingDeliveryService service;
@Test @Test
@@ -266,7 +266,7 @@ class OutboundDispatchServiceTest {
private static OutboundDispatchReadMapper.Binding binding(long inboundId, String inboundCode) { private static OutboundDispatchReadMapper.Binding binding(long inboundId, String inboundCode) {
return new OutboundDispatchReadMapper.Binding(10L, "O-10", inboundId, inboundCode, 7L, 8L, "客户A", 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) { private static OutboundDispatchEntity state(long id, long inboundId, String status, int version) {
@@ -12,6 +12,24 @@ import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("入库审核SQL契约") @DisplayName("入库审核SQL契约")
class InboundWorkflowSqlContractTest { 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 @Test
@DisplayName("基础表和增量迁移包含审核历史、幂等、Outbox和放行索引") @DisplayName("基础表和增量迁移包含审核历史、幂等、Outbox和放行索引")
void shouldProvideWorkflowPersistenceBoundaries() throws Exception { void shouldProvideWorkflowPersistenceBoundaries() throws Exception {
@@ -0,0 +1,40 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.mapper.InboundHoldGateMapper;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class InboundHoldGuardTest {
private final InboundHoldGateMapper mapper = mock(InboundHoldGateMapper.class);
private final InboundHoldGuard guard = new InboundHoldGuard(mapper);
@Test
void blocksInboundPlanTaskAndContainerScopes() {
when(mapper.countHeldInbound(7L, 11L)).thenReturn(1);
when(mapper.countHeldByPlan(22L)).thenReturn(1);
when(mapper.countHeldByTask(33L)).thenReturn(1);
when(mapper.countHeldByContainer(44L)).thenReturn(1);
for (Runnable action : new Runnable[] {
() -> guard.requireInboundNotHeld(7L, 11L),
() -> guard.requirePlanNotHeld(22L),
() -> guard.requireTaskNotHeld(33L),
() -> guard.requireContainerNotHeld(44L)
}) {
assertThatThrownBy(action::run).isInstanceOfSatisfying(BizException.class,
error -> assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT));
}
}
@Test
void treatsMissingPhysicalWorkProjectionAsFailClosed() {
when(mapper.hasPhysicalWork(7L, 11L)).thenReturn(null);
assertThat(guard.hasPhysicalWork(7L, 11L)).isTrue();
}
}
@@ -0,0 +1,135 @@
package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.InboundHoldDtos.CommandRequest;
import com.baiyue.firstleg.entity.InboundHoldRecordEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.mapper.InboundHoldRecordMapper;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class InboundHoldServiceTest {
@Mock InboundOrderMapper inbounds;
@Mock InboundHoldRecordMapper history;
@Mock InboundIdempotencyService idempotency;
@Mock InboundHoldGuard guard;
@InjectMocks InboundHoldService service;
@Test
void holdsOwnedInboundWithOptimisticVersionAndImmutableHistory() {
InboundOrderEntity before = inbound(false, null, 3);
InboundOrderEntity after = inbound(true, "海关抽查", 4);
when(idempotency.begin(7L, 9L, 11L, "INBOUND_HOLD", "hold-key",
"HOLD|7|11|3|海关抽查|88")).thenReturn(new InboundIdempotencyService.Context(7L, 101L, false));
when(inbounds.selectActiveByIdForUpdate(11L)).thenReturn(before);
when(inbounds.updateManualHold(7L, 11L, 3, false, true, "海关抽查", 9L)).thenReturn(1);
when(inbounds.selectById(11L)).thenReturn(after);
when(history.selectList(any(Wrapper.class))).thenReturn(List.of());
var result = service.hold(7L, 11L, 9L, "平台人员", "hold-key",
new CommandRequest(3, " 海关抽查 ", 88L));
assertThat(result.holdFlag()).isTrue();
assertThat(result.currentHoldReason()).isEqualTo("海关抽查");
assertThat(result.version()).isEqualTo(4);
assertThat(result.canRelease()).isTrue();
ArgumentCaptor<InboundHoldRecordEntity> record = ArgumentCaptor.forClass(InboundHoldRecordEntity.class);
verify(history).insert(record.capture());
assertThat(record.getValue().getHoldAction()).isEqualTo("HOLD");
assertThat(record.getValue().getReason()).isEqualTo("海关抽查");
assertThat(record.getValue().getEvidenceFileId()).isEqualTo(88L);
verify(idempotency).complete(new InboundIdempotencyService.Context(7L, 101L, false), 11L);
}
@Test
void replaysSameCompletedCommandWithoutWritingAgain() {
when(idempotency.begin(anyLong(), anyLong(), anyLong(), eq("INBOUND_HOLD"), anyString(), anyString()))
.thenReturn(new InboundIdempotencyService.Context(7L, 101L, true));
when(inbounds.selectById(11L)).thenReturn(inbound(true, "检查", 4));
when(history.selectList(any(Wrapper.class))).thenReturn(List.of());
assertThat(service.hold(7L, 11L, 9L, "平台人员", "hold-key",
new CommandRequest(3, "检查", null)).holdFlag()).isTrue();
verify(inbounds, never()).updateManualHold(anyLong(), anyLong(), anyInt(), anyBoolean(),
anyBoolean(), any(), anyLong());
verify(history, never()).insert(any(InboundHoldRecordEntity.class));
}
@Test
void rejectsCrossCustomerInboundAsNotFound() {
when(idempotency.begin(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()))
.thenReturn(new InboundIdempotencyService.Context(7L, 101L, false));
InboundOrderEntity other = inbound(false, null, 3);
other.setTenantId(8L);
when(inbounds.selectActiveByIdForUpdate(11L)).thenReturn(other);
assertThatThrownBy(() -> service.hold(7L, 11L, 9L, "平台人员", "hold-key",
new CommandRequest(3, "检查", null)))
.isInstanceOfSatisfying(BizException.class,
error -> assertThat(error.getResultCode()).isEqualTo(ResultCode.NOT_FOUND));
}
@Test
void rejectsManualActionAfterPhysicalPicking() {
when(idempotency.begin(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()))
.thenReturn(new InboundIdempotencyService.Context(7L, 101L, false));
when(inbounds.selectActiveByIdForUpdate(11L)).thenReturn(inbound(false, null, 3));
when(guard.hasPhysicalWork(7L, 11L)).thenReturn(true);
assertThatThrownBy(() -> service.hold(7L, 11L, 9L, "平台人员", "hold-key",
new CommandRequest(3, "检查", null)))
.isInstanceOfSatisfying(BizException.class,
error -> assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT));
verify(inbounds, never()).updateManualHold(anyLong(), anyLong(), anyInt(), anyBoolean(),
anyBoolean(), any(), anyLong());
}
@Test
void releasesAndClearsCurrentReasonButKeepsReleaseHistoryReason() {
when(idempotency.begin(anyLong(), anyLong(), anyLong(), eq("INBOUND_RELEASE"), anyString(), anyString()))
.thenReturn(new InboundIdempotencyService.Context(7L, 102L, false));
when(inbounds.selectActiveByIdForUpdate(11L)).thenReturn(inbound(true, "旧原因", 4));
when(inbounds.updateManualHold(7L, 11L, 4, true, false, null, 9L)).thenReturn(1);
when(inbounds.selectById(11L)).thenReturn(inbound(false, null, 5));
when(history.selectList(any(Wrapper.class))).thenReturn(List.of());
var result = service.release(7L, 11L, 9L, "平台人员", "release-key",
new CommandRequest(4, "复核通过", null));
assertThat(result.holdFlag()).isFalse();
assertThat(result.currentHoldReason()).isNull();
ArgumentCaptor<InboundHoldRecordEntity> record = ArgumentCaptor.forClass(InboundHoldRecordEntity.class);
verify(history).insert(record.capture());
assertThat(record.getValue().getHoldAction()).isEqualTo("RELEASE");
assertThat(record.getValue().getReason()).isEqualTo("复核通过");
}
private static InboundOrderEntity inbound(boolean held, String reason, int version) {
InboundOrderEntity value = new InboundOrderEntity();
value.setId(11L);
value.setTenantId(7L);
value.setHoldFlag(held);
value.setCurrentHoldReason(reason);
value.setVersion(version);
value.setDeleted(false);
value.setProcessStatus("WAIT_SPLIT");
return value;
}
}
@@ -89,7 +89,7 @@ class InboundOrderCreationServiceTest {
base.containerMode(), base.customsInspection(), base.destinationType(), base.destinationWarehouseId(), base.containerMode(), base.customsInspection(), base.destinationType(), base.destinationWarehouseId(),
base.destinationCode(), base.destinationName(), base.publicRemark(), base.expressCompanyName(), base.destinationCode(), base.destinationName(), base.publicRemark(), base.expressCompanyName(),
base.expressTrackingNo(), base.totalPackageCount(), base.pickupMethod(), base.requestedPickupAt(), 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(orders.selectOne(any(Wrapper.class))).thenReturn(null);
when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L)); when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L));
@@ -62,6 +62,8 @@ class LoadingListServiceTest {
private LoadingDocumentFileClient documentFiles; private LoadingDocumentFileClient documentFiles;
@MockBean @MockBean
private LoadingCompletedOutboxService loadingCompletedOutboxService; private LoadingCompletedOutboxService loadingCompletedOutboxService;
@MockBean
private InboundHoldGuard inboundHoldGuard;
@Resource @Resource
private LoadingListService service; private LoadingListService service;
@@ -101,6 +101,8 @@ class LoadingPickServiceTest {
private PdaPickSettlementService settlementService; private PdaPickSettlementService settlementService;
@MockBean @MockBean
private InboundPalletAllocationRepository palletAllocations; private InboundPalletAllocationRepository palletAllocations;
@MockBean
private InboundHoldGuard inboundHoldGuard;
@Resource @Resource
private LoadingPickService service; private LoadingPickService service;
@@ -26,6 +26,7 @@ import static org.mockito.Mockito.when;
class LoadingWorkPalletServiceTest { class LoadingWorkPalletServiceTest {
@MockBean private LoadingWorkPalletMapper pallets; @MockBean private LoadingWorkPalletMapper pallets;
@MockBean private LoadingWorkPalletCartonMapper cartons; @MockBean private LoadingWorkPalletCartonMapper cartons;
@MockBean private InboundHoldGuard inboundHoldGuard;
@Resource private LoadingWorkPalletService service; @Resource private LoadingWorkPalletService service;
@Test @Test
@@ -42,6 +42,7 @@ class SmartLoadingProvisionalAdjustmentTest {
@Mock private PendingReviewOrderClient pendingReviewOrders; @Mock private PendingReviewOrderClient pendingReviewOrders;
@Mock private InboundOrderCreationService inboundOrderCreationService; @Mock private InboundOrderCreationService inboundOrderCreationService;
@Mock private LoadingPickService loadingPickService; @Mock private LoadingPickService loadingPickService;
@Mock private InboundHoldGuard inboundHoldGuard;
@Mock private LoadingModelBridgeService loadingModelBridge; @Mock private LoadingModelBridgeService loadingModelBridge;
@Mock private SplitPreloadService splitPreloadService; @Mock private SplitPreloadService splitPreloadService;
@Mock private ColoadGroupAssignmentPort coloadGroups; @Mock private ColoadGroupAssignmentPort coloadGroups;
@@ -89,6 +89,7 @@ class SmartLoadingServiceTest {
@MockBean private PendingReviewOrderClient pendingReviewOrders; @MockBean private PendingReviewOrderClient pendingReviewOrders;
@MockBean private InboundOrderCreationService inboundOrderCreationService; @MockBean private InboundOrderCreationService inboundOrderCreationService;
@MockBean private LoadingPickService loadingPickService; @MockBean private LoadingPickService loadingPickService;
@MockBean private InboundHoldGuard inboundHoldGuard;
@MockBean private LoadingModelBridgeService loadingModelBridge; @MockBean private LoadingModelBridgeService loadingModelBridge;
@MockBean private SplitPreloadService splitPreloadService; @MockBean private SplitPreloadService splitPreloadService;
@MockBean private ColoadGroupAssignmentPort coloadGroups; @MockBean private ColoadGroupAssignmentPort coloadGroups;
@@ -0,0 +1,85 @@
-- 平台入库单扣货独立写权限;查看入库单不会自动获得扣货能力。
USE baiyue_iam;
SET NAMES utf8mb4;
DROP PROCEDURE IF EXISTS migrate_inbound_hold_permission;
DELIMITER $$
CREATE PROCEDURE migrate_inbound_hold_permission()
BEGIN
IF EXISTS (
SELECT 1
FROM iam_permission
WHERE (id = 5102032 AND (tenant_id <> 0
OR app_code <> 'PLATFORM'
OR permission_type <> 'BUTTON'
OR permission_code <> 'platform:firstleg:inbound:hold'))
OR (permission_code = 'platform:firstleg:inbound:hold'
AND (id <> 5102032 OR tenant_id <> 0 OR app_code <> 'PLATFORM'))
) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='入库单扣货权限初始化失败:权限ID、编码或类型冲突';
END IF;
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code,
permission_name, route_path, http_method, sort_no, status, built_in, deleted)
VALUES
(5102032, 0, 'PLATFORM', 'BUTTON', 5102030, 'platform:firstleg:inbound:hold',
'扣货与解除扣货', NULL, 'POST', 20, 'ACTIVE', 1, 0)
ON DUPLICATE KEY UPDATE
permission_name=VALUES(permission_name), parent_id=VALUES(parent_id),
route_path=VALUES(route_path), http_method=VALUES(http_method),
sort_no=VALUES(sort_no), status='ACTIVE', built_in=1, deleted=0;
INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at)
SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3)
FROM iam_role role
JOIN iam_permission permission
ON permission.tenant_id=0
AND permission.app_code='PLATFORM'
AND permission.permission_code='platform:firstleg:inbound:hold'
WHERE role.tenant_id=0
AND role.app_code='PLATFORM'
AND role.role_code='PLATFORM_ADMIN'
AND role.status='ACTIVE'
AND role.deleted=0
AND permission.status='ACTIVE'
AND permission.deleted=0;
IF (SELECT COUNT(*)
FROM iam_permission
WHERE id=5102032
AND tenant_id=0
AND app_code='PLATFORM'
AND permission_type='BUTTON'
AND parent_id=5102030
AND permission_code='platform:firstleg:inbound:hold'
AND http_method='POST'
AND status='ACTIVE'
AND built_in=1
AND deleted=0) <> 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='入库单扣货权限初始化失败:权限资源不完整';
END IF;
IF (SELECT COUNT(*)
FROM iam_role_permission grant_row
JOIN iam_role role
ON role.id=grant_row.role_id
AND role.tenant_id=grant_row.tenant_id
JOIN iam_permission permission
ON permission.id=grant_row.permission_id
AND permission.tenant_id=grant_row.tenant_id
WHERE role.tenant_id=0
AND role.app_code='PLATFORM'
AND role.role_code='PLATFORM_ADMIN'
AND role.status='ACTIVE'
AND role.deleted=0
AND permission.permission_code='platform:firstleg:inbound:hold'
AND permission.status='ACTIVE'
AND permission.deleted=0) <> 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='入库单扣货权限初始化失败:平台超级管理员授权不完整';
END IF;
END$$
DELIMITER ;
CALL migrate_inbound_hold_permission();
DROP PROCEDURE IF EXISTS migrate_inbound_hold_permission;
+1 -1
View File
@@ -124,7 +124,7 @@ paths:
get: get:
tags: [IAM Resources / IAM 资源] tags: [IAM Resources / IAM 资源]
summary: 查询当前用户有效权限 summary: 查询当前用户有效权限
description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。资金账户充值审核与手工调账分别返回 `platform:finance:recharge:review`、`platform:finance:customer-account:adjust`,并与 `platform:finance:customer-account:list` 只读权限分离。海外仓返回 `platform:overseas-warehouse:dispatch:list`、`platform:overseas-warehouse:dispatch:edit`、`platform:overseas-warehouse:dispatch:cancel`、`platform:overseas-warehouse:dispatch:transfer-delivery`、`platform:overseas-warehouse:outbound:list`、`platform:overseas-warehouse:outbound:create`、`platform:overseas-warehouse:outbound:cancel`、`platform:overseas-warehouse:delivery:list`、`platform:overseas-warehouse:delivery:return`、`platform:overseas-warehouse:delivery:status`、`platform:overseas-warehouse:exception-order:list`、`platform:overseas-warehouse:exception-order:create`、`platform:overseas-warehouse:customer-visit:list`、`platform:overseas-warehouse:customer-visit:edit`;海外 PDA 盘点、移库、换标和现场查询统一返回 `lastleg:pda:operate`,且仅限 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`。列表权限包含详情和只读辅助接口,写操作独立授权。 description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。入库单扣货与解除扣货返回独立写权限 `platform:firstleg:inbound:hold`,不会由 `platform:firstleg:inbound:list` 查看权限自动获得。资金账户充值审核与手工调账分别返回 `platform:finance:recharge:review`、`platform:finance:customer-account:adjust`,并与 `platform:finance:customer-account:list` 只读权限分离。海外仓返回 `platform:overseas-warehouse:dispatch:list`、`platform:overseas-warehouse:dispatch:edit`、`platform:overseas-warehouse:dispatch:cancel`、`platform:overseas-warehouse:dispatch:transfer-delivery`、`platform:overseas-warehouse:outbound:list`、`platform:overseas-warehouse:outbound:create`、`platform:overseas-warehouse:outbound:cancel`、`platform:overseas-warehouse:delivery:list`、`platform:overseas-warehouse:delivery:return`、`platform:overseas-warehouse:delivery:status`、`platform:overseas-warehouse:exception-order:list`、`platform:overseas-warehouse:exception-order:create`、`platform:overseas-warehouse:customer-visit:list`、`platform:overseas-warehouse:customer-visit:edit`;海外 PDA 盘点、移库、换标和现场查询统一返回 `lastleg:pda:operate`,且仅限 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`。列表权限包含详情和只读辅助接口,写操作独立授权。
responses: responses:
'200': '200':
description: 当前平台成员权限查询成功 description: 当前平台成员权限查询成功
@@ -46,6 +46,8 @@ class IamOpenApiContractTest {
.contains("merchant:finance:recharge:create") .contains("merchant:finance:recharge:create")
.contains("platform:finance:recharge:review") .contains("platform:finance:recharge:review")
.contains("platform:finance:customer-account:adjust") .contains("platform:finance:customer-account:adjust")
.contains("platform:firstleg:inbound:hold")
.contains("不会由 `platform:firstleg:inbound:list` 查看权限自动获得")
.contains("两者独立授权") .contains("两者独立授权")
.contains("列表权限包含详情只读") .contains("列表权限包含详情只读")
.contains("PermissionCodesApiResponse:") .contains("PermissionCodesApiResponse:")
@@ -0,0 +1,67 @@
package com.baiyue.iam.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class InboundHoldPermissionSqlContractTest {
@Test
void registersHoldAsASeparateWritePermissionUnderInboundList() throws Exception {
String sql = sql();
assertThat(sql).contains(
"(5102032, 0, 'PLATFORM', 'BUTTON', 5102030, 'platform:firstleg:inbound:hold'",
"'扣货与解除扣货', NULL, 'POST'",
"permission_type='BUTTON'",
"parent_id=5102030",
"http_method='POST'");
assertThat(sql).doesNotContain(
"permission_code IN ('platform:firstleg:inbound:list', 'platform:firstleg:inbound:hold')",
"permission_code IN ('platform:firstleg:inbound:view', 'platform:firstleg:inbound:hold')");
}
@Test
void failsClosedWhenTheFixedIdentityIsOccupied() throws Exception {
assertThat(sql()).contains(
"migrate_inbound_hold_permission",
"id = 5102032",
"permission_type <> 'BUTTON'",
"permission_code = 'platform:firstleg:inbound:hold'",
"SIGNAL SQLSTATE '45000'",
"权限ID、编码或类型冲突");
}
@Test
void grantsHoldOnlyToPlatformAdminByDefault() throws Exception {
String sql = sql();
int grantStart = sql.indexOf("INSERT IGNORE INTO iam_role_permission");
int grantEnd = sql.indexOf(';', grantStart);
String grant = sql.substring(grantStart, grantEnd);
assertThat(grant).contains(
"role.app_code='PLATFORM'",
"role.role_code='PLATFORM_ADMIN'",
"permission.permission_code='platform:firstleg:inbound:hold'");
assertThat(grant).doesNotContain(
"ORDER_OPERATOR",
"WAREHOUSE_OPERATOR",
"WAREHOUSE_TALLY",
"PLATFORM_CUSTOM_ADMIN");
}
@Test
void assertsTheResourceAndDefaultGrant() throws Exception {
assertThat(sql()).contains(
"入库单扣货权限初始化失败:权限资源不完整",
"入库单扣货权限初始化失败:平台超级管理员授权不完整",
") <> 1 THEN");
}
private static String sql() throws Exception {
return new ClassPathResource("db/inbound-hold-permission.sql")
.getContentAsString(StandardCharsets.UTF_8);
}
}
@@ -139,6 +139,22 @@ class AuthorizationSnapshotServiceTest {
"platform:finance:customer-account:adjust"); "platform:finance:customer-account:adjust");
} }
@Test
void exposesInboundHoldIndependentlyFromReadPermissions() {
when(mapper.countActiveIdentity(51L, 0L, 19L)).thenReturn(1);
when(mapper.findRoles(51L, 0L, 19L, "PLATFORM")).thenReturn(List.of("PLATFORM_ADMIN"));
when(mapper.findPermissions(51L, 0L, 19L, "PLATFORM")).thenReturn(List.of(
"platform:firstleg:inbound:list",
"platform:firstleg:inbound:hold"));
var permissions = service.currentPermissions(new AuthenticatedUser(51L, 0L, 0L, 19L,
"platform-admin", "PLATFORM", "PLATFORM", java.util.Set.of()), "PLATFORM");
assertThat(permissions).containsExactlyInAnyOrder(
"platform:firstleg:inbound:list",
"platform:firstleg:inbound:hold");
}
@Test @Test
void rejectsIncompleteCurrentIdentityAsBusinessError() { void rejectsIncompleteCurrentIdentityAsBusinessError() {
assertThatThrownBy(() -> service assertThatThrownBy(() -> service
@@ -50,6 +50,7 @@ public interface FirstlegUnifiedOrderClient {
LocalDateTime expectedArrivalAt, LocalDateTime requestedPickupAt, String feeReviewStatus, LocalDateTime expectedArrivalAt, LocalDateTime requestedPickupAt, String feeReviewStatus,
String companyName, String businessType, String taxMode, String containerMode, String companyName, String businessType, String taxMode, String containerMode,
Boolean customsInspection, String destinationType, String destinationCode, Boolean customsInspection, String destinationType, String destinationCode,
Boolean holdFlag, String currentHoldReason,
BigDecimal actualGrossWeightKg, BigDecimal actualNetWeightKg, BigDecimal actualVolumeCbm, BigDecimal actualGrossWeightKg, BigDecimal actualNetWeightKg, BigDecimal actualVolumeCbm,
BigDecimal volumeWeightKg, BigDecimal chargeableVolumeCbm, Integer palletCount, BigDecimal volumeWeightKg, BigDecimal chargeableVolumeCbm, Integer palletCount,
BigDecimal chargeableWeightKg, LocalDateTime measureCompletedAt, BigDecimal chargeableWeightKg, LocalDateTime measureCompletedAt,
@@ -19,6 +19,7 @@ public final class UnifiedOrderResponse {
Long receivingWarehouseId, String receivingWarehouseName, Long receivingWarehouseId, String receivingWarehouseName,
Long destinationWarehouseId, String destinationWarehouseName, Long destinationWarehouseId, String destinationWarehouseName,
Long inboundId, Integer inboundVersion, String inboundCode, LocalDate expectedShipDate, Long inboundId, Integer inboundVersion, String inboundCode, LocalDate expectedShipDate,
Boolean holdFlag, String currentHoldReason,
Integer totalPackageCount, String recommendedPalletType, String locationCodes, Integer totalPackageCount, String recommendedPalletType, String locationCodes,
BigDecimal estimatedVolumeCbm, BigDecimal expectedVolumeCbm, BigDecimal estimatedVolumeCbm, BigDecimal expectedVolumeCbm,
BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm, BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm,
@@ -287,7 +287,7 @@ public class UnifiedOrderQueryService {
order.getChannelCode(), order.getChannelName(), Boolean.TRUE.equals(order.getChannelChanged()), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(), order.getChannelCode(), order.getChannelName(), Boolean.TRUE.equals(order.getChannelChanged()), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(),
order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null, order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null,
order.getInboundCode(), order.getInboundCode(),
order.getExpectedShipDate(), totalPackageCount(cargo), null, null, order.getExpectedShipDate(), null, null, totalPackageCount(cargo), null, null,
order.getEstimatedVolumeCbm(), volumeCbm(cargo), null, null, order.getEstimatedVolumeCbm(), volumeCbm(cargo), null, null,
order.getExpectedArrivalAt(), null, null, null, order.getPickupMethod(), order.getTaxMode(), order.getContainerMode(), order.getExpectedArrivalAt(), null, null, null, order.getPickupMethod(), order.getTaxMode(), order.getContainerMode(),
order.getCustomsInspection(), order.getDestinationType(), order.getAmazonWarehouseCode(), order.getCustomsInspection(), order.getDestinationType(), order.getAmazonWarehouseCode(),
@@ -306,7 +306,8 @@ public class UnifiedOrderQueryService {
order.getCustomerId(), order.getCustomerName(), order.getCustomerId(), order.getCustomerName(),
order.getChannelCode(), order.getChannelName(), Boolean.TRUE.equals(order.getChannelChanged()), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(), order.getChannelCode(), order.getChannelName(), Boolean.TRUE.equals(order.getChannelChanged()), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(),
order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), inbound.id(), inbound.version(), inbound.inboundCode(), order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), inbound.id(), inbound.version(), inbound.inboundCode(),
order.getExpectedShipDate(), displayedPackageCount(order, cargo, inbound), null, order.getExpectedShipDate(), inbound.holdFlag(), inbound.currentHoldReason(),
displayedPackageCount(order, cargo, inbound), null,
distinctLocationCodes(inbound.locationCodes()), distinctLocationCodes(inbound.locationCodes()),
order.getEstimatedVolumeCbm(), volumeCbm(cargo), order.getEstimatedVolumeCbm(), volumeCbm(cargo),
inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(),
@@ -333,7 +334,7 @@ public class UnifiedOrderQueryService {
inbound.customerId(), inbound.customerName(), inbound.channelCode(), inbound.channelName(), inbound.customerId(), inbound.customerName(), inbound.channelCode(), inbound.channelName(),
order != null && Boolean.TRUE.equals(order.getChannelChanged()), inbound.warehouseId(), order != null && Boolean.TRUE.equals(order.getChannelChanged()), inbound.warehouseId(),
inbound.warehouseName(), inbound.deliveryWarehouseId(), inbound.deliveryWarehouseName(), inbound.warehouseName(), inbound.deliveryWarehouseId(), inbound.deliveryWarehouseName(),
inbound.id(), inbound.version(), inbound.inboundCode(), null, inbound.id(), inbound.version(), inbound.inboundCode(), null, inbound.holdFlag(), inbound.currentHoldReason(),
displayedPackageCount(order, cargo, inbound), null, distinctLocationCodes(inbound.locationCodes()), displayedPackageCount(order, cargo, inbound), null, distinctLocationCodes(inbound.locationCodes()),
order == null ? null : order.getEstimatedVolumeCbm(), volumeCbm(cargo), order == null ? null : order.getEstimatedVolumeCbm(), volumeCbm(cargo),
inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(),
@@ -753,6 +753,8 @@ components:
inboundVersion: {type: integer, nullable: true, description: C04 loading/complete 的 expectedVersion} inboundVersion: {type: integer, nullable: true, description: C04 loading/complete 的 expectedVersion}
inboundCode: {type: string, nullable: true, description: 当前订单片段的权威入仓码;缺失时为 null,不得回退为 orderId 或 orderNo} inboundCode: {type: string, nullable: true, description: 当前订单片段的权威入仓码;缺失时为 null,不得回退为 orderId 或 orderNo}
expectedShipDate: {type: string, format: date, nullable: true} expectedShipDate: {type: string, format: date, nullable: true}
holdFlag: {type: boolean, nullable: true, description: Firstleg 入库单当前扣货标识;尚未生成入库单时为 null}
currentHoldReason: {type: string, nullable: true, description: Firstleg 入库单当前扣货原因;未扣货或尚未生成入库单时为 null}
totalPackageCount: {type: integer, nullable: true, description: 无入仓事实时为当前订单有效货物箱数合计;存在入仓业务单元后为该单元履约总箱数,拆分后不以父单原始箱数覆盖} totalPackageCount: {type: integer, nullable: true, description: 无入仓事实时为当前订单有效货物箱数合计;存在入仓业务单元后为该单元履约总箱数,拆分后不以父单原始箱数覆盖}
recommendedPalletType: {type: string, nullable: true, description: 当前无列表级权威事实时返回 null,不得返回固定托盘类型} recommendedPalletType: {type: string, nullable: true, description: 当前无列表级权威事实时返回 null,不得返回固定托盘类型}
locationCodes: {type: string, nullable: true, description: "库位编码汇总;按首次出现顺序去重并使用中文顿号分隔"} locationCodes: {type: string, nullable: true, description: "库位编码汇总;按首次出现顺序去重并使用中文顿号分隔"}
@@ -54,6 +54,7 @@ class UnifiedOrderApiContractTest {
assertThat(stringMap(row.get("properties"))).containsKeys( assertThat(stringMap(row.get("properties"))).containsKeys(
"orderId", "mainStatus", "channelCode", "inboundId", "inboundVersion", "measurement", "preload", "orderId", "mainStatus", "channelCode", "inboundId", "inboundVersion", "measurement", "preload",
"loading", "delivery", "transport", "customerRemark", "latestInternalNote", "loading", "delivery", "transport", "customerRemark", "latestInternalNote",
"holdFlag", "currentHoldReason",
"expectedArrivalAt", "requestedPickupAt", "feeReviewStatus", "companyName", "businessType", "pickupMethod", "expectedArrivalAt", "requestedPickupAt", "feeReviewStatus", "companyName", "businessType", "pickupMethod",
"taxMode", "containerMode", "customsInspection", "destinationType", "destinationCode", "taxMode", "containerMode", "customsInspection", "destinationType", "destinationCode",
"totalPalletCount", "chargeableWeightKg", "chargeableVolumeCbm", "volumetricWeightKg", "totalNetWeightKg", "totalPalletCount", "chargeableWeightKg", "chargeableVolumeCbm", "volumetricWeightKg", "totalNetWeightKg",
@@ -64,6 +65,8 @@ class UnifiedOrderApiContractTest {
assertThat(inboundCode.get("description").toString()).contains("不得回退"); assertThat(inboundCode.get("description").toString()).contains("不得回退");
Map<String, Object> channelCode = stringMap(stringMap(row.get("properties")).get("channelCode")); Map<String, Object> channelCode = stringMap(stringMap(row.get("properties")).get("channelCode"));
assertThat(channelCode.get("description").toString()).contains("不由 channelName 推导"); assertThat(channelCode.get("description").toString()).contains("不由 channelName 推导");
assertThat(stringMap(row.get("properties")))
.doesNotContainKeys("holdCargo", "holdReason");
Map<String, Object> coLoadGroup = stringMap(schemas.get("UnifiedCoLoadGroupSummary")); Map<String, Object> coLoadGroup = stringMap(schemas.get("UnifiedCoLoadGroupSummary"));
assertThat(stringMap(coLoadGroup.get("properties"))) assertThat(stringMap(coLoadGroup.get("properties")))
.containsOnlyKeys("status", "memberCount", "memberInboundCodes"); .containsOnlyKeys("status", "memberCount", "memberInboundCodes");
@@ -82,7 +85,9 @@ class UnifiedOrderApiContractTest {
"page", String.class, FirstlegUnifiedOrderClient.Query.class); "page", String.class, FirstlegUnifiedOrderClient.Query.class);
assertThat(page.getAnnotation(PostMapping.class).value()).containsExactly("/query"); assertThat(page.getAnnotation(PostMapping.class).value()).containsExactly("/query");
assertThat(List.of(FirstlegUnifiedOrderClient.Inbound.class.getRecordComponents()).stream() assertThat(List.of(FirstlegUnifiedOrderClient.Inbound.class.getRecordComponents()).stream()
.map(component -> component.getName())).contains("channelCode", "channelName"); .map(component -> component.getName()))
.contains("channelCode", "channelName", "holdFlag", "currentHoldReason")
.doesNotContain("holdCargo", "holdReason");
} }
@Test @Test
@@ -74,6 +74,8 @@ class UnifiedOrderQueryServiceTest {
assertThat(row.inboundCode()).isEqualTo("RK-77"); assertThat(row.inboundCode()).isEqualTo("RK-77");
assertThat(row.channelCode()).isEqualTo("MATSON"); assertThat(row.channelCode()).isEqualTo("MATSON");
assertThat(row.inboundId()).isNull(); assertThat(row.inboundId()).isNull();
assertThat(row.holdFlag()).isNull();
assertThat(row.currentHoldReason()).isNull();
assertThat(row.customerRemark()).isEqualTo("客户要求提货前联系"); assertThat(row.customerRemark()).isEqualTo("客户要求提货前联系");
assertThat(row.latestInternalNote()).isEqualTo("客服已确认提货时间"); assertThat(row.latestInternalNote()).isEqualTo("客服已确认提货时间");
assertThat(row.relation()).isNull(); assertThat(row.relation()).isNull();
@@ -98,6 +100,8 @@ class UnifiedOrderQueryServiceTest {
assertThat(row.nextAction()).isEqualTo("APPROVE_ORDER"); assertThat(row.nextAction()).isEqualTo("APPROVE_ORDER");
assertThat(row.inboundId()).isEqualTo(88L); assertThat(row.inboundId()).isEqualTo(88L);
assertThat(row.internalOrderStatus()).isEqualTo(OrderStatus.SUBMITTED); assertThat(row.internalOrderStatus()).isEqualTo(OrderStatus.SUBMITTED);
assertThat(row.holdFlag()).isTrue();
assertThat(row.currentHoldReason()).isEqualTo("等待客户补充资料");
}); });
} }
@@ -592,6 +596,8 @@ class UnifiedOrderQueryServiceTest {
assertThat(result.summary().mainStatus()).isEqualTo("LOADED"); assertThat(result.summary().mainStatus()).isEqualTo("LOADED");
assertThat(result.summary().internalOrderStatus()).isEqualTo(OrderStatus.APPROVED); assertThat(result.summary().internalOrderStatus()).isEqualTo(OrderStatus.APPROVED);
assertThat(result.summary().inboundId()).isEqualTo(88L); assertThat(result.summary().inboundId()).isEqualTo(88L);
assertThat(result.summary().holdFlag()).isTrue();
assertThat(result.summary().currentHoldReason()).isEqualTo("等待客户补充资料");
ArgumentCaptor<FirstlegUnifiedOrderClient.DetailRequest> captor = ArgumentCaptor<FirstlegUnifiedOrderClient.DetailRequest> captor =
ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.DetailRequest.class); ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.DetailRequest.class);
verify(firstleg).detail(eq("internal-token"), captor.capture()); verify(firstleg).detail(eq("internal-token"), captor.capture());
@@ -701,6 +707,8 @@ class UnifiedOrderQueryServiceTest {
when(inbound.deliveryWarehouseName()).thenReturn("ONT8"); when(inbound.deliveryWarehouseName()).thenReturn("ONT8");
when(inbound.channelName()).thenReturn("美森"); when(inbound.channelName()).thenReturn("美森");
when(inbound.channelCode()).thenReturn("MATSON"); when(inbound.channelCode()).thenReturn("MATSON");
when(inbound.holdFlag()).thenReturn(true);
when(inbound.currentHoldReason()).thenReturn("等待客户补充资料");
when(inbound.totalPackageCount()).thenReturn(10); when(inbound.totalPackageCount()).thenReturn(10);
when(inbound.locationCodes()).thenReturn("A0-01, B2-03"); when(inbound.locationCodes()).thenReturn("A0-01, B2-03");
when(inbound.companyName()).thenReturn("测试客户有限公司"); when(inbound.companyName()).thenReturn("测试客户有限公司");
+2 -1
View File
@@ -5,7 +5,7 @@ import { dirname, resolve } from 'node:path'
import { pathToFileURL } from 'node:url' import { pathToFileURL } from 'node:url'
import process from 'node:process' import process from 'node:process'
const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', 'customer-operating-statistics'] const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'inbound-hold', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', 'customer-operating-statistics']
const OPTIONAL_MODULES = ['manual-order-splitting-fixture', 'manual-order-splitting-verify', const OPTIONAL_MODULES = ['manual-order-splitting-fixture', 'manual-order-splitting-verify',
'manual-preload-push-fixture', 'manual-preload-push-verify', 'manual-preload-push-fixture', 'manual-preload-push-verify',
'manual-pda-ready-fixture', 'manual-pda-ready-verify', 'manual-pda-ready-fixture', 'manual-pda-ready-verify',
@@ -45,6 +45,7 @@ const MODULE_DEPENDENCIES = new Map([
['suppliers', ['merchant-qualification-fixture']], ['suppliers', ['merchant-qualification-fixture']],
['supplemental', ['merchant-qualification-fixture']], ['supplemental', ['merchant-qualification-fixture']],
['orders', ['merchant-qualification-fixture']], ['orders', ['merchant-qualification-fixture']],
['inbound-hold', ['orders']],
['inbound-measurement', ['orders']], ['inbound-measurement', ['orders']],
['manual-order-splitting-fixture', ['inbound-measurement']], ['manual-order-splitting-fixture', ['inbound-measurement']],
['coload-groups', ['inbound-measurement']], ['coload-groups', ['inbound-measurement']],
+1
View File
@@ -62,6 +62,7 @@ export async function run(context) {
| 公共主数据与往来单位 | `master-data.mjs` | `master-data-partner-contract.test.mjs` | | 公共主数据与往来单位 | `master-data.mjs` | `master-data-partner-contract.test.mjs` |
| 平台基础、客户、海外仓储费与下载 | `system.mjs`、`customers.mjs`、`overseas-storage-fees.mjs`、`downloads.mjs` | 由 Admin 总入口默认编排;分别覆盖系统能力、客户管理、海外仓阶梯费和下载中心基础能力 | | 平台基础、客户、海外仓储费与下载 | `system.mjs`、`customers.mjs`、`overseas-storage-fees.mjs`、`downloads.mjs` | 由 Admin 总入口默认编排;分别覆盖系统能力、客户管理、海外仓阶梯费和下载中心基础能力 |
| 订单创建、可计价主数据、幂等零漂移与审核异步收敛 | `orders.mjs` | `orders-pricing-fixture-contract.test.mjs`、`orders-lifecycle-idempotency-contract.test.mjs`、`orders-approval-convergence-contract.test.mjs` | | 订单创建、可计价主数据、幂等零漂移与审核异步收敛 | `orders.mjs` | `orders-pricing-fixture-contract.test.mjs`、`orders-lifecycle-idempotency-contract.test.mjs`、`orders-approval-convergence-contract.test.mjs` |
| 入库单手工扣货与解除 | `inbound-hold.mjs`(客户隔离、乐观锁、幂等重放/冲突、不可变历史、统一订单投影、智能装柜候选拦截与恢复) | `inbound-hold-contract.test.mjs`;默认全量已启用,定向入口自动展开已审核订单与入库单造数 |
| 费用项目、价格配置与基础 Finance | `fee-items.mjs`、`pricing.mjs`、`finance.mjs` | `fee-items-contract.test.mjs`、`pricing-contract.test.mjs`、`finance-contract.test.mjs` | | 费用项目、价格配置与基础 Finance | `fee-items.mjs`、`pricing.mjs`、`finance.mjs` | `fee-items-contract.test.mjs`、`pricing-contract.test.mjs`、`finance-contract.test.mjs` |
| 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` | | 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` |
| 入仓自动头程费用、派生详情、人工发布与 MANUAL 其他费用版本 | `inbound-fees.mjs`(前置:`merchant-qualification-fixture,orders,inbound-measurement`) | `inbound-fees-contract.test.mjs` | | 入仓自动头程费用、派生详情、人工发布与 MANUAL 其他费用版本 | `inbound-fees.mjs`(前置:`merchant-qualification-fixture,orders,inbound-measurement`) | `inbound-fees-contract.test.mjs` |
@@ -0,0 +1,28 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { test } from 'node:test'
const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
const source = readFileSync(new URL('./inbound-hold.mjs', import.meta.url), 'utf8')
test('入库扣货真实 HTTP 模块进入默认全量并依赖订单造数', () => {
assert.match(entry, /CONFIGURED_MODULES\s*=\s*\[[^\n]*['"]orders['"][^\n]*['"]inbound-hold['"]/)
assert.match(entry, /\[['"]inbound-hold['"],\s*\[['"]orders['"]\]\]/)
})
test('入库扣货模块覆盖隔离、幂等、冲突、候选门禁和统一订单投影', () => {
assert.match(source, /错误客户不能读取扣货快照/)
assert.match(source, /扣货请求同键同载荷重放/)
assert.match(source, /扣货同键异载荷返回A0409/)
assert.match(source, /扣货入库单从智能装柜候选排除/)
assert.match(source, /解除后入库单恢复智能装柜候选/)
assert.match(source, /summary\?\.holdFlag/)
assert.match(source, /summary\?\.currentHoldReason/)
assert.doesNotMatch(source, /\.holdCargo\b|\.holdReason\b|\bholdCargo\s*:|\bholdReason\s*:/)
})
test('入库扣货写操作统一使用请求头 Idempotency-Key 和 expectedVersion', () => {
assert.match(source, /context\.request\('POST', path, body, context\.token, \{ 'Idempotency-Key': key \}\)/)
assert.match(source, /expectedVersion: initialVersion/)
assert.match(source, /expectedVersion: heldVersion/)
})
@@ -0,0 +1,173 @@
const MODULE = 'inbound-hold'
const OPTIONS = { scenario: 'inbound-hold-lifecycle', deploymentBlocker: true }
const SMART = '/api/platform/v1/first-leg/smart-loading/inbound-candidates'
function id(value) { return value == null ? '' : String(value) }
function code(response) { return id(response?.body?.code) || 'missing' }
function succeeded(response) { return response?.http === 200 && code(response) === '200' }
function rows(page) { return Array.isArray(page?.records) ? page.records : [] }
function channelGroup(channelCode) {
const value = String(channelCode || '').toUpperCase()
if (value.startsWith('EXX_')) return 'EXX'
if (value.startsWith('MATSON_CLX_')) return 'MATSON_CLX'
if (value.startsWith('MATSON_MAX_')) return 'MATSON_MAX'
if (value.startsWith('HEDE_')) return 'HEDE'
if (value.startsWith('OA_LA_') || value === 'OA_PARCEL') return 'OA_LA'
if (value.startsWith('OA_CHICAGO_')) return 'OA_CHICAGO'
if (value.startsWith('OA_HOUSTON_')) return 'OA_HOUSTON'
if (value.startsWith('OA_SAVANNAH_')) return 'OA_SAVANNAH'
if (value === 'OA_NEW_YORK_TRUCK') return value
throw new Error(`unsupported smart-loading channel group: ${channelCode}`)
}
function evidence(response, extra = '') {
return `HTTP=${response?.http ?? 'missing'}, code=${code(response)}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}${extra ? `, ${extra}` : ''}`
}
function expectCode(context, label, response, expected) {
context.assert(MODULE, label, response, response?.http === 200 && code(response) === expected,
evidence(response), OPTIONS)
}
function write(context, path, body, key) {
return context.request('POST', path, body, context.token, { 'Idempotency-Key': key })
}
export async function run(context) {
const fixture = context.createdResources.orders || {}
const tenantId = id(fixture.tenantId)
const orderId = id(fixture.orderId)
const inboundId = id(fixture.inboundId)
const inboundCode = id(fixture.inboundCode)
context.assert(MODULE, '复用本轮订单入库单', null,
Boolean(tenantId && orderId && inboundId && inboundCode),
`tenantId=${tenantId || 'missing'}, orderId=${orderId || 'missing'}, inboundId=${inboundId || 'missing'}, inboundCode=${inboundCode || 'missing'}`,
OPTIONS)
if (!tenantId || !orderId || !inboundId || !inboundCode) throw new Error('orders fixture missing inbound hold identity')
const unifiedPath = `/api/platform/v1/orders/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(tenantId)}`
const initialUnifiedResponse = await context.request('GET', unifiedPath)
const initialUnified = context.apiSuccess(MODULE, '扣货前读取统一订单', initialUnifiedResponse,
evidence(initialUnifiedResponse), OPTIONS)
const mainStatus = initialUnified?.summary?.mainStatus
const channelCode = id(initialUnified?.summary?.channelCode)
const warehouseId = id(initialUnified?.summary?.receivingWarehouseId || fixture.receivingWarehouseId)
context.assert(MODULE, '统一订单提供候选筛选事实', initialUnifiedResponse,
Boolean(mainStatus && channelCode && warehouseId),
`mainStatus=${mainStatus || 'missing'}, channelCode=${channelCode || 'missing'}, warehouseId=${warehouseId || 'missing'}`,
OPTIONS)
if (!mainStatus || !channelCode || !warehouseId) throw new Error('unified order missing hold acceptance filters')
const holdPath = `/api/platform/inbounds/${encodeURIComponent(inboundId)}/hold?tenantId=${encodeURIComponent(tenantId)}`
const initialHoldResponse = await context.request('GET', holdPath)
const initialHold = context.apiSuccess(MODULE, '读取初始扣货快照', initialHoldResponse,
evidence(initialHoldResponse), OPTIONS)
context.assert(MODULE, '初始入库单允许扣货', initialHoldResponse,
initialHold?.holdFlag === false && initialHold?.canHold === true && initialHold?.canRelease === false
&& Array.isArray(initialHold?.history),
`holdFlag=${initialHold?.holdFlag}, canHold=${initialHold?.canHold}, canRelease=${initialHold?.canRelease}, history=${initialHold?.history?.length ?? 'missing'}`,
OPTIONS)
const initialVersion = Number(initialHold?.version)
if (!Number.isInteger(initialVersion)) throw new Error('initial inbound hold version missing')
const candidatePath = `${SMART}?channelGroupCodes=${encodeURIComponent(channelGroup(channelCode))}`
+ `&warehouseId=${encodeURIComponent(warehouseId)}&customerId=${encodeURIComponent(tenantId)}`
+ `&inboundCode=${encodeURIComponent(inboundCode)}&page=1&size=20`
const beforeCandidatesResponse = await context.request('GET', candidatePath)
const beforeCandidates = context.apiSuccess(MODULE, '扣货前查询智能装柜候选', beforeCandidatesResponse,
evidence(beforeCandidatesResponse), OPTIONS)
context.assert(MODULE, '扣货前入库单可进入智能装柜候选', beforeCandidatesResponse,
rows(beforeCandidates).some((row) => id(row?.inboundId) === inboundId),
`inboundId=${inboundId}, candidates=${rows(beforeCandidates).map((row) => id(row?.inboundId)).join(',') || 'empty'}`,
OPTIONS)
const foreignTenantId = (BigInt(tenantId) + 1n).toString()
const foreignResponse = await context.request('GET',
`/api/platform/inbounds/${encodeURIComponent(inboundId)}/hold?tenantId=${encodeURIComponent(foreignTenantId)}`)
expectCode(context, '错误客户不能读取扣货快照', foreignResponse, 'A0404')
const reason = `${context.marker} 财务复核扣货`.slice(0, 1000)
const holdKey = `${context.marker}-inbound-hold`.slice(0, 128)
const holdBody = { expectedVersion: initialVersion, reason }
const holdResponse = await write(context, holdPath, holdBody, holdKey)
const held = context.apiSuccess(MODULE, '手工扣货入库单', holdResponse, evidence(holdResponse), OPTIONS)
context.assert(MODULE, '扣货写入当前快照和不可变历史', holdResponse,
held?.holdFlag === true && held?.currentHoldReason === reason && held?.canHold === false
&& held?.canRelease === true && Number(held?.version) === initialVersion + 1
&& held?.history?.[0]?.action === 'HOLD' && held?.history?.[0]?.reason === reason,
`holdFlag=${held?.holdFlag}, version=${held?.version}, history=${held?.history?.length ?? 'missing'}`,
OPTIONS)
const heldVersion = Number(held?.version)
const heldHistorySize = held?.history?.length
const replayResponse = await write(context, holdPath, holdBody, holdKey)
const replayed = context.apiSuccess(MODULE, '扣货请求同键同载荷重放', replayResponse,
evidence(replayResponse), OPTIONS)
context.assert(MODULE, '扣货幂等重放不增加版本或历史', replayResponse,
Number(replayed?.version) === heldVersion && replayed?.history?.length === heldHistorySize,
`version=${replayed?.version}, history=${replayed?.history?.length ?? 'missing'}`, OPTIONS)
const conflictResponse = await write(context, holdPath,
{ ...holdBody, reason: `${reason}-冲突` }, holdKey)
expectCode(context, '扣货同键异载荷返回A0409', conflictResponse, 'A0409')
const heldUnifiedResponse = await context.request('GET', unifiedPath)
const heldUnified = context.apiSuccess(MODULE, '扣货后读取统一订单', heldUnifiedResponse,
evidence(heldUnifiedResponse), OPTIONS)
context.assert(MODULE, '统一订单同步展示扣货且主状态不变', heldUnifiedResponse,
heldUnified?.summary?.holdFlag === true && heldUnified?.summary?.currentHoldReason === reason
&& heldUnified?.summary?.mainStatus === mainStatus,
`holdFlag=${heldUnified?.summary?.holdFlag}, mainStatus=${heldUnified?.summary?.mainStatus}`,
OPTIONS)
const heldCandidatesResponse = await context.request('GET', candidatePath)
const heldCandidates = context.apiSuccess(MODULE, '扣货后查询智能装柜候选', heldCandidatesResponse,
evidence(heldCandidatesResponse), OPTIONS)
context.assert(MODULE, '扣货入库单从智能装柜候选排除', heldCandidatesResponse,
!rows(heldCandidates).some((row) => id(row?.inboundId) === inboundId),
`inboundId=${inboundId}, candidates=${rows(heldCandidates).map((row) => id(row?.inboundId)).join(',') || 'empty'}`,
OPTIONS)
const releasePath = `/api/platform/inbounds/${encodeURIComponent(inboundId)}/hold/release?tenantId=${encodeURIComponent(tenantId)}`
const releaseReason = `${context.marker} 财务复核完成`.slice(0, 1000)
const releaseKey = `${context.marker}-inbound-release`.slice(0, 128)
const releaseBody = { expectedVersion: heldVersion, reason: releaseReason }
const releaseResponse = await write(context, releasePath, releaseBody, releaseKey)
const released = context.apiSuccess(MODULE, '解除入库单扣货', releaseResponse,
evidence(releaseResponse), OPTIONS)
context.assert(MODULE, '解除写入当前快照和不可变历史', releaseResponse,
released?.holdFlag === false && released?.currentHoldReason == null && released?.canHold === true
&& released?.canRelease === false && Number(released?.version) === heldVersion + 1
&& released?.history?.[0]?.action === 'RELEASE' && released?.history?.[0]?.reason === releaseReason
&& released?.history?.some?.((item) => item.action === 'HOLD' && item.reason === reason),
`holdFlag=${released?.holdFlag}, version=${released?.version}, history=${released?.history?.length ?? 'missing'}`,
OPTIONS)
const releasedVersion = Number(released?.version)
const releasedHistorySize = released?.history?.length
const releaseReplayResponse = await write(context, releasePath, releaseBody, releaseKey)
const releaseReplay = context.apiSuccess(MODULE, '解除请求同键同载荷重放', releaseReplayResponse,
evidence(releaseReplayResponse), OPTIONS)
context.assert(MODULE, '解除幂等重放不增加版本或历史', releaseReplayResponse,
Number(releaseReplay?.version) === releasedVersion && releaseReplay?.history?.length === releasedHistorySize,
`version=${releaseReplay?.version}, history=${releaseReplay?.history?.length ?? 'missing'}`, OPTIONS)
const staleResponse = await write(context, holdPath,
{ expectedVersion: heldVersion, reason: `${context.marker} 过期版本` },
`${context.marker}-inbound-hold-stale`.slice(0, 128))
expectCode(context, '过期版本再次扣货返回A0409', staleResponse, 'A0409')
const finalUnifiedResponse = await context.request('GET', unifiedPath)
const finalUnified = context.apiSuccess(MODULE, '解除后读取统一订单', finalUnifiedResponse,
evidence(finalUnifiedResponse), OPTIONS)
context.assert(MODULE, '解除后统一订单恢复且主状态不变', finalUnifiedResponse,
finalUnified?.summary?.holdFlag === false && finalUnified?.summary?.currentHoldReason == null
&& finalUnified?.summary?.mainStatus === mainStatus,
`holdFlag=${finalUnified?.summary?.holdFlag}, mainStatus=${finalUnified?.summary?.mainStatus}`,
OPTIONS)
const finalCandidatesResponse = await context.request('GET', candidatePath)
const finalCandidates = context.apiSuccess(MODULE, '解除后查询智能装柜候选', finalCandidatesResponse,
evidence(finalCandidatesResponse), OPTIONS)
context.assert(MODULE, '解除后入库单恢复智能装柜候选', finalCandidatesResponse,
rows(finalCandidates).some((row) => id(row?.inboundId) === inboundId),
`inboundId=${inboundId}, candidates=${rows(finalCandidates).map((row) => id(row?.inboundId)).join(',') || 'empty'}`,
OPTIONS)
}
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const unified = readFileSync(new URL('../deploy/sql/头程数据库统一增量-MySQL8.sql', import.meta.url), 'utf8')
const firstleg = readFileSync(new URL('../firstleg/src/main/resources/db/phase2-inbound-schema.sql', import.meta.url), 'utf8')
const iam = readFileSync(new URL('../iam/src/main/resources/db/inbound-hold-permission.sql', import.meta.url), 'utf8')
test('统一增量与 Firstleg 扣货快照和不可变历史结构一致', () => {
for (const source of [unified, firstleg]) {
assert.match(source, /current_hold_reason VARCHAR\(1000\) NULL COMMENT '当前扣货理由快照'/)
assert.match(source, /CREATE TABLE IF NOT EXISTS fl_inbound_hold_record/)
assert.match(source, /hold_action VARCHAR\(16\) NOT NULL COMMENT '动作:HOLD或RELEASE'/)
assert.match(source, /reason VARCHAR\(1000\) NOT NULL/)
assert.match(source, /evidence_file_id BIGINT UNSIGNED NULL/)
assert.match(source, /KEY idx_hold_inbound_time \(tenant_id, inbound_id, operated_at\)/)
}
assert.match(unified, /MODIFY COLUMN current_hold_reason VARCHAR\(1000\) NULL/)
})
test('统一增量与 IAM 模块冻结同一独立扣货权限且只默认授予平台管理员', () => {
for (const source of [unified, iam]) {
assert.match(source, /\(5102032, 0, 'PLATFORM', 'BUTTON', 5102030, 'platform:firstleg:inbound:hold'/)
assert.match(source, /role\.role_code='PLATFORM_ADMIN'/)
assert.match(source, /permission\.permission_code='platform:firstleg:inbound:hold'/)
assert.match(source, /入库单扣货权限初始化失败:权限ID、编码或类型冲突/)
assert.match(source, /入库单扣货权限初始化失败:权限资源不完整/)
assert.match(source, /入库单扣货权限初始化失败:平台超级管理员授权不完整/)
}
const migration = unified.slice(unified.indexOf('CREATE PROCEDURE migrate_inbound_hold_permission()'),
unified.indexOf('DROP PROCEDURE IF EXISTS assert_wallet_event_schema'))
assert.doesNotMatch(migration, /role_code='(?:ORDER_OPERATOR|WAREHOUSE_OPERATOR|WAREHOUSE_TALLY|PLATFORM_CUSTOM_ADMIN)'/)
})