优化
This commit is contained in:
+6
@@ -90,6 +90,12 @@ public class LoadingModelBridgeService {
|
||||
throw conflict("PDA开始前的预装关联不完整,禁止在LOADING后补关联");
|
||||
}
|
||||
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 currentPlan = repository.loadingPlan(loadingPlanId);
|
||||
if (preload == null || currentPlan == null) {
|
||||
|
||||
+11
@@ -18,6 +18,7 @@ import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/** 平台端装柜证据和国内装柜完成操作。 */
|
||||
@@ -58,6 +59,16 @@ public class PlatformLoadingDeliveryController {
|
||||
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作废错误装柜证据")
|
||||
@PostMapping("/{inboundId}/loading/evidence/{evidenceId}/void/pda")
|
||||
@PreAuthorize("hasAuthority('platform:firstleg:pda:work')")
|
||||
|
||||
@@ -7,7 +7,7 @@ public final class LoadingResponses {
|
||||
private LoadingResponses() {}
|
||||
|
||||
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,
|
||||
Long voidedBy, String voidedByName, LocalDateTime voidedAt,
|
||||
LocalDateTime createdAt, LocalDateTime updatedAt) {}
|
||||
|
||||
+23
-1
@@ -24,6 +24,7 @@ import feign.FeignException;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
@@ -149,6 +150,22 @@ public class LoadingDeliveryService {
|
||||
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
|
||||
public LoadingActionResponse voidEvidence(long tenantId, long inboundId, long evidenceId, long operatorId,
|
||||
String operatorName, String key, VoidEvidenceRequest request) {
|
||||
@@ -333,11 +350,16 @@ public class LoadingDeliveryService {
|
||||
private EvidenceListResponse evidenceResponse(long tenantId, long inboundId) {
|
||||
List<EvidenceResponse> values = repository.listEvidence(tenantId, inboundId).stream().map(e ->
|
||||
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();
|
||||
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) {
|
||||
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) {
|
||||
String key = trim(idempotencyKey);
|
||||
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, "装柜计划不存在");
|
||||
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
|
||||
if (!StringUtils.hasText(booking.getContainerNo())) {
|
||||
throw validation("录入真实柜号后才能推送预装清单并开启PDA作业");
|
||||
}
|
||||
if (key.equals(plan.getPushIdempotencyKey())) {
|
||||
if ("EDITING".equals(plan.getPlanStatus())) {
|
||||
ensurePendingPreloads(repository.items(planId), operatorId, operatorName);
|
||||
}
|
||||
loadingModelBridge.bindAllocatedBeforeLoading(plan.getId(), operatorId, operatorName, key);
|
||||
loadingPickService.beginIfReady(plan.getContainerBookingId(), operatorId, operatorName);
|
||||
requirePushReplay(planId, key, requestHash);
|
||||
return view(plan, booking);
|
||||
}
|
||||
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("仅编辑中的计划可开始装柜且版本必须匹配");
|
||||
List<LoadingPlanItemEntity> items = repository.items(planId);
|
||||
ensurePendingPreloads(items, operatorId, operatorName);
|
||||
@@ -254,13 +252,26 @@ public class SmartLoadingService {
|
||||
try {
|
||||
if (repository.push(planId, key, request.expectedVersion(), operatorId, operatorName) != 1) 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);
|
||||
loadingModelBridge.bindAllocatedBeforeLoading(planId, operatorId, operatorName, key);
|
||||
loadingPickService.beginIfReady(pushed.getContainerBookingId(), operatorId, operatorName);
|
||||
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) {
|
||||
if (items.isEmpty()) throw validation("装柜明细不能为空");
|
||||
if (items.stream().anyMatch(item -> item.getAccountId() == null || item.getInboundId() == null)) {
|
||||
|
||||
@@ -35,6 +35,13 @@ paths:
|
||||
application/json: {schema: {$ref: '#/components/schemas/UpsertEvidenceRequest'}}
|
||||
responses:
|
||||
'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:
|
||||
post:
|
||||
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}}
|
||||
IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
|
||||
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:
|
||||
type: object
|
||||
required: [expectedVersion, evidenceType, fileId]
|
||||
|
||||
+62
@@ -64,6 +64,54 @@ class LoadingModelBridgeServiceTest {
|
||||
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
|
||||
void refusesPostHocBindingAfterLoadingStarts() {
|
||||
when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "LOADING", 4));
|
||||
@@ -104,6 +152,20 @@ class LoadingModelBridgeServiceTest {
|
||||
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
|
||||
void terminalGateRequiresEveryLinkedContainerPlanCompleted() {
|
||||
when(repository.byInbound(10, 20)).thenReturn(List.of(
|
||||
|
||||
+2
@@ -16,11 +16,13 @@ class LoadingDeliveryOpenApiContractTest {
|
||||
assertThat(yaml)
|
||||
.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/{evidenceId}/content/pda:")
|
||||
.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/complete:")
|
||||
.contains("/api/platform/v1/inbounds/{inboundId}/loading/complete:")
|
||||
.contains("required: [expectedVersion, evidenceType, fileId]")
|
||||
.contains("previewUrl:")
|
||||
.contains("enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL]")
|
||||
.contains("同类型可多次追加")
|
||||
.contains("Required when evidenceType is SEAL")
|
||||
|
||||
+5
@@ -30,6 +30,11 @@ class PlatformLoadingDeliveryControllerContractTest {
|
||||
"platform:firstleg:pda:work", UpsertEvidenceRequest.class);
|
||||
get("pdaEvidence", "/{inboundId}/loading/evidence/pda",
|
||||
"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",
|
||||
AuthenticatedUser.class, long.class, long.class, long.class, String.class, VoidEvidenceRequest.class);
|
||||
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.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
@@ -54,6 +55,24 @@ class LoadingDeliveryServiceTest {
|
||||
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
|
||||
void completesLoadingOnlyAfterPickingAndFiveEvidenceFiles() {
|
||||
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) {
|
||||
LoadingEvidenceEntity value = new LoadingEvidenceEntity();
|
||||
value.setId(fileId); value.setInboundId(20L); value.setTenantId(10L);
|
||||
value.setEvidenceType(type); value.setFileId(fileId); value.setSealNo(sealNo);
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -391,6 +391,11 @@ class SmartLoadingServiceTest {
|
||||
|
||||
assertThat(result.status()).isEqualTo("EDITING");
|
||||
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);
|
||||
ordered.verify(splitPreloadService).ensurePendingForLoadingAllocation(9L, 10L, 7L, "u");
|
||||
ordered.verify(loadingModelBridge).bindAllocatedBeforeLoading(1, 7, "u", "key");
|
||||
@@ -398,19 +403,77 @@ class SmartLoadingServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void editingPushReplayEnsuresPendingPreloadBeforePda() {
|
||||
void exactPushReplayIsReadOnly() {
|
||||
LoadingPlanEntity pushed = plan("EDITING", 4);
|
||||
pushed.setPushIdempotencyKey("key");
|
||||
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());
|
||||
|
||||
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");
|
||||
verify(loadingModelBridge).bindAllocatedBeforeLoading(1, 7, "u", "key");
|
||||
verify(loadingPickService).beginIfReady(99, 7, "u");
|
||||
assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT);
|
||||
assertThat(error.getMessage()).contains("新的幂等键");
|
||||
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
|
||||
@@ -490,6 +553,12 @@ class SmartLoadingServiceTest {
|
||||
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() {
|
||||
LoadingPlanEntity plan = plan("EDITING", 0);
|
||||
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 pushKey = `${context.marker}-container-push`.slice(0, 128)
|
||||
const pushResponse = await request(context, 'POST', `${SMART}/plans/${planId}/push-preload`,
|
||||
{ expectedVersion: Number(assigned.version) }, { 'Idempotency-Key': pushKey })
|
||||
const pushRequest = Object.freeze({
|
||||
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)
|
||||
context.assert(MODULE, '推送后计划保持 EDITING 且版本递增', pushResponse,
|
||||
pushed?.status === 'EDITING' && Number(pushed?.version) === Number(assigned.version) + 1,
|
||||
`status=${pushed?.status || 'missing'}, version=${pushed?.version ?? 'missing'}`, OPTIONS)
|
||||
if (pushed?.status !== 'EDITING') return
|
||||
|
||||
const pushReplayResponse = await request(context, 'POST', `${SMART}/plans/${planId}/push-preload`,
|
||||
{ expectedVersion: Number(assigned.version) }, { 'Idempotency-Key': pushKey })
|
||||
const pushReplayResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers)
|
||||
const pushReplay = context.apiSuccess(MODULE, '推送预装 Idempotency-Key 重放', pushReplayResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '推送重放保持同一 EDITING 计划', pushReplayResponse,
|
||||
id(pushReplay?.id) === planId && pushReplay?.status === 'EDITING'
|
||||
@@ -476,6 +479,15 @@ export async function run(context) {
|
||||
context.addStep(MODULE, 'PDA 任务仓库隔离拒绝', wrongWarehouseResponse,
|
||||
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`, {
|
||||
expectedAssignmentVersion: Number(task?.assignment?.version || 0),
|
||||
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 claimedPlan = context.apiSuccess(MODULE, '领取后回查装柜计划', claimedPlanResponse, '', OPTIONS)
|
||||
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)
|
||||
|
||||
const inventoryResponse = await context.request('GET',
|
||||
|
||||
@@ -211,9 +211,11 @@ export async function run(context) {
|
||||
}, { 'Idempotency-Key': `${context.marker}-p3-smart`.slice(0, 128) })
|
||||
const smartData = context.apiSuccess(MODULE, '智能分柜并生成预装清单', smart, `inboundId=${inboundId}`, OPTIONS)
|
||||
const planId = id(smartData?.preloadPlan?.id)
|
||||
const smartPreloadVersion = Number(smartData?.preloadPlan?.version)
|
||||
context.assert(MODULE, '智能分柜预装计划 PENDING、版本和明细正确', smart,
|
||||
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)
|
||||
if (!planId) return
|
||||
|
||||
@@ -225,16 +227,19 @@ export async function run(context) {
|
||||
&& replay.body?.data?.preloadPlan?.items?.length === 1,
|
||||
`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',
|
||||
`${base}/preload/loading-plans/${loadingPlanId}?tenantId=${encodeURIComponent(tenantId)}`, {
|
||||
expectedPreloadVersion: Number(smartData.preloadPlan.version),
|
||||
expectedLoadingPlanVersion: Number(assignedLoadingPlan.version),
|
||||
}, { 'Idempotency-Key': `${context.marker}-bridge-bind`.slice(0, 128) })
|
||||
`${base}/preload/loading-plans/${loadingPlanId}?tenantId=${encodeURIComponent(tenantId)}`,
|
||||
bindVersions, { 'Idempotency-Key': `${context.marker}-bridge-bind`.slice(0, 128) })
|
||||
const link = context.apiSuccess(MODULE, '原子关联入库预装与柜次装柜计划', bindResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '桥接双版本递增且关联归属唯一', bindResponse,
|
||||
id(link?.inboundId) === inboundId && id(link?.loadingPlanId) === loadingPlanId
|
||||
&& 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)
|
||||
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) })
|
||||
const confirmingData = context.apiSuccess(MODULE, '提交预装清单确认', confirming, '', OPTIONS)
|
||||
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)
|
||||
if (confirmingData?.preloadPlan?.status !== 'CONFIRMING') return
|
||||
|
||||
@@ -618,9 +624,9 @@ export async function run(context) {
|
||||
: '预装确认推进主状态 LOADING 和计划 CONFIRMED', confirm,
|
||||
directPdaCompletion
|
||||
? 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'
|
||||
&& 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)
|
||||
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.match(value, /confirmed\?\.mainStatus === 'LOADING'/)
|
||||
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 missingContainerTaskAbsent =[\s\S]*if \(!missingContainerTaskAbsent\) return/)
|
||||
assert.match(value, /version: transportVersion/)
|
||||
@@ -312,6 +312,21 @@ test('container loading enforces channel isolation and stable PDA identity acros
|
||||
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', () => {
|
||||
const container = source('./phase3-container-loading.mjs')
|
||||
const convergence = source('./phase3-pda-convergence.mjs')
|
||||
|
||||
Reference in New Issue
Block a user