优化
This commit is contained in:
+6
@@ -90,6 +90,12 @@ public class LoadingModelBridgeService {
|
|||||||
throw conflict("PDA开始前的预装关联不完整,禁止在LOADING后补关联");
|
throw conflict("PDA开始前的预装关联不完整,禁止在LOADING后补关联");
|
||||||
}
|
}
|
||||||
for (AllocationScope scope : scopes) {
|
for (AllocationScope scope : scopes) {
|
||||||
|
// Internal push replay is pair-idempotent: an existing association has
|
||||||
|
// already advanced both versions, so calling public bind again would
|
||||||
|
// derive a different request hash for the same AUTO key.
|
||||||
|
if (repository.byPair(scope.tenantId(), scope.inboundId(), loadingPlanId) != null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
var preload = repository.preload(scope.tenantId(), scope.inboundId());
|
var preload = repository.preload(scope.tenantId(), scope.inboundId());
|
||||||
var currentPlan = repository.loadingPlan(loadingPlanId);
|
var currentPlan = repository.loadingPlan(loadingPlanId);
|
||||||
if (preload == null || currentPlan == null) {
|
if (preload == null || currentPlan == null) {
|
||||||
|
|||||||
+11
@@ -18,6 +18,7 @@ import jakarta.annotation.Resource;
|
|||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
/** 平台端装柜证据和国内装柜完成操作。 */
|
/** 平台端装柜证据和国内装柜完成操作。 */
|
||||||
@@ -58,6 +59,16 @@ public class PlatformLoadingDeliveryController {
|
|||||||
return ApiResponse.success(service.evidence(tenantId, inboundId));
|
return ApiResponse.success(service.evidence(tenantId, inboundId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "PDA查看装柜证据照片")
|
||||||
|
@GetMapping("/{inboundId}/loading/evidence/{evidenceId}/content/pda")
|
||||||
|
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
|
||||||
|
public ResponseEntity<byte[]> pdaEvidenceContent(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @PathVariable long inboundId,
|
||||||
|
@PathVariable long evidenceId) {
|
||||||
|
FirstlegScopeGuard.platform(user, tenantId);
|
||||||
|
return service.evidenceContent(tenantId, inboundId, evidenceId);
|
||||||
|
}
|
||||||
|
|
||||||
@Operation(summary = "PDA作废错误装柜证据")
|
@Operation(summary = "PDA作废错误装柜证据")
|
||||||
@PostMapping("/{inboundId}/loading/evidence/{evidenceId}/void/pda")
|
@PostMapping("/{inboundId}/loading/evidence/{evidenceId}/void/pda")
|
||||||
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
|
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ public final class LoadingResponses {
|
|||||||
private LoadingResponses() {}
|
private LoadingResponses() {}
|
||||||
|
|
||||||
public record EvidenceResponse(Long id, Long inboundId, Long tenantId, String evidenceType,
|
public record EvidenceResponse(Long id, Long inboundId, Long tenantId, String evidenceType,
|
||||||
Long fileId, String fileName, String sealNo, String remark,
|
Long fileId, String fileName, String previewUrl, String sealNo, String remark,
|
||||||
Integer version, String status, String voidReason,
|
Integer version, String status, String voidReason,
|
||||||
Long voidedBy, String voidedByName, LocalDateTime voidedAt,
|
Long voidedBy, String voidedByName, LocalDateTime voidedAt,
|
||||||
LocalDateTime createdAt, LocalDateTime updatedAt) {}
|
LocalDateTime createdAt, LocalDateTime updatedAt) {}
|
||||||
|
|||||||
+23
-1
@@ -24,6 +24,7 @@ import feign.FeignException;
|
|||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
@@ -149,6 +150,22 @@ public class LoadingDeliveryService {
|
|||||||
return evidenceResponse(tenantId, inboundId);
|
return evidenceResponse(tenantId, inboundId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ResponseEntity<byte[]> evidenceContent(long tenantId, long inboundId, long evidenceId) {
|
||||||
|
requireLoadingStage(tenantId, inboundId);
|
||||||
|
LoadingEvidenceEntity evidence = repository.listEvidence(tenantId, inboundId).stream()
|
||||||
|
.filter(item -> Long.valueOf(evidenceId).equals(item.getId()) && isActive(item))
|
||||||
|
.findFirst().orElseThrow(() -> new BizException(ResultCode.NOT_FOUND, "装柜证据不存在"));
|
||||||
|
try {
|
||||||
|
ResponseEntity<byte[]> response = files.download(internalToken, tenantId, inboundId, evidence.getFileId());
|
||||||
|
if (response == null || response.getBody() == null) {
|
||||||
|
throw new BizException(ResultCode.NOT_FOUND, "装柜证据文件不存在");
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch (FeignException ex) {
|
||||||
|
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public LoadingActionResponse voidEvidence(long tenantId, long inboundId, long evidenceId, long operatorId,
|
public LoadingActionResponse voidEvidence(long tenantId, long inboundId, long evidenceId, long operatorId,
|
||||||
String operatorName, String key, VoidEvidenceRequest request) {
|
String operatorName, String key, VoidEvidenceRequest request) {
|
||||||
@@ -333,11 +350,16 @@ public class LoadingDeliveryService {
|
|||||||
private EvidenceListResponse evidenceResponse(long tenantId, long inboundId) {
|
private EvidenceListResponse evidenceResponse(long tenantId, long inboundId) {
|
||||||
List<EvidenceResponse> values = repository.listEvidence(tenantId, inboundId).stream().map(e ->
|
List<EvidenceResponse> values = repository.listEvidence(tenantId, inboundId).stream().map(e ->
|
||||||
new EvidenceResponse(e.getId(), e.getInboundId(), e.getTenantId(), e.getEvidenceType(), e.getFileId(),
|
new EvidenceResponse(e.getId(), e.getInboundId(), e.getTenantId(), e.getEvidenceType(), e.getFileId(),
|
||||||
e.getFileName(), e.getSealNo(), e.getRemark(), e.getVersion(), activeStatus(e), e.getVoidReason(),
|
e.getFileName(), evidencePreviewUrl(tenantId, inboundId, e.getId()), e.getSealNo(), e.getRemark(), e.getVersion(), activeStatus(e), e.getVoidReason(),
|
||||||
e.getVoidedBy(), e.getVoidedByName(), e.getVoidedAt(), e.getCreatedAt(), e.getUpdatedAt())).toList();
|
e.getVoidedBy(), e.getVoidedByName(), e.getVoidedAt(), e.getCreatedAt(), e.getUpdatedAt())).toList();
|
||||||
return new EvidenceListResponse(inboundId, values);
|
return new EvidenceListResponse(inboundId, values);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String evidencePreviewUrl(long tenantId, long inboundId, long evidenceId) {
|
||||||
|
return "/api/platform/v1/inbounds/" + inboundId + "/loading/evidence/" + evidenceId
|
||||||
|
+ "/content/pda?tenantId=" + tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
private static boolean isActive(LoadingEvidenceEntity evidence) {
|
private static boolean isActive(LoadingEvidenceEntity evidence) {
|
||||||
return evidence != null && (evidence.getStatus() == null || "ACTIVE".equals(evidence.getStatus()));
|
return evidence != null && (evidence.getStatus() == null || "ACTIVE".equals(evidence.getStatus()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,20 +233,18 @@ public class SmartLoadingService {
|
|||||||
public Plan pushPreload(long planId, String idempotencyKey, PushRequest request, long operatorId, String operatorName) {
|
public Plan pushPreload(long planId, String idempotencyKey, PushRequest request, long operatorId, String operatorName) {
|
||||||
String key = trim(idempotencyKey);
|
String key = trim(idempotencyKey);
|
||||||
if (!StringUtils.hasText(key) || key.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "必须提供有效 Idempotency-Key");
|
if (!StringUtils.hasText(key) || key.length() > 128) throw new BizException(ResultCode.BAD_REQUEST, "必须提供有效 Idempotency-Key");
|
||||||
|
String requestHash = sha256("PUSH_PRELOAD|" + planId + '|' + request.expectedVersion());
|
||||||
LoadingPlanEntity plan = repository.plan(planId); if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在");
|
LoadingPlanEntity plan = repository.plan(planId); if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "装柜计划不存在");
|
||||||
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
|
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
|
||||||
if (!StringUtils.hasText(booking.getContainerNo())) {
|
if (!StringUtils.hasText(booking.getContainerNo())) {
|
||||||
throw validation("录入真实柜号后才能推送预装清单并开启PDA作业");
|
throw validation("录入真实柜号后才能推送预装清单并开启PDA作业");
|
||||||
}
|
}
|
||||||
if (key.equals(plan.getPushIdempotencyKey())) {
|
if (key.equals(plan.getPushIdempotencyKey())) {
|
||||||
if ("EDITING".equals(plan.getPlanStatus())) {
|
requirePushReplay(planId, key, requestHash);
|
||||||
ensurePendingPreloads(repository.items(planId), operatorId, operatorName);
|
|
||||||
}
|
|
||||||
loadingModelBridge.bindAllocatedBeforeLoading(plan.getId(), operatorId, operatorName, key);
|
|
||||||
loadingPickService.beginIfReady(plan.getContainerBookingId(), operatorId, operatorName);
|
|
||||||
return view(plan, booking);
|
return view(plan, booking);
|
||||||
}
|
}
|
||||||
if (StringUtils.hasText(plan.getPushIdempotencyKey())) throw conflict("预装清单已推送,不能使用新的幂等键重复推送");
|
if (StringUtils.hasText(plan.getPushIdempotencyKey())) throw conflict("预装清单已推送,不能使用新的幂等键重复推送");
|
||||||
|
if (repository.operation(key) != null) throw conflict("Idempotency-Key已用于不同推送请求");
|
||||||
if (!"EDITING".equals(plan.getPlanStatus()) || !Objects.equals(plan.getVersion(), request.expectedVersion())) throw conflict("仅编辑中的计划可开始装柜且版本必须匹配");
|
if (!"EDITING".equals(plan.getPlanStatus()) || !Objects.equals(plan.getVersion(), request.expectedVersion())) throw conflict("仅编辑中的计划可开始装柜且版本必须匹配");
|
||||||
List<LoadingPlanItemEntity> items = repository.items(planId);
|
List<LoadingPlanItemEntity> items = repository.items(planId);
|
||||||
ensurePendingPreloads(items, operatorId, operatorName);
|
ensurePendingPreloads(items, operatorId, operatorName);
|
||||||
@@ -254,13 +252,26 @@ public class SmartLoadingService {
|
|||||||
try {
|
try {
|
||||||
if (repository.push(planId, key, request.expectedVersion(), operatorId, operatorName) != 1) throw conflict("装柜计划已被并发修改");
|
if (repository.push(planId, key, request.expectedVersion(), operatorId, operatorName) != 1) throw conflict("装柜计划已被并发修改");
|
||||||
} catch (DuplicateKeyException e) { throw conflict("幂等键已用于其他推送"); }
|
} catch (DuplicateKeyException e) { throw conflict("幂等键已用于其他推送"); }
|
||||||
history(planId, null, "PUSH_PRELOAD", "EDITING", "EDITING", key, operatorId, operatorName);
|
history(planId, null, "PUSH_PRELOAD", "EDITING", "EDITING", key,
|
||||||
|
json(values("version", request.expectedVersion())),
|
||||||
|
json(values("requestHash", requestHash)), operatorId, operatorName);
|
||||||
LoadingPlanEntity pushed = repository.plan(planId);
|
LoadingPlanEntity pushed = repository.plan(planId);
|
||||||
loadingModelBridge.bindAllocatedBeforeLoading(planId, operatorId, operatorName, key);
|
loadingModelBridge.bindAllocatedBeforeLoading(planId, operatorId, operatorName, key);
|
||||||
loadingPickService.beginIfReady(pushed.getContainerBookingId(), operatorId, operatorName);
|
loadingPickService.beginIfReady(pushed.getContainerBookingId(), operatorId, operatorName);
|
||||||
return view(pushed, requireBooking(pushed.getContainerBookingId()));
|
return view(pushed, requireBooking(pushed.getContainerBookingId()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void requirePushReplay(long planId, String key, String requestHash) {
|
||||||
|
LoadingPlanHistoryEntity history = repository.operation(key);
|
||||||
|
if (history == null
|
||||||
|
|| !Objects.equals(history.getLoadingPlanId(), planId)
|
||||||
|
|| !Objects.equals(history.getActionType(), "PUSH_PRELOAD")
|
||||||
|
|| history.getAfterSnapshot() == null
|
||||||
|
|| !sameRequestHash(history.getAfterSnapshot(), requestHash)) {
|
||||||
|
throw conflict("Idempotency-Key已用于不同推送请求");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void ensurePendingPreloads(List<LoadingPlanItemEntity> items, long operatorId, String operatorName) {
|
private void ensurePendingPreloads(List<LoadingPlanItemEntity> items, long operatorId, String operatorName) {
|
||||||
if (items.isEmpty()) throw validation("装柜明细不能为空");
|
if (items.isEmpty()) throw validation("装柜明细不能为空");
|
||||||
if (items.stream().anyMatch(item -> item.getAccountId() == null || item.getInboundId() == null)) {
|
if (items.stream().anyMatch(item -> item.getAccountId() == null || item.getInboundId() == null)) {
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ paths:
|
|||||||
application/json: {schema: {$ref: '#/components/schemas/UpsertEvidenceRequest'}}
|
application/json: {schema: {$ref: '#/components/schemas/UpsertEvidenceRequest'}}
|
||||||
responses:
|
responses:
|
||||||
'200': {description: Business envelope; ownership, type and version failures use business error codes}
|
'200': {description: Business envelope; ownership, type and version failures use business error codes}
|
||||||
|
/api/platform/v1/inbounds/{inboundId}/loading/evidence/{evidenceId}/content/pda:
|
||||||
|
get:
|
||||||
|
summary: Download one loading evidence photo for the active PDA worker
|
||||||
|
description: Uses the existing platform:firstleg:pda:work authority and validates customer, inbound and evidence ownership before proxying File service content.
|
||||||
|
parameters: [{$ref: '#/components/parameters/InboundId'}, {$ref: '#/components/parameters/EvidenceId'}, {$ref: '#/components/parameters/TenantId'}]
|
||||||
|
responses:
|
||||||
|
'200': {description: Authenticated image bytes}
|
||||||
/api/platform/v1/inbounds/{inboundId}/loading/evidence/{evidenceId}/void/pda:
|
/api/platform/v1/inbounds/{inboundId}/loading/evidence/{evidenceId}/void/pda:
|
||||||
post:
|
post:
|
||||||
summary: Void an incorrect PDA loading evidence photo
|
summary: Void an incorrect PDA loading evidence photo
|
||||||
@@ -102,6 +109,19 @@ components:
|
|||||||
EvidenceId: {name: evidenceId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
EvidenceId: {name: evidenceId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
|
||||||
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
|
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
|
||||||
schemas:
|
schemas:
|
||||||
|
EvidenceResponse:
|
||||||
|
type: object
|
||||||
|
required: [id, inboundId, tenantId, evidenceType, fileId, previewUrl, version, status]
|
||||||
|
properties:
|
||||||
|
id: {type: integer, format: int64}
|
||||||
|
inboundId: {type: integer, format: int64}
|
||||||
|
tenantId: {type: integer, format: int64}
|
||||||
|
evidenceType: {type: string, enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL]}
|
||||||
|
fileId: {type: integer, format: int64}
|
||||||
|
fileName: {type: string}
|
||||||
|
previewUrl: {type: string, description: Authenticated PDA image-content path; request with the current bearer token}
|
||||||
|
version: {type: integer}
|
||||||
|
status: {type: string}
|
||||||
UpsertEvidenceRequest:
|
UpsertEvidenceRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [expectedVersion, evidenceType, fileId]
|
required: [expectedVersion, evidenceType, fileId]
|
||||||
|
|||||||
+62
@@ -64,6 +64,54 @@ class LoadingModelBridgeServiceTest {
|
|||||||
verify(repository).touchLoadingPlan(40, 3, 7, "admin");
|
verify(repository).touchLoadingPlan(40, 3, 7, "admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void editingReplayWithEveryPairExistingIsReadOnly() {
|
||||||
|
var plan = new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 9);
|
||||||
|
when(repository.loadingPlan(40)).thenReturn(plan);
|
||||||
|
when(repository.allocatedScopes(40)).thenReturn(List.of(
|
||||||
|
new AllocationScope(10L, 20L, 8), new AllocationScope(11L, 21L, 4)));
|
||||||
|
when(repository.byPair(10, 20, 40)).thenReturn(new PreloadLoadingPlanLinkEntity());
|
||||||
|
when(repository.byPair(11, 21, 40)).thenReturn(new PreloadLoadingPlanLinkEntity());
|
||||||
|
Link first = link("PENDING", 3, "EDITING", 9, 8);
|
||||||
|
when(repository.byLoadingPlan(40)).thenReturn(List.of(first));
|
||||||
|
|
||||||
|
assertThat(service.bindAllocatedBeforeLoading(40, 7, "admin", "push-1"))
|
||||||
|
.containsExactly(first);
|
||||||
|
|
||||||
|
verify(repository, never()).preload(anyLong(), anyLong());
|
||||||
|
verify(repository, never()).insert(any());
|
||||||
|
verify(repository, never()).touchPreload(anyLong(), anyLong(), anyLong(), anyInt(), anyLong(), anyString());
|
||||||
|
verify(repository, never()).touchLoadingPlan(anyLong(), anyInt(), anyLong(), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void editingReplaySkipsExistingPairAndCreatesOnlyMissingPair() {
|
||||||
|
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 3));
|
||||||
|
when(repository.allocatedScopes(40)).thenReturn(List.of(
|
||||||
|
new AllocationScope(10L, 20L, 8), new AllocationScope(11L, 21L, 4)));
|
||||||
|
when(repository.byPair(10, 20, 40)).thenReturn(new PreloadLoadingPlanLinkEntity());
|
||||||
|
when(repository.byPair(11, 21, 40)).thenReturn(null);
|
||||||
|
when(repository.preload(11, 21)).thenReturn(new PreloadSnapshot(31L, 11L, 21L, "PENDING", 5));
|
||||||
|
when(repository.loadingPlan(40)).thenReturn(
|
||||||
|
new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 3),
|
||||||
|
new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 3));
|
||||||
|
when(repository.allocatedPackages(11, 21, 40)).thenReturn(4);
|
||||||
|
when(repository.touchPreload(31, 11, 21, 5, 7, "admin")).thenReturn(1);
|
||||||
|
when(repository.touchLoadingPlan(40, 3, 7, "admin")).thenReturn(1);
|
||||||
|
Link linked = link("PENDING", 6, "EDITING", 4, 4);
|
||||||
|
when(repository.byInbound(11, 21)).thenReturn(List.of(linked));
|
||||||
|
when(repository.byLoadingPlan(40)).thenReturn(List.of(linked));
|
||||||
|
|
||||||
|
assertThat(service.bindAllocatedBeforeLoading(40, 7, "admin", "push-1"))
|
||||||
|
.containsExactly(linked);
|
||||||
|
|
||||||
|
verify(repository, never()).preload(10, 20);
|
||||||
|
verify(repository, times(2)).preload(11, 21);
|
||||||
|
verify(repository, times(1)).insert(any());
|
||||||
|
verify(repository, times(1)).touchPreload(31, 11, 21, 5, 7, "admin");
|
||||||
|
verify(repository, times(1)).touchLoadingPlan(40, 3, 7, "admin");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void refusesPostHocBindingAfterLoadingStarts() {
|
void refusesPostHocBindingAfterLoadingStarts() {
|
||||||
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "LOADING", 4));
|
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "LOADING", 4));
|
||||||
@@ -104,6 +152,20 @@ class LoadingModelBridgeServiceTest {
|
|||||||
verify(repository, never()).byInbound(10, 20);
|
verify(repository, never()).byInbound(10, 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void publicBindSameKeyWithChangedVersionsStillConflicts() {
|
||||||
|
var prior = new PreloadLoadingPlanLinkEntity();
|
||||||
|
prior.setRequestHash("prior-request-hash");
|
||||||
|
when(repository.byIdempotency(7, "manual-key")).thenReturn(prior);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.bind(10, 20, 40, 7, "admin", "manual-key",
|
||||||
|
new BindRequest(3, 4)))
|
||||||
|
.hasMessageContaining("Idempotency-Key已用于不同关联请求");
|
||||||
|
|
||||||
|
verify(repository, never()).byPair(anyLong(), anyLong(), anyLong());
|
||||||
|
verify(repository, never()).insert(any());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void terminalGateRequiresEveryLinkedContainerPlanCompleted() {
|
void terminalGateRequiresEveryLinkedContainerPlanCompleted() {
|
||||||
when(repository.byInbound(10, 20)).thenReturn(List.of(
|
when(repository.byInbound(10, 20)).thenReturn(List.of(
|
||||||
|
|||||||
+2
@@ -16,11 +16,13 @@ class LoadingDeliveryOpenApiContractTest {
|
|||||||
assertThat(yaml)
|
assertThat(yaml)
|
||||||
.contains("/api/platform/v1/inbounds/{inboundId}/loading/evidence:")
|
.contains("/api/platform/v1/inbounds/{inboundId}/loading/evidence:")
|
||||||
.contains("/api/platform/v1/inbounds/{inboundId}/loading/evidence/pda:")
|
.contains("/api/platform/v1/inbounds/{inboundId}/loading/evidence/pda:")
|
||||||
|
.contains("/api/platform/v1/inbounds/{inboundId}/loading/evidence/{evidenceId}/content/pda:")
|
||||||
.contains("/api/platform/v1/inbounds/{inboundId}/loading/evidence/{evidenceId}/void:")
|
.contains("/api/platform/v1/inbounds/{inboundId}/loading/evidence/{evidenceId}/void:")
|
||||||
.contains("/api/platform/v1/inbounds/{inboundId}/loading/picking/start:")
|
.contains("/api/platform/v1/inbounds/{inboundId}/loading/picking/start:")
|
||||||
.contains("/api/platform/v1/inbounds/{inboundId}/loading/picking/complete:")
|
.contains("/api/platform/v1/inbounds/{inboundId}/loading/picking/complete:")
|
||||||
.contains("/api/platform/v1/inbounds/{inboundId}/loading/complete:")
|
.contains("/api/platform/v1/inbounds/{inboundId}/loading/complete:")
|
||||||
.contains("required: [expectedVersion, evidenceType, fileId]")
|
.contains("required: [expectedVersion, evidenceType, fileId]")
|
||||||
|
.contains("previewUrl:")
|
||||||
.contains("enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL]")
|
.contains("enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL]")
|
||||||
.contains("同类型可多次追加")
|
.contains("同类型可多次追加")
|
||||||
.contains("Required when evidenceType is SEAL")
|
.contains("Required when evidenceType is SEAL")
|
||||||
|
|||||||
+5
@@ -30,6 +30,11 @@ class PlatformLoadingDeliveryControllerContractTest {
|
|||||||
"platform:firstleg:pda:work", UpsertEvidenceRequest.class);
|
"platform:firstleg:pda:work", UpsertEvidenceRequest.class);
|
||||||
get("pdaEvidence", "/{inboundId}/loading/evidence/pda",
|
get("pdaEvidence", "/{inboundId}/loading/evidence/pda",
|
||||||
"platform:firstleg:pda:work");
|
"platform:firstleg:pda:work");
|
||||||
|
Method pdaContent = PlatformLoadingDeliveryController.class.getDeclaredMethod("pdaEvidenceContent",
|
||||||
|
AuthenticatedUser.class, long.class, long.class, long.class);
|
||||||
|
assertThat(pdaContent.getAnnotation(GetMapping.class).value())
|
||||||
|
.containsExactly("/{inboundId}/loading/evidence/{evidenceId}/content/pda");
|
||||||
|
assertAuthority(pdaContent, "platform:firstleg:pda:work");
|
||||||
Method pdaVoid = PlatformLoadingDeliveryController.class.getDeclaredMethod("pdaVoidEvidence",
|
Method pdaVoid = PlatformLoadingDeliveryController.class.getDeclaredMethod("pdaVoidEvidence",
|
||||||
AuthenticatedUser.class, long.class, long.class, long.class, String.class, VoidEvidenceRequest.class);
|
AuthenticatedUser.class, long.class, long.class, long.class, String.class, VoidEvidenceRequest.class);
|
||||||
assertThat(pdaVoid.getAnnotation(PostMapping.class).value())
|
assertThat(pdaVoid.getAnnotation(PostMapping.class).value())
|
||||||
|
|||||||
+20
@@ -24,6 +24,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
|||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
@@ -54,6 +55,24 @@ class LoadingDeliveryServiceTest {
|
|||||||
token.set(service, "token");
|
token.set(service, "token");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void returnsAuthenticatedPreviewUrlAndProxiesOwnedEvidenceContent() {
|
||||||
|
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
|
||||||
|
LoadingEvidenceEntity target = evidence("PHOTO_STEP_1", 11L, null);
|
||||||
|
target.setId(99L); target.setInboundId(20L); target.setTenantId(10L); target.setStatus("ACTIVE");
|
||||||
|
when(repository.listEvidence(10, 20)).thenReturn(List.of(target));
|
||||||
|
when(files.download("token", 10, 20, 11)).thenReturn(ResponseEntity.ok(new byte[]{1, 2, 3}));
|
||||||
|
|
||||||
|
var listed = service.evidence(10, 20);
|
||||||
|
var content = service.evidenceContent(10, 20, 99);
|
||||||
|
|
||||||
|
assertThat(listed.evidences()).singleElement().satisfies(item ->
|
||||||
|
assertThat(item.previewUrl()).isEqualTo(
|
||||||
|
"/api/platform/v1/inbounds/20/loading/evidence/99/content/pda?tenantId=10"));
|
||||||
|
assertThat(content.getBody()).containsExactly(1, 2, 3);
|
||||||
|
verify(files).download("token", 10, 20, 11);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void completesLoadingOnlyAfterPickingAndFiveEvidenceFiles() {
|
void completesLoadingOnlyAfterPickingAndFiveEvidenceFiles() {
|
||||||
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
|
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
|
||||||
@@ -297,6 +316,7 @@ class LoadingDeliveryServiceTest {
|
|||||||
|
|
||||||
private static LoadingEvidenceEntity evidence(String type, Long fileId, String sealNo) {
|
private static LoadingEvidenceEntity evidence(String type, Long fileId, String sealNo) {
|
||||||
LoadingEvidenceEntity value = new LoadingEvidenceEntity();
|
LoadingEvidenceEntity value = new LoadingEvidenceEntity();
|
||||||
|
value.setId(fileId); value.setInboundId(20L); value.setTenantId(10L);
|
||||||
value.setEvidenceType(type); value.setFileId(fileId); value.setSealNo(sealNo);
|
value.setEvidenceType(type); value.setFileId(fileId); value.setSealNo(sealNo);
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -391,6 +391,11 @@ class SmartLoadingServiceTest {
|
|||||||
|
|
||||||
assertThat(result.status()).isEqualTo("EDITING");
|
assertThat(result.status()).isEqualTo("EDITING");
|
||||||
verify(repository).push(1, "key", 3, 7, "u");
|
verify(repository).push(1, "key", 3, 7, "u");
|
||||||
|
ArgumentCaptor<LoadingPlanHistoryEntity> history = ArgumentCaptor.forClass(LoadingPlanHistoryEntity.class);
|
||||||
|
verify(repository).insert(history.capture());
|
||||||
|
assertThat(history.getValue().getActionType()).isEqualTo("PUSH_PRELOAD");
|
||||||
|
assertThat(history.getValue().getAfterSnapshot())
|
||||||
|
.contains(sha256("PUSH_PRELOAD|1|3"));
|
||||||
var ordered = inOrder(splitPreloadService, loadingModelBridge, loadingPickService);
|
var ordered = inOrder(splitPreloadService, loadingModelBridge, loadingPickService);
|
||||||
ordered.verify(splitPreloadService).ensurePendingForLoadingAllocation(9L, 10L, 7L, "u");
|
ordered.verify(splitPreloadService).ensurePendingForLoadingAllocation(9L, 10L, 7L, "u");
|
||||||
ordered.verify(loadingModelBridge).bindAllocatedBeforeLoading(1, 7, "u", "key");
|
ordered.verify(loadingModelBridge).bindAllocatedBeforeLoading(1, 7, "u", "key");
|
||||||
@@ -398,19 +403,77 @@ class SmartLoadingServiceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void editingPushReplayEnsuresPendingPreloadBeforePda() {
|
void exactPushReplayIsReadOnly() {
|
||||||
LoadingPlanEntity pushed = plan("EDITING", 4);
|
LoadingPlanEntity pushed = plan("EDITING", 4);
|
||||||
pushed.setPushIdempotencyKey("key");
|
pushed.setPushIdempotencyKey("key");
|
||||||
when(repository.plan(1)).thenReturn(pushed);
|
when(repository.plan(1)).thenReturn(pushed);
|
||||||
when(repository.items(1)).thenReturn(List.of(item(8, 9, 20, 1)));
|
when(repository.booking(99)).thenReturn(booking());
|
||||||
|
when(repository.operation("key"))
|
||||||
|
.thenReturn(history(1, null, "PUSH_PRELOAD", sha256("PUSH_PRELOAD|1|3")));
|
||||||
|
|
||||||
|
Plan replay = service.pushPreload(1, "key", new PushRequest(3), 7, "u");
|
||||||
|
|
||||||
|
assertThat(replay.version()).isEqualTo(4);
|
||||||
|
verify(splitPreloadService, never()).ensurePendingForLoadingAllocation(anyLong(), anyLong(), anyLong(), anyString());
|
||||||
|
verify(loadingModelBridge, never()).bindAllocatedBeforeLoading(anyLong(), anyLong(), anyString(), anyString());
|
||||||
|
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
|
||||||
|
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
|
||||||
|
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void samePushKeyWithDifferentRequestIsRejectedWithoutWrites() {
|
||||||
|
LoadingPlanEntity pushed = plan("EDITING", 4);
|
||||||
|
pushed.setPushIdempotencyKey("key");
|
||||||
|
when(repository.plan(1)).thenReturn(pushed);
|
||||||
|
when(repository.booking(99)).thenReturn(booking());
|
||||||
|
when(repository.operation("key"))
|
||||||
|
.thenReturn(history(1, null, "PUSH_PRELOAD", sha256("PUSH_PRELOAD|1|3")));
|
||||||
|
|
||||||
|
BizException error = catchThrowableOfType(
|
||||||
|
() -> service.pushPreload(1, "key", new PushRequest(4), 7, "u"), BizException.class);
|
||||||
|
|
||||||
|
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
|
||||||
|
assertThat(error.getMessage()).contains("不同推送请求");
|
||||||
|
verify(repository, never()).items(anyLong());
|
||||||
|
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
|
||||||
|
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
|
||||||
|
verifyNoPushSideEffects();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void newPushKeyAfterSuccessfulPushIsRejectedWithoutWrites() {
|
||||||
|
LoadingPlanEntity pushed = plan("EDITING", 4);
|
||||||
|
pushed.setPushIdempotencyKey("first-key");
|
||||||
|
when(repository.plan(1)).thenReturn(pushed);
|
||||||
when(repository.booking(99)).thenReturn(booking());
|
when(repository.booking(99)).thenReturn(booking());
|
||||||
|
|
||||||
service.pushPreload(1, "key", new PushRequest(4), 7, "u");
|
BizException error = catchThrowableOfType(
|
||||||
|
() -> service.pushPreload(1, "new-key", new PushRequest(3), 7, "u"), BizException.class);
|
||||||
|
|
||||||
verify(splitPreloadService).ensurePendingForLoadingAllocation(9L, 10L, 7L, "u");
|
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
|
||||||
verify(loadingModelBridge).bindAllocatedBeforeLoading(1, 7, "u", "key");
|
assertThat(error.getMessage()).contains("新的幂等键");
|
||||||
verify(loadingPickService).beginIfReady(99, 7, "u");
|
|
||||||
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
|
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
|
||||||
|
verifyNoPushSideEffects();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pushKeyUsedByAnotherPlanOperationIsRejectedBeforeWrites() {
|
||||||
|
LoadingPlanEntity editing = plan("EDITING", 3);
|
||||||
|
when(repository.plan(1)).thenReturn(editing);
|
||||||
|
when(repository.booking(99)).thenReturn(booking());
|
||||||
|
when(repository.operation("used-key"))
|
||||||
|
.thenReturn(history(1, 8L, "REMOVE_ITEM", sha256("DELETE|1|8|3|0")));
|
||||||
|
|
||||||
|
BizException error = catchThrowableOfType(
|
||||||
|
() -> service.pushPreload(1, "used-key", new PushRequest(3), 7, "u"), BizException.class);
|
||||||
|
|
||||||
|
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
|
||||||
|
assertThat(error.getMessage()).contains("不同推送请求");
|
||||||
|
verify(repository, never()).items(anyLong());
|
||||||
|
verify(repository, never()).push(anyLong(), anyString(), anyInt(), anyLong(), anyString());
|
||||||
|
verify(repository, never()).insert(any(LoadingPlanHistoryEntity.class));
|
||||||
|
verifyNoPushSideEffects();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -490,6 +553,12 @@ class SmartLoadingServiceTest {
|
|||||||
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
|
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void verifyNoPushSideEffects() {
|
||||||
|
verify(splitPreloadService, never()).ensurePendingForLoadingAllocation(anyLong(), anyLong(), anyLong(), anyString());
|
||||||
|
verify(loadingModelBridge, never()).bindAllocatedBeforeLoading(anyLong(), anyLong(), anyString(), anyString());
|
||||||
|
verify(loadingPickService, never()).beginIfReady(anyLong(), anyLong(), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
private void stubAssignment() {
|
private void stubAssignment() {
|
||||||
LoadingPlanEntity plan = plan("EDITING", 0);
|
LoadingPlanEntity plan = plan("EDITING", 0);
|
||||||
when(repository.plan(1)).thenReturn(plan, plan("EDITING", 1));
|
when(repository.plan(1)).thenReturn(plan, plan("EDITING", 1));
|
||||||
|
|||||||
@@ -387,16 +387,19 @@ export async function run(context) {
|
|||||||
const releasedContainer = context.apiSuccess(MODULE, '放舱后回查货柜运输版本', releasedContainerResponse, '', OPTIONS)
|
const releasedContainer = context.apiSuccess(MODULE, '放舱后回查货柜运输版本', releasedContainerResponse, '', OPTIONS)
|
||||||
|
|
||||||
const pushKey = `${context.marker}-container-push`.slice(0, 128)
|
const pushKey = `${context.marker}-container-push`.slice(0, 128)
|
||||||
const pushResponse = await request(context, 'POST', `${SMART}/plans/${planId}/push-preload`,
|
const pushRequest = Object.freeze({
|
||||||
{ expectedVersion: Number(assigned.version) }, { 'Idempotency-Key': pushKey })
|
path: `${SMART}/plans/${planId}/push-preload`,
|
||||||
|
body: Object.freeze({ expectedVersion: Number(assigned.version) }),
|
||||||
|
headers: Object.freeze({ 'Idempotency-Key': pushKey }),
|
||||||
|
})
|
||||||
|
const pushResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers)
|
||||||
const pushed = context.apiSuccess(MODULE, '柜号与 RELEASED 订舱就绪后推送预装计划', pushResponse, '', OPTIONS)
|
const pushed = context.apiSuccess(MODULE, '柜号与 RELEASED 订舱就绪后推送预装计划', pushResponse, '', OPTIONS)
|
||||||
context.assert(MODULE, '推送后计划保持 EDITING 且版本递增', pushResponse,
|
context.assert(MODULE, '推送后计划保持 EDITING 且版本递增', pushResponse,
|
||||||
pushed?.status === 'EDITING' && Number(pushed?.version) === Number(assigned.version) + 1,
|
pushed?.status === 'EDITING' && Number(pushed?.version) === Number(assigned.version) + 1,
|
||||||
`status=${pushed?.status || 'missing'}, version=${pushed?.version ?? 'missing'}`, OPTIONS)
|
`status=${pushed?.status || 'missing'}, version=${pushed?.version ?? 'missing'}`, OPTIONS)
|
||||||
if (pushed?.status !== 'EDITING') return
|
if (pushed?.status !== 'EDITING') return
|
||||||
|
|
||||||
const pushReplayResponse = await request(context, 'POST', `${SMART}/plans/${planId}/push-preload`,
|
const pushReplayResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers)
|
||||||
{ expectedVersion: Number(assigned.version) }, { 'Idempotency-Key': pushKey })
|
|
||||||
const pushReplay = context.apiSuccess(MODULE, '推送预装 Idempotency-Key 重放', pushReplayResponse, '', OPTIONS)
|
const pushReplay = context.apiSuccess(MODULE, '推送预装 Idempotency-Key 重放', pushReplayResponse, '', OPTIONS)
|
||||||
context.assert(MODULE, '推送重放保持同一 EDITING 计划', pushReplayResponse,
|
context.assert(MODULE, '推送重放保持同一 EDITING 计划', pushReplayResponse,
|
||||||
id(pushReplay?.id) === planId && pushReplay?.status === 'EDITING'
|
id(pushReplay?.id) === planId && pushReplay?.status === 'EDITING'
|
||||||
@@ -476,6 +479,15 @@ export async function run(context) {
|
|||||||
context.addStep(MODULE, 'PDA 任务仓库隔离拒绝', wrongWarehouseResponse,
|
context.addStep(MODULE, 'PDA 任务仓库隔离拒绝', wrongWarehouseResponse,
|
||||||
failure(wrongWarehouseResponse), `HTTP=${wrongWarehouseResponse.http}, code=${codeOf(wrongWarehouseResponse)}`, OPTIONS)
|
failure(wrongWarehouseResponse), `HTTP=${wrongWarehouseResponse.http}, code=${codeOf(wrongWarehouseResponse)}`, OPTIONS)
|
||||||
|
|
||||||
|
const beforeClaimPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`)
|
||||||
|
const beforeClaimPlan = context.apiSuccess(MODULE, '领取任务前读取装柜计划即时版本',
|
||||||
|
beforeClaimPlanResponse, '', OPTIONS)
|
||||||
|
const beforeClaimPlanVersion = Number(beforeClaimPlan?.version)
|
||||||
|
context.assert(MODULE, '领取任务前计划保持 EDITING 且版本有效', beforeClaimPlanResponse,
|
||||||
|
beforeClaimPlan?.status === 'EDITING' && Number.isInteger(beforeClaimPlanVersion),
|
||||||
|
`status=${beforeClaimPlan?.status || 'missing'}, version=${beforeClaimPlan?.version ?? 'missing'}`, OPTIONS)
|
||||||
|
if (beforeClaimPlan?.status !== 'EDITING' || !Number.isInteger(beforeClaimPlanVersion)) return
|
||||||
|
|
||||||
const claimResponse = await request(context, 'POST', `${PDA}/tasks/${taskId}/claim`, {
|
const claimResponse = await request(context, 'POST', `${PDA}/tasks/${taskId}/claim`, {
|
||||||
expectedAssignmentVersion: Number(task?.assignment?.version || 0),
|
expectedAssignmentVersion: Number(task?.assignment?.version || 0),
|
||||||
operationId: `${context.marker}-pda-claim`.slice(0, 128),
|
operationId: `${context.marker}-pda-claim`.slice(0, 128),
|
||||||
@@ -489,7 +501,7 @@ export async function run(context) {
|
|||||||
const claimedPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`)
|
const claimedPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`)
|
||||||
const claimedPlan = context.apiSuccess(MODULE, '领取后回查装柜计划', claimedPlanResponse, '', OPTIONS)
|
const claimedPlan = context.apiSuccess(MODULE, '领取后回查装柜计划', claimedPlanResponse, '', OPTIONS)
|
||||||
context.assert(MODULE, '领取任务后计划进入 LOADING 且版本递增', claimedPlanResponse,
|
context.assert(MODULE, '领取任务后计划进入 LOADING 且版本递增', claimedPlanResponse,
|
||||||
claimedPlan?.status === 'LOADING' && Number(claimedPlan?.version) === Number(pushed?.version) + 1,
|
claimedPlan?.status === 'LOADING' && Number(claimedPlan?.version) > beforeClaimPlanVersion,
|
||||||
`status=${claimedPlan?.status || 'missing'}, version=${claimedPlan?.version ?? 'missing'}`, OPTIONS)
|
`status=${claimedPlan?.status || 'missing'}, version=${claimedPlan?.version ?? 'missing'}`, OPTIONS)
|
||||||
|
|
||||||
const inventoryResponse = await context.request('GET',
|
const inventoryResponse = await context.request('GET',
|
||||||
|
|||||||
@@ -211,9 +211,11 @@ export async function run(context) {
|
|||||||
}, { 'Idempotency-Key': `${context.marker}-p3-smart`.slice(0, 128) })
|
}, { 'Idempotency-Key': `${context.marker}-p3-smart`.slice(0, 128) })
|
||||||
const smartData = context.apiSuccess(MODULE, '智能分柜并生成预装清单', smart, `inboundId=${inboundId}`, OPTIONS)
|
const smartData = context.apiSuccess(MODULE, '智能分柜并生成预装清单', smart, `inboundId=${inboundId}`, OPTIONS)
|
||||||
const planId = id(smartData?.preloadPlan?.id)
|
const planId = id(smartData?.preloadPlan?.id)
|
||||||
|
const smartPreloadVersion = Number(smartData?.preloadPlan?.version)
|
||||||
context.assert(MODULE, '智能分柜预装计划 PENDING、版本和明细正确', smart,
|
context.assert(MODULE, '智能分柜预装计划 PENDING、版本和明细正确', smart,
|
||||||
smartData?.mainStatus === 'WAIT_SPLIT' && Boolean(planId) && smartData?.preloadPlan?.status === 'PENDING'
|
smartData?.mainStatus === 'WAIT_SPLIT' && Boolean(planId) && smartData?.preloadPlan?.status === 'PENDING'
|
||||||
&& Number(smartData?.preloadPlan?.version) === 0 && smartData?.preloadPlan?.items?.length === 1,
|
&& Number.isInteger(smartPreloadVersion) && smartPreloadVersion >= 0
|
||||||
|
&& smartData?.preloadPlan?.items?.length === 1,
|
||||||
`mainStatus=${smartData?.mainStatus || 'missing'}, planId=${planId || 'missing'}, status=${smartData?.preloadPlan?.status || 'missing'}`, OPTIONS)
|
`mainStatus=${smartData?.mainStatus || 'missing'}, planId=${planId || 'missing'}, status=${smartData?.preloadPlan?.status || 'missing'}`, OPTIONS)
|
||||||
if (!planId) return
|
if (!planId) return
|
||||||
|
|
||||||
@@ -225,16 +227,19 @@ export async function run(context) {
|
|||||||
&& replay.body?.data?.preloadPlan?.items?.length === 1,
|
&& replay.body?.data?.preloadPlan?.items?.length === 1,
|
||||||
`HTTP=${replay.http}, code=${codeOf(replay)}, planId=${id(replay.body?.data?.preloadPlan?.id) || 'missing'}`, OPTIONS)
|
`HTTP=${replay.http}, code=${codeOf(replay)}, planId=${id(replay.body?.data?.preloadPlan?.id) || 'missing'}`, OPTIONS)
|
||||||
|
|
||||||
|
const bindVersions = Object.freeze({
|
||||||
|
expectedPreloadVersion: smartPreloadVersion,
|
||||||
|
expectedLoadingPlanVersion: Number(assignedLoadingPlan.version),
|
||||||
|
})
|
||||||
const bindResponse = await request(context, 'POST',
|
const bindResponse = await request(context, 'POST',
|
||||||
`${base}/preload/loading-plans/${loadingPlanId}?tenantId=${encodeURIComponent(tenantId)}`, {
|
`${base}/preload/loading-plans/${loadingPlanId}?tenantId=${encodeURIComponent(tenantId)}`,
|
||||||
expectedPreloadVersion: Number(smartData.preloadPlan.version),
|
bindVersions, { 'Idempotency-Key': `${context.marker}-bridge-bind`.slice(0, 128) })
|
||||||
expectedLoadingPlanVersion: Number(assignedLoadingPlan.version),
|
|
||||||
}, { 'Idempotency-Key': `${context.marker}-bridge-bind`.slice(0, 128) })
|
|
||||||
const link = context.apiSuccess(MODULE, '原子关联入库预装与柜次装柜计划', bindResponse, '', OPTIONS)
|
const link = context.apiSuccess(MODULE, '原子关联入库预装与柜次装柜计划', bindResponse, '', OPTIONS)
|
||||||
context.assert(MODULE, '桥接双版本递增且关联归属唯一', bindResponse,
|
context.assert(MODULE, '桥接双版本递增且关联归属唯一', bindResponse,
|
||||||
id(link?.inboundId) === inboundId && id(link?.loadingPlanId) === loadingPlanId
|
id(link?.inboundId) === inboundId && id(link?.loadingPlanId) === loadingPlanId
|
||||||
&& id(link?.preloadPlanId) === planId && Number(link?.allocatedPackageCount) === 1
|
&& id(link?.preloadPlanId) === planId && Number(link?.allocatedPackageCount) === 1
|
||||||
&& Number(link?.preloadVersion) === 1 && Number(link?.loadingPlanVersion) === 2,
|
&& Number(link?.preloadVersion) > bindVersions.expectedPreloadVersion
|
||||||
|
&& Number(link?.loadingPlanVersion) > bindVersions.expectedLoadingPlanVersion,
|
||||||
`linkId=${id(link?.id) || 'missing'}, preloadVersion=${link?.preloadVersion ?? 'missing'}, loadingVersion=${link?.loadingPlanVersion ?? 'missing'}`, OPTIONS)
|
`linkId=${id(link?.id) || 'missing'}, preloadVersion=${link?.preloadVersion ?? 'missing'}, loadingVersion=${link?.loadingPlanVersion ?? 'missing'}`, OPTIONS)
|
||||||
if (!link?.id) return
|
if (!link?.id) return
|
||||||
|
|
||||||
@@ -606,7 +611,8 @@ export async function run(context) {
|
|||||||
{ expectedVersion: Number(link.preloadVersion) }, { 'Idempotency-Key': `${context.marker}-p3-confirming`.slice(0, 128) })
|
{ expectedVersion: Number(link.preloadVersion) }, { 'Idempotency-Key': `${context.marker}-p3-confirming`.slice(0, 128) })
|
||||||
const confirmingData = context.apiSuccess(MODULE, '提交预装清单确认', confirming, '', OPTIONS)
|
const confirmingData = context.apiSuccess(MODULE, '提交预装清单确认', confirming, '', OPTIONS)
|
||||||
context.assert(MODULE, '预装清单进入 CONFIRMING 且版本递增', confirming,
|
context.assert(MODULE, '预装清单进入 CONFIRMING 且版本递增', confirming,
|
||||||
confirmingData?.preloadPlan?.status === 'CONFIRMING' && Number(confirmingData?.preloadPlan?.version) === 2,
|
confirmingData?.preloadPlan?.status === 'CONFIRMING'
|
||||||
|
&& Number(confirmingData?.preloadPlan?.version) > Number(link.preloadVersion),
|
||||||
`status=${confirmingData?.preloadPlan?.status || 'missing'}, version=${confirmingData?.preloadPlan?.version ?? 'missing'}`, OPTIONS)
|
`status=${confirmingData?.preloadPlan?.status || 'missing'}, version=${confirmingData?.preloadPlan?.version ?? 'missing'}`, OPTIONS)
|
||||||
if (confirmingData?.preloadPlan?.status !== 'CONFIRMING') return
|
if (confirmingData?.preloadPlan?.status !== 'CONFIRMING') return
|
||||||
|
|
||||||
@@ -618,9 +624,9 @@ export async function run(context) {
|
|||||||
: '预装确认推进主状态 LOADING 和计划 CONFIRMED', confirm,
|
: '预装确认推进主状态 LOADING 和计划 CONFIRMED', confirm,
|
||||||
directPdaCompletion
|
directPdaCompletion
|
||||||
? confirmed?.mainStatus === 'LOADING' && confirmed?.preloadPlan?.status === 'CONFIRMING'
|
? confirmed?.mainStatus === 'LOADING' && confirmed?.preloadPlan?.status === 'CONFIRMING'
|
||||||
&& Number(confirmed?.preloadPlan?.version) === 2
|
&& Number(confirmed?.preloadPlan?.version) === Number(confirmingData.preloadPlan.version)
|
||||||
: confirmed?.mainStatus === 'LOADING' && confirmed?.preloadPlan?.status === 'CONFIRMED'
|
: confirmed?.mainStatus === 'LOADING' && confirmed?.preloadPlan?.status === 'CONFIRMED'
|
||||||
&& Number(confirmed?.preloadPlan?.version) === 3,
|
&& Number(confirmed?.preloadPlan?.version) > Number(confirmingData.preloadPlan.version),
|
||||||
`mainStatus=${confirmed?.mainStatus || 'missing'}, planStatus=${confirmed?.preloadPlan?.status || 'missing'}, version=${confirmed?.preloadPlan?.version ?? 'missing'}`, OPTIONS)
|
`mainStatus=${confirmed?.mainStatus || 'missing'}, planStatus=${confirmed?.preloadPlan?.status || 'missing'}, version=${confirmed?.preloadPlan?.version ?? 'missing'}`, OPTIONS)
|
||||||
if (confirmed?.mainStatus !== 'LOADING') return
|
if (confirmed?.mainStatus !== 'LOADING') return
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ test('bridge loading starts through a real PDA batch before preload confirmation
|
|||||||
assert.doesNotMatch(value, /关联柜次计划进入 LOADING/)
|
assert.doesNotMatch(value, /关联柜次计划进入 LOADING/)
|
||||||
assert.match(value, /confirmed\?\.mainStatus === 'LOADING'/)
|
assert.match(value, /confirmed\?\.mainStatus === 'LOADING'/)
|
||||||
assert.match(value, /confirmed\?\.preloadPlan\?\.status === 'CONFIRMING'/)
|
assert.match(value, /confirmed\?\.preloadPlan\?\.status === 'CONFIRMING'/)
|
||||||
assert.match(value, /Number\(confirmed\?\.preloadPlan\?\.version\) === 2/)
|
assert.match(value, /Number\(confirmed\?\.preloadPlan\?\.version\) === Number\(confirmingData\.preloadPlan\.version\)/)
|
||||||
assert.match(value, /const missingContainerPushRejected = failure\(missingContainerPushResponse\)[\s\S]*if \(!missingContainerPushRejected\) return/)
|
assert.match(value, /const missingContainerPushRejected = failure\(missingContainerPushResponse\)[\s\S]*if \(!missingContainerPushRejected\) return/)
|
||||||
assert.match(value, /const missingContainerTaskAbsent =[\s\S]*if \(!missingContainerTaskAbsent\) return/)
|
assert.match(value, /const missingContainerTaskAbsent =[\s\S]*if \(!missingContainerTaskAbsent\) return/)
|
||||||
assert.match(value, /version: transportVersion/)
|
assert.match(value, /version: transportVersion/)
|
||||||
@@ -312,6 +312,21 @@ test('container loading enforces channel isolation and stable PDA identity acros
|
|||||||
assert.doesNotMatch(batch, /containerNo:/)
|
assert.doesNotMatch(batch, /containerNo:/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('preload and loading versions use immediate operation baselines and exact push replay', () => {
|
||||||
|
const container = source('./phase3-container-loading.mjs')
|
||||||
|
assert.match(container, /const pushRequest = Object\.freeze\(\{[\s\S]*path:[\s\S]*body: Object\.freeze[\s\S]*headers: Object\.freeze/)
|
||||||
|
assert.equal((container.match(/request\(context, 'POST', pushRequest\.path, pushRequest\.body, pushRequest\.headers\)/g) || []).length, 2)
|
||||||
|
assert.match(container, /const beforeClaimPlanVersion = Number\(beforeClaimPlan\?\.version\)/)
|
||||||
|
assert.match(container, /Number\(claimedPlan\?\.version\) > beforeClaimPlanVersion/)
|
||||||
|
|
||||||
|
const loading = source('./phase3-loading.mjs')
|
||||||
|
assert.match(loading, /Number\.isInteger\(smartPreloadVersion\) && smartPreloadVersion >= 0/)
|
||||||
|
assert.match(loading, /Number\(link\?\.preloadVersion\) > bindVersions\.expectedPreloadVersion/)
|
||||||
|
assert.match(loading, /Number\(link\?\.loadingPlanVersion\) > bindVersions\.expectedLoadingPlanVersion/)
|
||||||
|
assert.match(loading, /Number\(confirmingData\?\.preloadPlan\?\.version\) > Number\(link\.preloadVersion\)/)
|
||||||
|
assert.doesNotMatch(loading, /Number\((?:smartData|link|confirmingData|confirmed)[^\n]*version\) === [0-9]/)
|
||||||
|
})
|
||||||
|
|
||||||
test('PDA completion acceptance verifies immutable BATCH and COMPLETE_LOADING audit with operator metadata', () => {
|
test('PDA completion acceptance verifies immutable BATCH and COMPLETE_LOADING audit with operator metadata', () => {
|
||||||
const container = source('./phase3-container-loading.mjs')
|
const container = source('./phase3-container-loading.mjs')
|
||||||
const convergence = source('./phase3-pda-convergence.mjs')
|
const convergence = source('./phase3-pda-convergence.mjs')
|
||||||
|
|||||||
Reference in New Issue
Block a user