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