From 0c8f6fe2b35c1355e1d502e3531d51d32ad3a29a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 7 Sep 2026 11:20:10 +0800 Subject: [PATCH] feat: add inbound hold lifecycle --- deploy/sql/头程数据库统一增量-MySQL8.sql | 90 ++++++++- .../PlatformInboundOrderController.java | 40 ++++ .../baiyue/firstleg/dto/InboundHoldDtos.java | 42 +++++ .../CabinetLoadingEvidenceService.java | 5 + .../service/LoadingDeliveryService.java | 6 + .../mapper/InboundHoldGateMapper.java | 72 ++++++++ .../firstleg/mapper/InboundOrderMapper.java | 13 ++ .../mapper/SmartLoadingCandidateMapper.java | 24 ++- .../mapper/OutboundDispatchReadMapper.java | 6 +- .../service/OutboundDispatchService.java | 2 + .../repository/SmartLoadingRepository.java | 4 +- .../firstleg/service/InboundHoldGuard.java | 39 ++++ .../firstleg/service/InboundHoldService.java | 121 ++++++++++++ .../firstleg/service/LoadingListService.java | 3 + .../firstleg/service/LoadingPickService.java | 13 ++ .../service/LoadingWorkPalletService.java | 4 + .../firstleg/service/SmartLoadingService.java | 9 + .../resources/db/phase2-inbound-schema.sql | 2 +- .../main/resources/openapi/firstleg-v1.yaml | 70 +++++++ .../controller/InboundHoldContractTest.java | 87 +++++++++ .../CabinetLoadingEvidenceServiceTest.java | 2 + .../service/LoadingDeliveryServiceTest.java | 2 + .../dispatch/OutboundDispatchServiceTest.java | 2 +- .../InboundWorkflowSqlContractTest.java | 18 ++ .../service/InboundHoldGuardTest.java | 40 ++++ .../service/InboundHoldServiceTest.java | 135 ++++++++++++++ .../InboundOrderCreationServiceTest.java | 2 +- .../service/LoadingListServiceTest.java | 2 + .../service/LoadingPickServiceTest.java | 2 + .../service/LoadingWorkPalletServiceTest.java | 1 + ...SmartLoadingProvisionalAdjustmentTest.java | 1 + .../service/SmartLoadingServiceTest.java | 1 + .../resources/db/inbound-hold-permission.sql | 85 +++++++++ iam/src/main/resources/openapi/iam-v1.yaml | 2 +- .../controller/IamOpenApiContractTest.java | 2 + .../InboundHoldPermissionSqlContractTest.java | 67 +++++++ .../AuthorizationSnapshotServiceTest.java | 16 ++ .../client/FirstlegUnifiedOrderClient.java | 1 + .../order/dto/UnifiedOrderResponse.java | 1 + .../service/UnifiedOrderQueryService.java | 7 +- .../src/main/resources/openapi/order-v1.yaml | 2 + .../UnifiedOrderApiContractTest.java | 7 +- .../service/UnifiedOrderQueryServiceTest.java | 8 + scripts/admin-test-env-acceptance.mjs | 3 +- scripts/admin-test-env-modules/README.md | 1 + .../inbound-hold-contract.test.mjs | 28 +++ .../admin-test-env-modules/inbound-hold.mjs | 173 ++++++++++++++++++ ...inbound-hold-unified-sql-contract.test.mjs | 33 ++++ 48 files changed, 1280 insertions(+), 16 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dto/InboundHoldDtos.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundHoldGateMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/InboundHoldGuard.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/InboundHoldService.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/controller/InboundHoldContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/InboundHoldGuardTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/InboundHoldServiceTest.java create mode 100644 iam/src/main/resources/db/inbound-hold-permission.sql create mode 100644 iam/src/test/java/com/baiyue/iam/repository/InboundHoldPermissionSqlContractTest.java create mode 100644 scripts/admin-test-env-modules/inbound-hold-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/inbound-hold.mjs create mode 100644 scripts/inbound-hold-unified-sql-contract.test.mjs diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 3ffecbda1..a90caa5bb 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -2560,7 +2560,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( destination_code VARCHAR(32) NULL COMMENT '目的仓编码,例如FBA仓编码', destination_name VARCHAR(128) NULL COMMENT '目的仓名称快照', hold_flag TINYINT(1) NOT NULL DEFAULT 0 COMMENT '当前是否扣货', - current_hold_reason VARCHAR(500) NULL COMMENT '当前扣货理由快照', + current_hold_reason VARCHAR(1000) NULL COMMENT '当前扣货理由快照', last_overseas_hold_version BIGINT UNSIGNED NULL COMMENT '海外暂扣事件最新聚合版本,用于阻止乱序覆盖', total_package_count INT UNSIGNED NULL COMMENT '确认后的总箱数', actual_gross_weight_kg DECIMAL(18,3) NULL COMMENT '确认后的实际总毛重kg', @@ -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; 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; + +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; DELIMITER $$ 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(); DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; SELECT 'PASS' AS migration_status, - '统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、客户资金账户、公共资源、可靠审计及IAM安全事件结构校验通过' AS message; + '统一增量全部执行完成;父子订单、入库扣货、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、客户资金账户、公共资源、可靠审计及IAM安全事件结构校验通过' AS message; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundOrderController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundOrderController.java index 4d270f936..edf1ef508 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundOrderController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundOrderController.java @@ -10,9 +10,12 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.InboundOrderDetail; import com.baiyue.firstleg.dto.InboundOrderQuery; import com.baiyue.firstleg.dto.InboundOrderSummary; +import com.baiyue.firstleg.dto.InboundHoldDtos.CommandRequest; +import com.baiyue.firstleg.dto.InboundHoldDtos.Detail; import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.InboundCartonLabelPrintService; import com.baiyue.firstleg.service.InboundOrderQueryService; +import com.baiyue.firstleg.service.InboundHoldService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @@ -39,6 +42,7 @@ import java.util.List; @RequestMapping("/api/platform/inbounds") public class PlatformInboundOrderController { @Resource private InboundOrderQueryService service; + @Resource private InboundHoldService holdService; @Resource private InboundCartonLabelPrintService cartonLabelPrintService; @Operation(summary = "分页查询入库单", @@ -68,6 +72,42 @@ public class PlatformInboundOrderController { return ApiResponse.success(service.detail(tenantId, inboundId)); } + @Operation(summary = "查询入库单扣货快照与不可变历史") + @GetMapping("/{inboundId}/hold") + @PreAuthorize("hasAuthority('platform:firstleg:inbound:list')") + public ApiResponse 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 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 releaseHold(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam long tenantId, + @PathVariable long inboundId, + @RequestHeader("Idempotency-Key") String idempotencyKey, + @Valid @org.springframework.web.bind.annotation.RequestBody CommandRequest request) { + requirePlatformTenant(user, tenantId); + return ApiResponse.success(holdService.release(tenantId, inboundId, user.userId(), user.username(), + idempotencyKey, request)); + } + @Operation(summary = "生成入仓箱唛 PDF", description = "首次调用按订单货物明细冻结整单箱号;重复打印及指定箱下标补打复用原箱号映射。") @PostMapping("/{inboundId}/carton-label-pdf") diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundHoldDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundHoldDtos.java new file mode 100644 index 000000000..689bfc5d2 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundHoldDtos.java @@ -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 history + ) { } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceService.java index 5ed4d2e85..c5673de44 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceService.java @@ -12,6 +12,7 @@ import com.baiyue.firstleg.loading.domain.LoadingEvidenceType; import com.baiyue.firstleg.loading.entity.CabinetLoadingEvidenceEntity; import com.baiyue.firstleg.loading.mapper.CabinetLoadingEvidenceMapper; import com.baiyue.firstleg.repository.LoadingPickRepository; +import com.baiyue.firstleg.service.InboundHoldGuard; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import feign.FeignException; @@ -41,10 +42,12 @@ public class CabinetLoadingEvidenceService { @Resource private LoadingPickRepository loadingPicks; @Resource private LoadingEvidenceFileClient files; @Resource private ObjectMapper objectMapper; + @Resource private InboundHoldGuard inboundHoldGuard; @Value("${baiyue.security.internal-service-token:}") private String internalToken; @Transactional public EvidenceList upsert(long taskId, String key, UpsertRequest request, long operatorId, String operatorName) { + inboundHoldGuard.requireTaskNotHeld(taskId); return upsert(taskId, key, request, operatorId, operatorName, hash(request)); } @@ -91,6 +94,7 @@ public class CabinetLoadingEvidenceService { public EvidenceList upload(long taskId, long warehouseId, String key, int expectedVersion, String evidenceType, String sealNo, String remark, MultipartFile multipart, long operatorId, String operatorName) { + inboundHoldGuard.requireTaskNotHeld(taskId); Scope scope = scope(taskId, warehouseId, operatorId, true); requireKey(key); String requestHash = uploadHash(warehouseId, expectedVersion, evidenceType, sealNo, remark, multipart); @@ -128,6 +132,7 @@ public class CabinetLoadingEvidenceService { @Transactional public EvidenceList voidEvidence(long taskId, long evidenceId, VoidRequest request, long operatorId, String operatorName) { + inboundHoldGuard.requireTaskNotHeld(taskId); Scope scope = scope(taskId, request.warehouseId(), operatorId, true); CabinetLoadingEvidenceEntity value = mapper.byContainer(scope.containerBookingId()).stream() .filter(item -> Objects.equals(item.getId(), evidenceId)).findFirst() diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java index 7ad30b3c8..096ee9003 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/LoadingDeliveryService.java @@ -16,6 +16,7 @@ import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService; import com.baiyue.firstleg.loading.repository.LoadingRepository; import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.service.FbaPricingService; +import com.baiyue.firstleg.service.InboundHoldGuard; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; @@ -43,6 +44,7 @@ public class LoadingDeliveryService { @Resource private CabinetLoadingEvidenceService cabinetEvidenceService; @Resource private FbaPricingService pricingService; @Resource private ObjectMapper objectMapper; + @Resource private InboundHoldGuard inboundHoldGuard; @Transactional public LoadingActionResponse completeLoading(long tenantId, long inboundId, long operatorId, @@ -54,6 +56,7 @@ public class LoadingDeliveryService { long operatorId, String operatorName, String key, CompleteLoadingRequest request, boolean acceptedPdaCompletion) { + inboundHoldGuard.requireInboundNotHeld(tenantId, inboundId); String hash = hash(request); LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, COMPLETE_LOADING, key, hash, acceptedPdaCompletion); @@ -106,6 +109,7 @@ public class LoadingDeliveryService { * every external/business hard gate must pass before the PDA task is mutated. */ public void requirePdaCompletionReady(long tenantId, long inboundId) { + inboundHoldGuard.requireInboundNotHeld(tenantId, inboundId); Snapshot inbound = requireInbound(tenantId, inboundId); if (!"LOADING".equals(inbound.processStatus())) { throw new BizException(ResultCode.CONFLICT, "PDA确认装柜完成前订单必须处于LOADING"); @@ -144,6 +148,7 @@ public class LoadingDeliveryService { @Transactional public PickingResponse startPicking(long tenantId, long inboundId, long operatorId, String operatorName, String key, PickingRequest request) { + inboundHoldGuard.requireInboundNotHeld(tenantId, inboundId); String hash = hash(request); LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, "START_PICKING", key, hash); if (replay != null) return pickResponse(tenantId, inboundId); @@ -171,6 +176,7 @@ public class LoadingDeliveryService { @Transactional public PickingResponse completePicking(long tenantId, long inboundId, long operatorId, String operatorName, String key, PickingRequest request) { + inboundHoldGuard.requireInboundNotHeld(tenantId, inboundId); String hash = hash(request); LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, "COMPLETE_PICKING", key, hash); if (replay != null) return pickResponse(tenantId, inboundId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundHoldGateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundHoldGateMapper.java new file mode 100644 index 000000000..9ce969034 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundHoldGateMapper.java @@ -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); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java index 4e532cafe..ffb4f0d87 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundOrderMapper.java @@ -24,6 +24,19 @@ public interface InboundOrderMapper extends BaseMapper { @Param("detained") boolean detained, @Param("reason") String reason, @Param("sourceVersion") long sourceVersion, @Param("operatorId") long operatorId); + @Update(""" + UPDATE fl_inbound_order + SET hold_flag=#{holdFlag}, current_hold_reason=#{reason}, + updated_by=#{operatorId}, version=version+1 + WHERE id=#{inboundId} AND tenant_id=#{tenantId} AND deleted=0 + AND version=#{expectedVersion} AND hold_flag=#{expectedHoldFlag} + """) + int updateManualHold(@Param("tenantId") long tenantId, @Param("inboundId") long inboundId, + @Param("expectedVersion") int expectedVersion, + @Param("expectedHoldFlag") boolean expectedHoldFlag, + @Param("holdFlag") boolean holdFlag, @Param("reason") String reason, + @Param("operatorId") long operatorId); + @Update(""" UPDATE fl_inbound_order SET process_status = CASE WHEN fulfillment_type='ONE_CLICK_DROPSHIP' diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java index 440aa326d..418095224 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java @@ -49,6 +49,23 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba List occupiedOrderIds(@Param("query") InboundCandidateQuery query, @Param("actualChannels") Set actualChannels); + @Select(""" + + """) + List heldOrderIds(@Param("query") InboundCandidateQuery query, + @Param("actualChannels") Set actualChannels); + @Select(""" SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId, o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode, @@ -105,6 +122,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba batch.total_volume_cbm,source.total_package_count ) i ON i.tenant_id=o.tenant_id AND i.inbound_id=o.id AND i.measurement_id=m.id WHERE (#{warehouseId} IS NULL OR o.warehouse_id=#{warehouseId}) AND o.process_status='WAIT_SPLIT' + AND COALESCE(o.hold_flag,0)=0 AND o.actual_gross_weight_kg IS NOT NULL AND o.actual_net_weight_kg IS NOT NULL AND (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT')) AND i.package_count>COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li @@ -148,6 +166,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba AND split_item.parent_inbound_id=o.id),0) availablePackageCount FROM fl_inbound_order o WHERE (#{query.warehouseId} IS NULL OR o.warehouse_id=#{query.warehouseId}) + AND COALESCE(o.hold_flag,0)=0 AND o.channel_code IN #{channel} AND (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT')) @@ -242,6 +261,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba LEFT JOIN fl_coload_group current_group ON current_group.id=current_member.group_id AND current_group.group_status='ACTIVE' WHERE o.process_status='WAIT_SPLIT' + AND COALESCE(o.hold_flag,0)=0 AND o.actual_gross_weight_kg IS NOT NULL AND o.actual_net_weight_kg IS NOT NULL AND (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT')) AND ( @@ -282,7 +302,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba ON current_member.tenant_id=o.tenant_id AND current_member.inbound_id=o.id AND current_member.active_flag=1 LEFT JOIN fl_coload_group current_group ON current_group.id=current_member.group_id AND current_group.group_status IN ('ACTIVE','LOCKED') - WHERE o.process_status IN ('WAIT_MEASURE','MEASURING','MEASURE_PENDING','MEASURE_REJECTED', + WHERE COALESCE(o.hold_flag,0)=0 + AND o.process_status IN ('WAIT_MEASURE','MEASURING','MEASURE_PENDING','MEASURE_REJECTED', 'WAIT_PRICING','FEE_CONFIRMING','INBOUNDING','LOADING') AND ( @@ -388,6 +409,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba batch.total_volume_cbm,source.total_package_count ) i ON i.tenant_id=o.tenant_id AND i.inbound_id=o.id AND i.measurement_id=m.id WHERE o.id=#{inboundId} + AND COALESCE(o.hold_flag,0)=0 AND (o.process_status='WAIT_SPLIT' OR (o.process_status='LOADING' AND EXISTS ( SELECT 1 FROM fl_loading_plan_item same_plan_item WHERE same_plan_item.loading_plan_id=#{planId} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java index 0ef6aacdd..9acde107d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/mapper/OutboundDispatchReadMapper.java @@ -13,7 +13,7 @@ public interface OutboundDispatchReadMapper { @Select(""" SELECT DISTINCT inbound.order_id orderId,inbound.order_no orderNo,inbound.id inboundId, inbound.inbound_code inboundCode,inbound.tenant_id tenantId,inbound.customer_id customerId, - inbound.customer_name customerName,inbound.process_status inboundStatus, + inbound.customer_name customerName,inbound.process_status inboundStatus,inbound.hold_flag holdFlag, booking.id containerBookingId,booking.cabinet_no cabinetNo,booking.container_no containerNo, plan.id loadingPlanId,plan.plan_status loadingPlanStatus FROM fl_loading_plan_item item @@ -29,7 +29,7 @@ public interface OutboundDispatchReadMapper { @Select(""" SELECT inbound.order_id orderId,inbound.order_no orderNo,inbound.id inboundId, inbound.inbound_code inboundCode,inbound.tenant_id tenantId,inbound.customer_id customerId, - inbound.customer_name customerName,inbound.process_status inboundStatus, + inbound.customer_name customerName,inbound.process_status inboundStatus,inbound.hold_flag holdFlag, booking.id containerBookingId,booking.cabinet_no cabinetNo,booking.container_no containerNo, plan.id loadingPlanId,plan.plan_status loadingPlanStatus FROM fl_loading_plan_item item @@ -178,7 +178,7 @@ public interface OutboundDispatchReadMapper { @Param("truckAppointmentId") Long truckAppointmentId); record Binding(Long orderId, String orderNo, Long inboundId, String inboundCode, Long tenantId, - Long customerId, String customerName, String inboundStatus, Long containerBookingId, + Long customerId, String customerName, String inboundStatus, Boolean holdFlag, Long containerBookingId, String cabinetNo, String containerNo, Long loadingPlanId, String loadingPlanStatus) {} record IdStatus(Long id, String status) {} record OceanBinding(Long id, String status, Long customerId) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java index 3c88b2fd0..87b4896fc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/outbound/dispatch/service/OutboundDispatchService.java @@ -179,6 +179,8 @@ public class OutboundDispatchService { OutboundDispatchReadMapper.OceanBinding ocean, OutboundDispatchReadMapper.Evidence evidence, List containerContributorGates) { List items = new ArrayList<>(); + items.add(item("INBOUND_NOT_HELD", "入库单未扣货", !Boolean.TRUE.equals(binding.holdFlag()), + Boolean.TRUE.equals(binding.holdFlag()) ? "HELD" : "RELEASED", "RELEASED")); items.add(item("INBOUND_LOADED", "入库单装柜完成", "LOADED".equals(binding.inboundStatus()), binding.inboundStatus(), "LOADED")); items.add(item("LOADING_PLAN_COMPLETED", "柜次装柜计划完成", "COMPLETED".equals(binding.loadingPlanStatus()), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java index e3e8ff37d..e542d0273 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java @@ -60,7 +60,9 @@ public class SmartLoadingRepository { return candidates.planColoadMembers(planId); } public List occupiedOrderIds(InboundCandidateQuery query, Set actualChannels) { - return candidates.occupiedOrderIds(query, actualChannels); + return java.util.stream.Stream.concat(candidates.occupiedOrderIds(query, actualChannels).stream(), + candidates.heldOrderIds(query, actualChannels).stream()) + .distinct().toList(); } public Candidate candidate(long planId, long inboundId, long detailId) { return candidates.candidate(planId, inboundId, detailId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundHoldGuard.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundHoldGuard.java new file mode 100644 index 000000000..efb748afe --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundHoldGuard.java @@ -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, "入库单已扣货,当前履约操作已阻断"); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundHoldService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundHoldService.java new file mode 100644 index 000000000..3dd80a02e --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundHoldService.java @@ -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 records = history.selectList(new LambdaQueryWrapper() + .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); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index 762a479ba..6b0335f58 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -70,6 +70,8 @@ public class LoadingListService { @Lazy private LoadingPickService loadingPickService; @Resource + private InboundHoldGuard inboundHoldGuard; + @Resource private LoadingCompletedOutboxService loadingCompletedOutboxService; @Resource private ObjectMapper objectMapper; @@ -491,6 +493,7 @@ public class LoadingListService { public PreloadConfirmationResponse confirmPreload(long planId, long operatorId, String operatorName, String idempotencyKey, ConfirmPreloadRequest request) { + inboundHoldGuard.requirePlanNotHeld(planId); String requestHash = sha256("CONFIRM_PRELOAD|" + planId + '|' + request.expectedVersion()); String operationId = operationId("CONFIRM_PRELOAD", planId, idempotencyKey); HistoryRow replay = repository.history(operationId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index 5546a0b33..f7f5b6643 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -104,6 +104,8 @@ public class LoadingPickService { private PdaPickSettlementService settlementService; @Resource private InboundPalletAllocationRepository palletAllocations; + @Resource + private InboundHoldGuard inboundHoldGuard; /** Opens the field task after planning and ocean release; the real container number may be filled later. */ @Transactional @@ -112,6 +114,7 @@ public class LoadingPickService { if (booking == null) return; var plan = smartLoadingRepository.planByBooking(containerBookingId); if (plan == null || !"EDITING".equals(plan.getPlanStatus())) return; + inboundHoldGuard.requirePlanNotHeld(plan.getId()); var links = loadingModelBridge.byLoadingPlan(plan.getId()); if (links.isEmpty() || links.stream().anyMatch(link -> !"CONFIRMED".equals(link.preloadStatus()))) return; var allocation = oceanBookingRepository.allocationByContainer(containerBookingId); @@ -124,6 +127,7 @@ public class LoadingPickService { } public List requirePreloadConfirmationReady(long loadingPlanId) { + inboundHoldGuard.requirePlanNotHeld(loadingPlanId); List links = loadingModelBridge.requireAllocatedLinksForConfirmation(loadingPlanId); for (Link link : links) { if (!Set.of("PENDING", "CONFIRMING").contains(link.preloadStatus())) { @@ -145,6 +149,7 @@ public class LoadingPickService { @Transactional public void begin(long planId, String containerNo, long operatorId, String operatorName, List planItems) { + inboundHoldGuard.requirePlanNotHeld(planId); LoadingPickTaskEntity task = repository.taskByPlan(planId); if (task == null) { task = new LoadingPickTaskEntity(); @@ -242,6 +247,7 @@ public class LoadingPickService { @Transactional public Task claim(long taskId, ClaimRequest request, long operatorId, String operatorName) { + inboundHoldGuard.requireTaskNotHeld(taskId); String operationId = operation(request.operationId()); String requestHash = hash("CLAIM|" + taskId + '|' + request.expectedAssignmentVersion()); Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId); @@ -271,6 +277,7 @@ public class LoadingPickService { @Transactional public Task assign(long taskId, AssignRequest request, long operatorId, String operatorName) { + inboundHoldGuard.requireTaskNotHeld(taskId); String operationId = operation(request.operationId()); String assigneeName = request.assigneeName().trim(); String requestHash = hash("ASSIGN|" + taskId + '|' + request.assigneeId() + '|' @@ -294,6 +301,7 @@ public class LoadingPickService { @Transactional public Task pick(long taskId, long itemId, PickRequest request, long operatorId, String operatorName) { + inboundHoldGuard.requireTaskNotHeld(taskId); String operationId = request.operationId().trim(); String requestHash = hash(taskId + "|" + itemId + "|" + request.warehouseId() + "|" + request.pickedPackageCount()); @@ -338,6 +346,7 @@ public class LoadingPickService { @Transactional public Task partial(long taskId, long itemId, PartialPickRequest request, long operatorId, String operatorName) { + inboundHoldGuard.requireTaskNotHeld(taskId); String operationId = request.operationId().trim(); String requestHash = hash("PARTIAL|" + taskId + "|" + itemId + "|" + request.warehouseId() + "|" + request.finalPickedPackageCount()); @@ -368,6 +377,7 @@ public class LoadingPickService { @Transactional public Task skip(long taskId, long itemId, SkipRequest request, long operatorId, String operatorName) { + inboundHoldGuard.requireTaskNotHeld(taskId); String operationId = request.operationId().trim(); String reason = trim(request.reason()); String requestHash = hash("SKIP|" + taskId + "|" + itemId + "|" + request.warehouseId() @@ -405,6 +415,7 @@ public class LoadingPickService { @Transactional public Task batch(long taskId, String idempotencyKey, BatchRequest request, long operatorId, String operatorName) { + inboundHoldGuard.requireTaskNotHeld(taskId); String operationId = operation(idempotencyKey); String requestHash = batchRequestHash(taskId, request); Task replay = replayTaskOperation(taskId, operationId, requestHash, operatorId); @@ -451,6 +462,7 @@ public class LoadingPickService { @Transactional public Task review(long taskId, ReviewRequest request, long operatorId, String operatorName) { + inboundHoldGuard.requireTaskNotHeld(taskId); return complete(taskId, new CompleteLoadingRequest(request.expectedTaskVersion(), request.operationId(), request.remark()), operatorId, operatorName); } @@ -461,6 +473,7 @@ public class LoadingPickService { */ @Transactional public Task complete(long taskId, CompleteLoadingRequest request, long operatorId, String operatorName) { + inboundHoldGuard.requireTaskNotHeld(taskId); String operationId = operation(request.operationId()); String remark = trim(request.remark()); String requestHash = hash("COMPLETE|" + taskId + '|' + request.expectedTaskVersion() + '|' + remark); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingWorkPalletService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingWorkPalletService.java index 4557f92ce..a557e4c72 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingWorkPalletService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingWorkPalletService.java @@ -29,9 +29,11 @@ import java.util.Objects; public class LoadingWorkPalletService { @Resource private LoadingWorkPalletMapper pallets; @Resource private LoadingWorkPalletCartonMapper cartons; + @Resource private InboundHoldGuard inboundHoldGuard; @Transactional public Pallet open(long taskId, OpenRequest request, long operatorId, String operatorName) { + inboundHoldGuard.requireTaskNotHeld(taskId); TaskContext context = requireWorkTask(taskId, request.warehouseId(), operatorId); String code = request.palletCode().trim(); String operationId = request.operationId().trim(); @@ -68,6 +70,7 @@ public class LoadingWorkPalletService { @Transactional public Pallet scan(long taskId, long palletId, ScanRequest request, long operatorId, String operatorName) { + inboundHoldGuard.requireTaskNotHeld(taskId); requireWorkTask(taskId, request.warehouseId(), operatorId); LoadingWorkPalletEntity target = requireOpenPallet(taskId, palletId, request.warehouseId()); String operationId = request.operationId().trim(); @@ -114,6 +117,7 @@ public class LoadingWorkPalletService { @Transactional public Pallet seal(long taskId, long palletId, SealRequest request, long operatorId, String operatorName) { + inboundHoldGuard.requireTaskNotHeld(taskId); requireWorkTask(taskId, request.warehouseId(), operatorId); LoadingWorkPalletEntity replay = pallets.selectBySealOperation(request.operationId().trim()); if (replay != null) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index 98fe56224..3fea4c3c1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -58,6 +58,7 @@ public class SmartLoadingService { @Resource private PendingReviewOrderClient pendingReviewOrders; @Resource private InboundOrderCreationService inboundOrderCreationService; @Resource private LoadingPickService loadingPickService; + @Resource private InboundHoldGuard inboundHoldGuard; @Resource private LoadingModelBridgeService loadingModelBridge; @Resource private SplitPreloadService splitPreloadService; @Resource private ColoadGroupAssignmentPort coloadGroups; @@ -432,6 +433,7 @@ public class SmartLoadingService { ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); Candidate candidate = repository.candidate(planId, request.inboundId(), request.inboundDetailId()); if (candidate == null) throw conflict("入库货物不存在或尚未完成入库"); + inboundHoldGuard.requireInboundNotHeld(candidate.accountId(), candidate.inboundId()); if (!wholeGroupAssignment) requireStandalone(candidate.accountId(), candidate.inboundId()); requireSameCustomerScope(booking, candidate); var task = loadingPickService.mutationSnapshot(planId); @@ -513,6 +515,7 @@ public class SmartLoadingService { throw conflict("待审核订单快照已变化,请刷新后重试"); } long inboundId = inboundOrderCreationService.prepareFromDraft(snapshot); + inboundHoldGuard.requireInboundNotHeld(request.customerId(), inboundId); LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName); if (!"EDITING".equals(plan.getPlanStatus())) { throw conflict("待审核订单只能加入编辑中的装柜清单"); @@ -583,6 +586,9 @@ public class SmartLoadingService { if (replay != null) return replay; InboundOrderEntity projection = repository.inbound(request.inboundId()); + if (projection != null) { + inboundHoldGuard.requireInboundNotHeld(projection.getTenantId(), projection.getId()); + } if (!wholeGroupAssignment && projection != null && Set.of("PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING").contains(projection.getProcessStatus())) { @@ -597,6 +603,7 @@ public class SmartLoadingService { : List.copyOf(frozenDetails); if (details.isEmpty()) throw conflict("入仓码不存在、已分配完或尚未完成入库"); Candidate first = details.get(0); + inboundHoldGuard.requireInboundNotHeld(first.accountId(), first.inboundId()); if (!wholeGroupAssignment) requireStandalone(first.accountId(), first.inboundId()); if (!repository.activeItemsByInbound(planId, first.accountId(), request.inboundId()).isEmpty()) { throw conflict("该入仓码已在当前柜次中,请先调整或移除现有分配"); @@ -728,6 +735,7 @@ public class SmartLoadingService { ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); InboundOrderEntity selected = repository.inbound(request.inboundId()); if (selected == null) throw conflict("同柜不可拆组成员当前不可分配"); + inboundHoldGuard.requireInboundNotHeld(selected.getTenantId(), selected.getId()); GroupSnapshot discovered = requiredGroup(selected.getTenantId(), selected.getId()); Map> memberCandidates = requireWholeGroupCandidates( planId, booking, discovered, repository.items(planId)); @@ -851,6 +859,7 @@ public class SmartLoadingService { LoadingPlanEntity plan = editing(planId, request.expectedPlanVersion(), operatorId, operatorName); LoadingPlanItemEntity item = repository.item(planId, itemId); if (item == null) throw new BizException(ResultCode.NOT_FOUND, "装柜明细不存在"); + inboundHoldGuard.requireInboundNotHeld(item.getAccountId(), item.getInboundId()); requireStandalone(item.getAccountId(), item.getInboundId()); if (!Objects.equals(item.getVersion(), request.expectedItemVersion())) throw conflict("装柜明细版本冲突"); LoadingPickService.MutationSnapshot pickSnapshot = loadingPickService.mutationSnapshot(planId); diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index cd8e62e7c..76de2bd92 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -42,7 +42,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_order ( destination_code VARCHAR(32) NULL COMMENT '目的仓编码,例如FBA仓编码', destination_name VARCHAR(128) NULL COMMENT '目的仓名称快照', hold_flag TINYINT(1) NOT NULL DEFAULT 0 COMMENT '当前是否扣货', - current_hold_reason VARCHAR(500) NULL COMMENT '当前扣货理由快照', + current_hold_reason VARCHAR(1000) NULL COMMENT '当前扣货理由快照', last_overseas_hold_version BIGINT UNSIGNED NULL COMMENT '海外暂扣事件最新聚合版本,用于阻止乱序覆盖', total_package_count INT UNSIGNED NULL COMMENT '确认后的总箱数', actual_gross_weight_kg DECIMAL(18,3) NULL COMMENT '已确认称重图片含托盘毛重汇总kg', diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 4ba1afcb7..64a7432ae 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -6,6 +6,44 @@ info: security: - bearerAuth: [] paths: + /api/platform/inbounds/{inboundId}/hold: + get: + summary: 查询入库单当前扣货快照与不可变历史 + description: 需要 platform:firstleg:inbound:list;canHold/canRelease 由服务端按实物作业事实计算,前端不得推导。 + parameters: + - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: + '200': {description: 成功时 data 为 InboundHoldDetail;客户归属不匹配时响应体 code=A0404} + post: + summary: 扣货入库单 + description: 需要 platform:firstleg:inbound:hold;同事务写当前快照和不可变历史,不改变 Order 主状态。 + parameters: + - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/InboundHoldCommand'} + responses: + '200': {description: 成功或完全同参幂等重放时 data 为 InboundHoldDetail;版本、状态、异参重放或实物作业冲突时响应体 code=A0409} + /api/platform/inbounds/{inboundId}/hold/release: + post: + summary: 解除入库单扣货 + description: 需要 platform:firstleg:inbound:hold;解除原因进入不可变历史,当前原因快照清空,入库聚合 version 递增 1。 + parameters: + - {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/InboundHoldCommand'} + responses: + '200': {description: 成功或完全同参幂等重放时 data 为 InboundHoldDetail;版本、状态、异参重放或实物作业冲突时响应体 code=A0409} /api/merchant/v1/first-leg/inbounds/{inboundId}/carton-label-pdf: post: summary: 客户端生成本客户入库单箱唛 PDF @@ -1218,6 +1256,38 @@ components: required: true schema: {type: string, pattern: '^[1-9][0-9]*$'} schemas: + InboundHoldCommand: + type: object + required: [expectedVersion, reason] + properties: + expectedVersion: {type: integer, minimum: 0, description: 当前入库聚合 version;成功后递增 1} + reason: {type: string, minLength: 1, maxLength: 1000} + evidenceFileId: {type: integer, format: int64, minimum: 1, nullable: true, description: 可选的既有证据文件引用;本接口不上传文件} + InboundHoldHistoryItem: + type: object + required: [id, action, reason, operatedAt] + properties: + id: {type: integer, format: int64} + action: {type: string, enum: [HOLD, RELEASE]} + reason: {type: string, maxLength: 1000} + evidenceFileId: {type: integer, format: int64, nullable: true} + operatorName: {type: string, nullable: true} + operatedAt: {type: string, format: date-time} + InboundHoldDetail: + type: object + required: [inboundId, tenantId, holdFlag, version, canHold, canRelease, history] + properties: + inboundId: {type: integer, format: int64} + tenantId: {type: integer, format: int64} + holdFlag: {type: boolean, description: 当前权威扣货状态} + currentHoldReason: {type: string, maxLength: 1000, nullable: true, description: 未扣货时固定为 null} + version: {type: integer, minimum: 0} + canHold: {type: boolean} + canRelease: {type: boolean} + actionBlockReason: {type: string, nullable: true} + history: + type: array + items: {$ref: '#/components/schemas/InboundHoldHistoryItem'} OrderChannelChangeValidationRequest: type: object additionalProperties: false diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundHoldContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundHoldContractTest.java new file mode 100644 index 000000000..0b62a2d3f --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundHoldContractTest.java @@ -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 document = stringMap(new Yaml().load(source)); + Map paths = stringMap(document.get("paths")); + assertThat(paths.keySet()).contains( + "/api/platform/inbounds/{inboundId}/hold", + "/api/platform/inbounds/{inboundId}/hold/release"); + + Map schemas = stringMap(stringMap(document.get("components")).get("schemas")); + Map 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 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 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 stringMap(Object value) { + return (Map) value; + } + + @SuppressWarnings("unchecked") + private static List stringList(Object value) { + return (List) value; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceServiceTest.java index a5abbd653..fad9fdb85 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceServiceTest.java @@ -8,6 +8,7 @@ import com.baiyue.firstleg.entity.LoadingPickTaskEntity; import com.baiyue.firstleg.loading.entity.CabinetLoadingEvidenceEntity; import com.baiyue.firstleg.loading.mapper.CabinetLoadingEvidenceMapper; import com.baiyue.firstleg.repository.LoadingPickRepository; +import com.baiyue.firstleg.service.InboundHoldGuard; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; import org.junit.jupiter.api.BeforeEach; @@ -35,6 +36,7 @@ class CabinetLoadingEvidenceServiceTest { @MockBean LoadingPickRepository loadingPicks; @MockBean LoadingEvidenceFileClient files; @MockBean ObjectMapper objectMapper; + @MockBean InboundHoldGuard inboundHoldGuard; @Resource CabinetLoadingEvidenceService service; @BeforeEach diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java index facb75365..2118e71a4 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/LoadingDeliveryServiceTest.java @@ -7,6 +7,7 @@ import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; import com.baiyue.firstleg.mapper.LoadingPickItemMapper.InboundProgress; import com.baiyue.firstleg.repository.LoadingPickRepository; import com.baiyue.firstleg.service.FbaPricingService; +import com.baiyue.firstleg.service.InboundHoldGuard; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; @@ -30,6 +31,7 @@ class LoadingDeliveryServiceTest { @MockBean CabinetLoadingEvidenceService cabinetEvidence; @MockBean FbaPricingService pricingService; @MockBean ObjectMapper objectMapper; + @MockBean InboundHoldGuard inboundHoldGuard; @Resource LoadingDeliveryService service; @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java index 9e9897742..4c5f3b462 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/outbound/dispatch/OutboundDispatchServiceTest.java @@ -266,7 +266,7 @@ class OutboundDispatchServiceTest { private static OutboundDispatchReadMapper.Binding binding(long inboundId, String inboundCode) { return new OutboundDispatchReadMapper.Binding(10L, "O-10", inboundId, inboundCode, 7L, 8L, "客户A", - "LOADED", 22L, "CAB-22", "C-22", 44L, "COMPLETED"); + "LOADED", false, 22L, "CAB-22", "C-22", 44L, "COMPLETED"); } private static OutboundDispatchEntity state(long id, long inboundId, String status, int version) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java index 3d06d0412..36909bfcf 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundWorkflowSqlContractTest.java @@ -12,6 +12,24 @@ import static org.assertj.core.api.Assertions.assertThat; @DisplayName("入库审核SQL契约") class InboundWorkflowSqlContractTest { + @Test + @DisplayName("扣货快照、不可变历史和幂等记录使用冻结字段与长度") + void shouldPersistInboundHoldSnapshotHistoryAndIdempotency() throws Exception { + String schema = new ClassPathResource("db/phase2-inbound-schema.sql") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(schema) + .contains("hold_flag TINYINT(1) NOT NULL DEFAULT 0") + .contains("current_hold_reason VARCHAR(1000) NULL") + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_hold_record") + .contains("hold_action VARCHAR(16) NOT NULL") + .contains("reason VARCHAR(1000) NOT NULL") + .contains("evidence_file_id BIGINT UNSIGNED NULL") + .contains("CREATE TABLE IF NOT EXISTS fl_inbound_idempotency") + .contains("idempotency_key VARCHAR(128) NOT NULL") + .contains("UNIQUE KEY uk_inbound_idempotency"); + } + @Test @DisplayName("基础表和增量迁移包含审核历史、幂等、Outbox和放行索引") void shouldProvideWorkflowPersistenceBoundaries() throws Exception { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundHoldGuardTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundHoldGuardTest.java new file mode 100644 index 000000000..78ec7fa08 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundHoldGuardTest.java @@ -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(); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundHoldServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundHoldServiceTest.java new file mode 100644 index 000000000..f2cd4f0bf --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundHoldServiceTest.java @@ -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 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 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; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java index a9583fa1b..93c19b4b3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java @@ -89,7 +89,7 @@ class InboundOrderCreationServiceTest { base.containerMode(), base.customsInspection(), base.destinationType(), base.destinationWarehouseId(), base.destinationCode(), base.destinationName(), base.publicRemark(), base.expressCompanyName(), base.expressTrackingNo(), base.totalPackageCount(), base.pickupMethod(), base.requestedPickupAt(), - "ONE_CLICK_DROPSHIP"); + "ONE_CLICK_DROPSHIP", base.lxCode()); when(orders.selectOne(any(Wrapper.class))).thenReturn(null); when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L)); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index 82ae7df19..4732996f0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -62,6 +62,8 @@ class LoadingListServiceTest { private LoadingDocumentFileClient documentFiles; @MockBean private LoadingCompletedOutboxService loadingCompletedOutboxService; + @MockBean + private InboundHoldGuard inboundHoldGuard; @Resource private LoadingListService service; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index 9d8fe12b1..05c064f19 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -101,6 +101,8 @@ class LoadingPickServiceTest { private PdaPickSettlementService settlementService; @MockBean private InboundPalletAllocationRepository palletAllocations; + @MockBean + private InboundHoldGuard inboundHoldGuard; @Resource private LoadingPickService service; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingWorkPalletServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingWorkPalletServiceTest.java index c48acc2e2..f2ce1d0c2 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingWorkPalletServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingWorkPalletServiceTest.java @@ -26,6 +26,7 @@ import static org.mockito.Mockito.when; class LoadingWorkPalletServiceTest { @MockBean private LoadingWorkPalletMapper pallets; @MockBean private LoadingWorkPalletCartonMapper cartons; + @MockBean private InboundHoldGuard inboundHoldGuard; @Resource private LoadingWorkPalletService service; @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingProvisionalAdjustmentTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingProvisionalAdjustmentTest.java index 83f057ead..b897bf500 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingProvisionalAdjustmentTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingProvisionalAdjustmentTest.java @@ -42,6 +42,7 @@ class SmartLoadingProvisionalAdjustmentTest { @Mock private PendingReviewOrderClient pendingReviewOrders; @Mock private InboundOrderCreationService inboundOrderCreationService; @Mock private LoadingPickService loadingPickService; + @Mock private InboundHoldGuard inboundHoldGuard; @Mock private LoadingModelBridgeService loadingModelBridge; @Mock private SplitPreloadService splitPreloadService; @Mock private ColoadGroupAssignmentPort coloadGroups; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index 9574bfde9..cdbed4b46 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -89,6 +89,7 @@ class SmartLoadingServiceTest { @MockBean private PendingReviewOrderClient pendingReviewOrders; @MockBean private InboundOrderCreationService inboundOrderCreationService; @MockBean private LoadingPickService loadingPickService; + @MockBean private InboundHoldGuard inboundHoldGuard; @MockBean private LoadingModelBridgeService loadingModelBridge; @MockBean private SplitPreloadService splitPreloadService; @MockBean private ColoadGroupAssignmentPort coloadGroups; diff --git a/iam/src/main/resources/db/inbound-hold-permission.sql b/iam/src/main/resources/db/inbound-hold-permission.sql new file mode 100644 index 000000000..ad20dd6e9 --- /dev/null +++ b/iam/src/main/resources/db/inbound-hold-permission.sql @@ -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; diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index a514bfa67..efd7ce318 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -124,7 +124,7 @@ paths: get: tags: [IAM Resources / IAM 资源] 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: '200': description: 当前平台成员权限查询成功 diff --git a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java index a100dd534..18d1e3192 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java @@ -46,6 +46,8 @@ class IamOpenApiContractTest { .contains("merchant:finance:recharge:create") .contains("platform:finance:recharge:review") .contains("platform:finance:customer-account:adjust") + .contains("platform:firstleg:inbound:hold") + .contains("不会由 `platform:firstleg:inbound:list` 查看权限自动获得") .contains("两者独立授权") .contains("列表权限包含详情只读") .contains("PermissionCodesApiResponse:") diff --git a/iam/src/test/java/com/baiyue/iam/repository/InboundHoldPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/InboundHoldPermissionSqlContractTest.java new file mode 100644 index 000000000..e7b72194b --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/InboundHoldPermissionSqlContractTest.java @@ -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); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java index b62d26115..d9ffdae80 100644 --- a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java @@ -139,6 +139,22 @@ class AuthorizationSnapshotServiceTest { "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 void rejectsIncompleteCurrentIdentityAsBusinessError() { assertThatThrownBy(() -> service diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java index 9e47bf2fb..52074bc89 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java @@ -50,6 +50,7 @@ public interface FirstlegUnifiedOrderClient { LocalDateTime expectedArrivalAt, LocalDateTime requestedPickupAt, String feeReviewStatus, String companyName, String businessType, String taxMode, String containerMode, Boolean customsInspection, String destinationType, String destinationCode, + Boolean holdFlag, String currentHoldReason, BigDecimal actualGrossWeightKg, BigDecimal actualNetWeightKg, BigDecimal actualVolumeCbm, BigDecimal volumeWeightKg, BigDecimal chargeableVolumeCbm, Integer palletCount, BigDecimal chargeableWeightKg, LocalDateTime measureCompletedAt, diff --git a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java index ef47551f5..35cf62245 100644 --- a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java +++ b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java @@ -19,6 +19,7 @@ public final class UnifiedOrderResponse { Long receivingWarehouseId, String receivingWarehouseName, Long destinationWarehouseId, String destinationWarehouseName, Long inboundId, Integer inboundVersion, String inboundCode, LocalDate expectedShipDate, + Boolean holdFlag, String currentHoldReason, Integer totalPackageCount, String recommendedPalletType, String locationCodes, BigDecimal estimatedVolumeCbm, BigDecimal expectedVolumeCbm, BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm, diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index 0d5ab84d4..726231c5b 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -287,7 +287,7 @@ public class UnifiedOrderQueryService { order.getChannelCode(), order.getChannelName(), Boolean.TRUE.equals(order.getChannelChanged()), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(), order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null, order.getInboundCode(), - order.getExpectedShipDate(), totalPackageCount(cargo), null, null, + order.getExpectedShipDate(), null, null, totalPackageCount(cargo), null, null, order.getEstimatedVolumeCbm(), volumeCbm(cargo), null, null, order.getExpectedArrivalAt(), null, null, null, order.getPickupMethod(), order.getTaxMode(), order.getContainerMode(), order.getCustomsInspection(), order.getDestinationType(), order.getAmazonWarehouseCode(), @@ -306,7 +306,8 @@ public class UnifiedOrderQueryService { order.getCustomerId(), order.getCustomerName(), order.getChannelCode(), order.getChannelName(), Boolean.TRUE.equals(order.getChannelChanged()), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(), 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()), order.getEstimatedVolumeCbm(), volumeCbm(cargo), inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), @@ -333,7 +334,7 @@ public class UnifiedOrderQueryService { inbound.customerId(), inbound.customerName(), inbound.channelCode(), inbound.channelName(), order != null && Boolean.TRUE.equals(order.getChannelChanged()), inbound.warehouseId(), 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()), order == null ? null : order.getEstimatedVolumeCbm(), volumeCbm(cargo), inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index 3e5dc9436..5a2af485c 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -753,6 +753,8 @@ components: inboundVersion: {type: integer, nullable: true, description: C04 loading/complete 的 expectedVersion} inboundCode: {type: string, nullable: true, description: 当前订单片段的权威入仓码;缺失时为 null,不得回退为 orderId 或 orderNo} 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: 无入仓事实时为当前订单有效货物箱数合计;存在入仓业务单元后为该单元履约总箱数,拆分后不以父单原始箱数覆盖} recommendedPalletType: {type: string, nullable: true, description: 当前无列表级权威事实时返回 null,不得返回固定托盘类型} locationCodes: {type: string, nullable: true, description: "库位编码汇总;按首次出现顺序去重并使用中文顿号分隔"} diff --git a/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java b/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java index 213fc233f..8a8586b3d 100644 --- a/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java +++ b/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java @@ -54,6 +54,7 @@ class UnifiedOrderApiContractTest { assertThat(stringMap(row.get("properties"))).containsKeys( "orderId", "mainStatus", "channelCode", "inboundId", "inboundVersion", "measurement", "preload", "loading", "delivery", "transport", "customerRemark", "latestInternalNote", + "holdFlag", "currentHoldReason", "expectedArrivalAt", "requestedPickupAt", "feeReviewStatus", "companyName", "businessType", "pickupMethod", "taxMode", "containerMode", "customsInspection", "destinationType", "destinationCode", "totalPalletCount", "chargeableWeightKg", "chargeableVolumeCbm", "volumetricWeightKg", "totalNetWeightKg", @@ -64,6 +65,8 @@ class UnifiedOrderApiContractTest { assertThat(inboundCode.get("description").toString()).contains("不得回退"); Map channelCode = stringMap(stringMap(row.get("properties")).get("channelCode")); assertThat(channelCode.get("description").toString()).contains("不由 channelName 推导"); + assertThat(stringMap(row.get("properties"))) + .doesNotContainKeys("holdCargo", "holdReason"); Map coLoadGroup = stringMap(schemas.get("UnifiedCoLoadGroupSummary")); assertThat(stringMap(coLoadGroup.get("properties"))) .containsOnlyKeys("status", "memberCount", "memberInboundCodes"); @@ -82,7 +85,9 @@ class UnifiedOrderApiContractTest { "page", String.class, FirstlegUnifiedOrderClient.Query.class); assertThat(page.getAnnotation(PostMapping.class).value()).containsExactly("/query"); 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 diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java index 5f11ea806..422706d48 100644 --- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -74,6 +74,8 @@ class UnifiedOrderQueryServiceTest { assertThat(row.inboundCode()).isEqualTo("RK-77"); assertThat(row.channelCode()).isEqualTo("MATSON"); assertThat(row.inboundId()).isNull(); + assertThat(row.holdFlag()).isNull(); + assertThat(row.currentHoldReason()).isNull(); assertThat(row.customerRemark()).isEqualTo("客户要求提货前联系"); assertThat(row.latestInternalNote()).isEqualTo("客服已确认提货时间"); assertThat(row.relation()).isNull(); @@ -98,6 +100,8 @@ class UnifiedOrderQueryServiceTest { assertThat(row.nextAction()).isEqualTo("APPROVE_ORDER"); assertThat(row.inboundId()).isEqualTo(88L); 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().internalOrderStatus()).isEqualTo(OrderStatus.APPROVED); assertThat(result.summary().inboundId()).isEqualTo(88L); + assertThat(result.summary().holdFlag()).isTrue(); + assertThat(result.summary().currentHoldReason()).isEqualTo("等待客户补充资料"); ArgumentCaptor captor = ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.DetailRequest.class); verify(firstleg).detail(eq("internal-token"), captor.capture()); @@ -701,6 +707,8 @@ class UnifiedOrderQueryServiceTest { when(inbound.deliveryWarehouseName()).thenReturn("ONT8"); when(inbound.channelName()).thenReturn("美森"); when(inbound.channelCode()).thenReturn("MATSON"); + when(inbound.holdFlag()).thenReturn(true); + when(inbound.currentHoldReason()).thenReturn("等待客户补充资料"); when(inbound.totalPackageCount()).thenReturn(10); when(inbound.locationCodes()).thenReturn("A0-01, B2-03"); when(inbound.companyName()).thenReturn("测试客户有限公司"); diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 38023a014..aed1f6802 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -5,7 +5,7 @@ import { dirname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' 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', 'manual-preload-push-fixture', 'manual-preload-push-verify', 'manual-pda-ready-fixture', 'manual-pda-ready-verify', @@ -45,6 +45,7 @@ const MODULE_DEPENDENCIES = new Map([ ['suppliers', ['merchant-qualification-fixture']], ['supplemental', ['merchant-qualification-fixture']], ['orders', ['merchant-qualification-fixture']], + ['inbound-hold', ['orders']], ['inbound-measurement', ['orders']], ['manual-order-splitting-fixture', ['inbound-measurement']], ['coload-groups', ['inbound-measurement']], diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 6f967a634..e5f75afee 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -62,6 +62,7 @@ export async function run(context) { | 公共主数据与往来单位 | `master-data.mjs` | `master-data-partner-contract.test.mjs` | | 平台基础、客户、海外仓储费与下载 | `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` | +| 入库单手工扣货与解除 | `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` | | 入库测量、首次打印冻结箱码与墨家扫码 | `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` | diff --git a/scripts/admin-test-env-modules/inbound-hold-contract.test.mjs b/scripts/admin-test-env-modules/inbound-hold-contract.test.mjs new file mode 100644 index 000000000..cbdfff879 --- /dev/null +++ b/scripts/admin-test-env-modules/inbound-hold-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/) +}) diff --git a/scripts/admin-test-env-modules/inbound-hold.mjs b/scripts/admin-test-env-modules/inbound-hold.mjs new file mode 100644 index 000000000..efca25bd1 --- /dev/null +++ b/scripts/admin-test-env-modules/inbound-hold.mjs @@ -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) +} diff --git a/scripts/inbound-hold-unified-sql-contract.test.mjs b/scripts/inbound-hold-unified-sql-contract.test.mjs new file mode 100644 index 000000000..503b85c8e --- /dev/null +++ b/scripts/inbound-hold-unified-sql-contract.test.mjs @@ -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)'/) +})