优化
This commit is contained in:
+24
-12
@@ -35,6 +35,7 @@ import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -122,19 +123,30 @@ public class CarrierScheduleQueryService {
|
||||
value.setManualReason(outboundManualReason(strategy, booking));
|
||||
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
|
||||
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0);
|
||||
try { repository.insert(value); }
|
||||
catch (DuplicateKeyException exception) {
|
||||
CarrierScheduleQueryEntity concurrent = repository.findByBusinessScope(customerId, oceanBookingId,
|
||||
containerBookingId);
|
||||
if (concurrent != null) return concurrent;
|
||||
throw exception;
|
||||
String persistenceStage = "QUERY_TASK";
|
||||
try {
|
||||
try { repository.insert(value); }
|
||||
catch (DuplicateKeyException exception) {
|
||||
CarrierScheduleQueryEntity concurrent = repository.findByBusinessScope(customerId, oceanBookingId,
|
||||
containerBookingId);
|
||||
if (concurrent != null) return concurrent;
|
||||
throw exception;
|
||||
}
|
||||
persistenceStage = "NODE_FACT";
|
||||
CarrierScheduleNodeFactEntity departed = addNode(value.getId(), "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM",
|
||||
CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM",
|
||||
sha256("OUTBOUND_CONFIRM|" + containerBookingId));
|
||||
if (departed != null) {
|
||||
persistenceStage = "CROSS_REGION_OUTBOX";
|
||||
nodeOutbox.publish(value, departed, requireContainerNo(containerBookingId));
|
||||
}
|
||||
persistenceStage = "QUERY_HISTORY";
|
||||
addHistory(value.getId(), "AUTO_CREATE", null, 0, "国内确认出仓自动创建", null, value,
|
||||
operatorId, operatorName);
|
||||
} catch (DataIntegrityViolationException exception) {
|
||||
throw conflict("船节点持久化契约与当前服务不一致(" + persistenceStage
|
||||
+ "),请确认OTHER/MANUAL约束及节点事实表已初始化");
|
||||
}
|
||||
CarrierScheduleNodeFactEntity departed = addNode(value.getId(), "FIRSTLEG", "OUTBOUND_CONFIRM", "OUTBOUND_CONFIRM",
|
||||
CarrierScheduleNode.DEPARTED_WAREHOUSE, now, null, now, null, null, "OUTBOUND_CONFIRM",
|
||||
sha256("OUTBOUND_CONFIRM|" + containerBookingId));
|
||||
if (departed != null) nodeOutbox.publish(value, departed, requireContainerNo(containerBookingId));
|
||||
addHistory(value.getId(), "AUTO_CREATE", null, 0, "国内确认出仓自动创建", null, value,
|
||||
operatorId, operatorName);
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
+19
@@ -61,6 +61,25 @@ class CarrierScheduleNodeOutboxServiceTest {
|
||||
verify(mapper, never()).insert(org.mockito.ArgumentMatchers.any(InboundOutboxEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void initialOutboundDepartureUsesVersionZeroWithoutEnvelopeFailure() throws Exception {
|
||||
CarrierScheduleQueryEntity query = query();
|
||||
query.setVersion(0);
|
||||
CarrierScheduleNodeFactEntity fact = fact();
|
||||
fact.setStandardNode("DEPARTED_WAREHOUSE"); fact.setNodeSequence(10);
|
||||
fact.setProviderCode("FIRSTLEG"); fact.setProviderEventId("OUTBOUND_CONFIRM");
|
||||
fact.setProviderNodeCode("OUTBOUND_CONFIRM");
|
||||
|
||||
service.publish(query, fact, "CSNU1234567");
|
||||
|
||||
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
|
||||
verify(mapper).insert(captor.capture());
|
||||
EventEnvelope envelope = objectMapper.readValue(captor.getValue().getPayloadJson(), EventEnvelope.class);
|
||||
assertThat(envelope.aggregateVersion()).isZero();
|
||||
assertThat(envelope.handoverVersion()).isEqualTo(1L);
|
||||
assertThat(envelope.payload().path("standardNode").asText()).isEqualTo("DEPARTED_WAREHOUSE");
|
||||
}
|
||||
|
||||
private static CarrierScheduleQueryEntity query() {
|
||||
CarrierScheduleQueryEntity value = new CarrierScheduleQueryEntity();
|
||||
value.setId(2088000000000000001L); value.setCustomerId(2088000000000000002L);
|
||||
|
||||
+21
@@ -23,6 +23,7 @@ import org.mockito.Mock;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
@@ -166,6 +167,26 @@ class CarrierScheduleQueryServiceTest {
|
||||
verify(repository, times(1)).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void outboundPersistenceContractMismatchReturnsExplicitBusinessConflict() {
|
||||
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
|
||||
booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER");
|
||||
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
|
||||
container.setContainerNo("CSNU1234567");
|
||||
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
|
||||
when(portSchedules.containerBooking(20L)).thenReturn(container);
|
||||
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
|
||||
org.mockito.Mockito.doThrow(new DataIntegrityViolationException("check constraint rejected"))
|
||||
.when(repository).insert(any(CarrierScheduleQueryEntity.class));
|
||||
|
||||
assertThatThrownBy(() -> service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("船节点持久化契约与当前服务不一致")
|
||||
.hasMessageContaining("QUERY_TASK")
|
||||
.hasMessageContaining("OTHER/MANUAL");
|
||||
verify(repository, never()).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void providerActualObservationAdvancesBusinessNodeWithoutChangingTaskStatus() {
|
||||
CarrierScheduleQueryEntity task = task(0, 0);
|
||||
|
||||
+3
-1
@@ -3,6 +3,7 @@ package com.baiyue.lastleg.inboundplan.pda.dto;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
@@ -14,7 +15,8 @@ public final class ReceivingContainerRequests {
|
||||
public record Action(@NotBlank @Size(max = 255) String idempotencyKey) { }
|
||||
public record Sign(@NotNull @Size(min = 3, max = 3) List<@Valid SignedPhoto> photos,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey) { }
|
||||
public record SignedPhoto(@NotNull ReceivingPhotoRole role, @Positive long fileId) { }
|
||||
public record SignedPhoto(@NotNull ReceivingPhotoRole role,
|
||||
@NotBlank @Pattern(regexp = "^[1-9][0-9]{0,18}$") String fileId) { }
|
||||
public record CartonResolve(@NotBlank String cartonMark) { }
|
||||
public record SkuResolve(@NotBlank @Size(max = 128) String skuCode) { }
|
||||
public record PalletResolve(@NotBlank String palletCode, @NotBlank String inboundCode) { }
|
||||
|
||||
+2
-2
@@ -17,7 +17,7 @@ public final class ReceivingContainerResponses {
|
||||
int totalPackages, int receivedPackages, int pendingPackages,
|
||||
List<Inbound> inbounds) { }
|
||||
|
||||
public record SignedPhoto(ReceivingPhotoRole role, long fileId) { }
|
||||
public record SignedPhoto(ReceivingPhotoRole role, String fileId) { }
|
||||
|
||||
public record Inbound(String inboundCode, String destinationType,
|
||||
String overseasWarehouseId, String overseasWarehouseName,
|
||||
@@ -48,5 +48,5 @@ public final class ReceivingContainerResponses {
|
||||
String destinationType, String palletCode, int acceptedPackages,
|
||||
int receivedPackages, int pendingPackages, String sessionStatus) { }
|
||||
|
||||
public record Photo(long fileId, String fileName, String contentType, long sizeBytes) { }
|
||||
public record Photo(String fileId, String fileName, String contentType, long sizeBytes) { }
|
||||
}
|
||||
|
||||
+14
-4
@@ -169,7 +169,7 @@ public class ReceivingContainerService {
|
||||
String customerId = session.getCustomerId();
|
||||
if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Unload photos can only be uploaded before signing");
|
||||
UnloadPhotoFileGateway.UploadedFile uploaded = unloadPhotos.upload(customerId, session.getId(), operatorId, file);
|
||||
return new ReceivingContainerResponses.Photo(uploaded.fileId(), uploaded.fileName(), uploaded.contentType(), uploaded.sizeBytes());
|
||||
return new ReceivingContainerResponses.Photo(Long.toString(uploaded.fileId()), uploaded.fileName(), uploaded.contentType(), uploaded.sizeBytes());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -181,7 +181,7 @@ public class ReceivingContainerService {
|
||||
String customerId = session.getCustomerId();
|
||||
if (!"WAIT_SIGN".equals(session.getStatus())) throw conflict("Container has already been signed");
|
||||
List<ReceivingContainerResponses.SignedPhoto> photos = validateSignPhotos(request);
|
||||
List<Long> photoFileIds = photos.stream().map(ReceivingContainerResponses.SignedPhoto::fileId).toList();
|
||||
List<Long> photoFileIds = photos.stream().map(photo -> positiveFileId(photo.fileId())).toList();
|
||||
photoFileIds.forEach(fileId -> unloadPhotos.validate(customerId, session.getId(), fileId));
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
session.setSignPhotos(write(photos)); session.setStatus("SIGNED"); session.setSignedAt(now);
|
||||
@@ -513,9 +513,9 @@ public class ReceivingContainerService {
|
||||
Set<com.baiyue.lastleg.inboundplan.pda.dto.ReceivingPhotoRole> roles = request.photos().stream()
|
||||
.map(ReceivingContainerRequests.SignedPhoto::role).collect(Collectors.toSet());
|
||||
if (roles.size() != 3 || roles.contains(null)) throw conflict("Each unload photo role is required exactly once");
|
||||
Set<Long> fileIds = request.photos().stream().map(ReceivingContainerRequests.SignedPhoto::fileId)
|
||||
Set<Long> fileIds = request.photos().stream().map(value -> positiveFileId(value.fileId()))
|
||||
.collect(Collectors.toSet());
|
||||
if (fileIds.size() != 3 || fileIds.stream().anyMatch(value -> value == null || value <= 0)) {
|
||||
if (fileIds.size() != 3) {
|
||||
throw conflict("Unload photo file IDs must be positive and unique");
|
||||
}
|
||||
return request.photos().stream()
|
||||
@@ -523,6 +523,16 @@ public class ReceivingContainerService {
|
||||
.map(value -> new ReceivingContainerResponses.SignedPhoto(value.role(), value.fileId())).toList();
|
||||
}
|
||||
|
||||
private long positiveFileId(String value) {
|
||||
try {
|
||||
long fileId = Long.parseLong(value);
|
||||
if (fileId > 0) return fileId;
|
||||
} catch (NumberFormatException ignored) {
|
||||
// Validation normally rejects this before the service; keep the domain fail-closed.
|
||||
}
|
||||
throw conflict("Unload photo file ID must be a positive decimal string");
|
||||
}
|
||||
|
||||
private SkuRef requireSku(InboundRef ref, String skuCode) {
|
||||
SkuRef result = skuRef(ref, skuCode);
|
||||
if (result == null) throw notFound("SKU is not part of the selected inbound code");
|
||||
|
||||
@@ -623,7 +623,7 @@ components:
|
||||
required: [role, fileId]
|
||||
properties:
|
||||
role: { type: string, enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN] }
|
||||
fileId: { type: integer, format: int64, minimum: 1 }
|
||||
fileId: { type: string, pattern: '^[1-9][0-9]{0,18}$', description: Snowflake file ID kept as a decimal string }
|
||||
ReceivingBulk:
|
||||
type: object
|
||||
required: [inboundCode, skuCode, palletCode, packageCount, idempotencyKey]
|
||||
@@ -701,7 +701,7 @@ components:
|
||||
type: object
|
||||
required: [fileId, fileName, contentType, sizeBytes]
|
||||
properties:
|
||||
fileId: { type: integer, format: int64 }
|
||||
fileId: { type: string, pattern: '^[1-9][0-9]{0,18}$', description: Snowflake file ID kept as a decimal string }
|
||||
fileName: { type: string }
|
||||
contentType: { type: string }
|
||||
sizeBytes: { type: integer, format: int64 }
|
||||
|
||||
+12
-10
@@ -304,16 +304,18 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1);
|
||||
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
|
||||
ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of(
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, 3L),
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L),
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, 2L)), "sign-key");
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, "3"),
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, "2092476334483931138"),
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, "2")), "sign-key");
|
||||
|
||||
var response = service.sign("7", "receiver", request);
|
||||
|
||||
org.assertj.core.api.Assertions.assertThat(response.photos()).extracting("role")
|
||||
.containsExactly(ReceivingPhotoRole.LICENSE_PLATE, ReceivingPhotoRole.CONTAINER_SEAL,
|
||||
ReceivingPhotoRole.DOOR_OPEN);
|
||||
verify(unloadPhotos).validate("100", 8L, 1L);
|
||||
org.assertj.core.api.Assertions.assertThat(response.photos()).extracting("fileId")
|
||||
.containsExactly("2092476334483931138", "2", "3");
|
||||
verify(unloadPhotos).validate("100", 8L, 2092476334483931138L);
|
||||
verify(unloadPhotos).validate("100", 8L, 2L);
|
||||
verify(unloadPhotos).validate("100", 8L, 3L);
|
||||
}
|
||||
@@ -322,9 +324,9 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
void signRejectsDuplicateRolesBeforeFileValidation() {
|
||||
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
|
||||
ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of(
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L),
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 2L),
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, 3L)), "invalid-sign");
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, "1"),
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, "2"),
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, "3")), "invalid-sign");
|
||||
|
||||
assertThatThrownBy(() -> service.sign("7", "receiver", request))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
||||
@@ -345,9 +347,9 @@ class ReceivingContainerServiceBehaviorTest {
|
||||
void signRejectsDuplicateFileIdsBeforeFileValidation() {
|
||||
when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN"));
|
||||
ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of(
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, 1L),
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, 1L),
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, 3L)), "invalid-files");
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.LICENSE_PLATE, "1"),
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.CONTAINER_SEAL, "1"),
|
||||
new ReceivingContainerRequests.SignedPhoto(ReceivingPhotoRole.DOOR_OPEN, "3")), "invalid-files");
|
||||
|
||||
assertThatThrownBy(() -> service.sign("7", "receiver", request))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
||||
|
||||
@@ -49,7 +49,7 @@ export async function run(context) {
|
||||
| 订单创建、可计价主数据与审核异步收敛 | `orders.mjs` | `orders-pricing-fixture-contract.test.mjs`、`orders-approval-convergence-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` |
|
||||
| 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、四类货柜资料、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-container-documents.mjs`、`phase3-customs.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-container-documents-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` |
|
||||
| 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、四类货柜资料、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-container-documents.mjs`、`phase3-customs.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-container-documents-contract.test.mjs`、`phase3-phase4-departure-pickup-chain-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` |
|
||||
| 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` |
|
||||
| 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs`(DRAFT 可编辑;RELEASED 冻结;船司查询只创建并写当前 marker 登记任务) | `ocean-booking-prototype-contract.test.mjs`、`ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` |
|
||||
| 独立拖车预约与可选履约关联 | `truck-appointments.mjs`(同 marker 客户 + ACTIVE TRUCKING 承运商;订舱/柜次/计划可空;DISPATCH 前需提单号和有效 `BILL_OF_LADING` 文件) | `truck-appointments-contract.test.mjs` |
|
||||
@@ -59,6 +59,7 @@ export async function run(context) {
|
||||
| 四期海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-contract.test.mjs` |
|
||||
| 四期海外 PDA 柜号收货与库内操作 HTTP 生命周期 | `phase4-overseas-pda.mjs`(按柜完成签收和实收,成功后仅以内存上下文交接操作员会话和已完成库存事实)、`phase4-overseas-pda-operations.mjs`(紧随收货执行 P2 盘点、P3 整托移库、P4 托盘拆装换绑、P5 换标复核;finally 停用双操作员并清空临时 Token;任何真实 fixture 缺失均 fail-closed) | `phase4-overseas-pda-contract.test.mjs`、`phase4-overseas-pda-operations-contract.test.mjs` |
|
||||
| 三四期跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(双 Token Audience 互斥、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询重试/幂等/诊断证据;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` |
|
||||
| 国内离仓至海外 PDA 可见人工验收节点 | `phase3-customs.mjs`(四类柜资料与整柜照片)、`phase3-outbound-dispatch.mjs`(柜级离仓)、`phase3-carrier-schedule-queries.mjs`、`phase4-carrier-pickup-ready.mjs`、`phase4-overseas-pda.mjs`(同柜候选) | `phase3-phase4-departure-pickup-chain-contract.test.mjs` |
|
||||
| 四期海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` |
|
||||
| 四期海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs` | `phase4-overseas-master-data-contract.test.mjs` |
|
||||
| 四期船节点安全冒烟 | `phase4-carrier-nodes.mjs`(standalone 对历史任务只读;仅复用当前 marker 的 `phase3CarrierScheduleQuery` 时补录预计事实) | `phase4-carrier-nodes-contract.test.mjs` |
|
||||
|
||||
@@ -151,8 +151,6 @@ export function outboundAggregateFieldGaps(record) {
|
||||
containerNo: isNonBlank,
|
||||
loadingPlanId: isPositiveId,
|
||||
loadingPlanStatus: isNonBlank,
|
||||
pickTaskId: isPositiveId,
|
||||
pickTaskStatus: isNonBlank,
|
||||
oceanBookingId: isPositiveId,
|
||||
oceanBookingStatus: isNonBlank,
|
||||
dispatchStatus: isNonBlank,
|
||||
|
||||
@@ -12,15 +12,16 @@ export async function run(context) {
|
||||
'本轮未取得 phase3-outbound-dispatch 的 marker 隔离聚合,未使用历史资源', 'fixture')
|
||||
return
|
||||
}
|
||||
const selector = `inboundId=${encodeURIComponent(fixture.inboundId)}&containerBookingId=${encodeURIComponent(fixture.containerBookingId)}`
|
||||
const response = await context.request('GET', `${BASE}/check-gate?${selector}`)
|
||||
const gate = context.apiSuccess(MODULE, '复核国内出仓统一硬门禁', response, selector, OPTIONS)
|
||||
const items = Array.isArray(gate?.items) ? gate.items : []
|
||||
const response = await context.request('GET', `${BASE}/${encodeURIComponent(fixture.containerBookingId)}/gate`)
|
||||
const gate = context.apiSuccess(MODULE, '复核国内出仓柜级统一硬门禁', response,
|
||||
`containerBookingId=${fixture.containerBookingId}`, OPTIONS)
|
||||
const items = Array.isArray(gate?.inbounds)
|
||||
? gate.inbounds.flatMap((item) => Array.isArray(item?.gate?.items) ? item.gate.items : []) : []
|
||||
const required = ['INBOUND_LOADED', 'LOADING_PLAN_COMPLETED', 'PDA_PICK_COMPLETED',
|
||||
'OCEAN_BOOKING_RELEASED', 'TRUCK_DEPARTED', 'LOADING_EVIDENCE_COMPLETE',
|
||||
'CUSTOMS_CLEARANCE', 'SAILING_SCHEDULE']
|
||||
context.assert(MODULE, '八项业务门禁全部由服务端真实判定并通过', response,
|
||||
'OCEAN_BOOKING_RELEASED', 'LOADING_EVIDENCE_COMPLETE', 'CUSTOMS_CLEARANCE']
|
||||
context.assert(MODULE, '六项硬门禁全部由服务端真实判定并通过', response,
|
||||
gate?.passed === true && required.every((code) => items.some((item) => item.code === code
|
||||
&& item.passed === true && item.actual !== 'MODULE_UNAVAILABLE' && item.actual !== 'ASSESSMENT_ERROR')),
|
||||
&& item.passed === true && item.actual !== 'MODULE_UNAVAILABLE' && item.actual !== 'ASSESSMENT_ERROR'))
|
||||
&& items.every((item) => !['TRUCK_DEPARTED', 'SAILING_SCHEDULE'].includes(item.code)),
|
||||
`passed=${gate?.passed ?? 'missing'}, gates=${items.map((item) => `${item.code}:${item.passed}`).join(',')}`, OPTIONS)
|
||||
}
|
||||
|
||||
@@ -35,8 +35,9 @@ test('phase3 inventory mutation and downstream gates cannot pass as coverage gap
|
||||
assert.match(dispatch, /国内出仓六项硬门禁必须在同轮全部通过[\s\S]{0,300}false/)
|
||||
assert.match(dispatch, /拖车与独立船期仅作聚合参考且不参与出仓门禁/)
|
||||
assert.match(dispatch, /'CUSTOMS_CLEARANCE'/)
|
||||
assert.doesNotMatch(dispatch, /expectedCodes[\s\S]{0,240}'TRUCK_DEPARTED'|expectedCodes[\s\S]{0,240}'SAILING_SCHEDULE'/)
|
||||
assert.match(aggregate, /'CUSTOMS', 'SAILING_SCHEDULE'/)
|
||||
assert.match(dispatch, /const expectedCodes = \['INBOUND_LOADED', 'LOADING_PLAN_COMPLETED', 'PDA_PICK_COMPLETED',[\s\S]{0,120}'CUSTOMS_CLEARANCE'\]/)
|
||||
assert.match(aggregate, /'OCEAN_BOOKING', 'CUSTOMS', 'OUTBOUND_DISPATCH'/)
|
||||
assert.match(aggregate, /拖车和独立船期时间线只作参考/)
|
||||
assert.doesNotMatch(aggregate, /报关与船期历史并入统一时间线|当前服务端聚合未公开/)
|
||||
})
|
||||
|
||||
@@ -62,4 +63,5 @@ test('outbound lifecycle checks confirm replay, immutable departure reverse boun
|
||||
assert.match(waiting, /confirmations\.length === 1 && reversed\.length === 0/)
|
||||
assert.match(waiting, /实际离仓节点冻结后保持一次确认且零次成功撤销/)
|
||||
assert.match(waiting, /detail\?\.aggregate\?\.dispatchStatus === 'WAITING_FOR_LOADING'/)
|
||||
assert.match(waiting, /inbounds\?\.every/)
|
||||
})
|
||||
|
||||
@@ -23,29 +23,27 @@ export async function run(context) {
|
||||
'本轮未取得 phase3-outbound-dispatch 的 marker 隔离聚合,未使用历史资源', 'fixture')
|
||||
return
|
||||
}
|
||||
const selector = `inboundId=${encodeURIComponent(fixture.inboundId)}&containerBookingId=${encodeURIComponent(fixture.containerBookingId)}`
|
||||
const response = await context.request('GET', `${BASE}/detail?${selector}`)
|
||||
const detail = context.apiSuccess(MODULE, '复核出仓主聚合与统一时间线', response, selector, OPTIONS)
|
||||
const response = await context.request('GET', `${BASE}/${encodeURIComponent(fixture.containerBookingId)}`)
|
||||
const detail = context.apiSuccess(MODULE, '复核柜级出仓主聚合与统一时间线', response,
|
||||
`containerBookingId=${fixture.containerBookingId}`, OPTIONS)
|
||||
const aggregate = detail?.aggregate
|
||||
const inbound = aggregate?.inbounds?.find?.((item) => id(item?.inboundId) === id(fixture.inboundId))
|
||||
const timeline = Array.isArray(detail?.timeline) ? detail.timeline : []
|
||||
const expectedModules = ['INBOUND', 'LOADING_PLAN', 'PDA_PICK', 'LOADING_EVIDENCE',
|
||||
'OCEAN_BOOKING', 'TRUCK_APPOINTMENT', 'CUSTOMS', 'SAILING_SCHEDULE', 'OUTBOUND_DISPATCH']
|
||||
'OCEAN_BOOKING', 'CUSTOMS', 'OUTBOUND_DISPATCH']
|
||||
context.assert(MODULE, '聚合唯一关联本轮订单、入库单、柜次和客户', response,
|
||||
id(aggregate?.orderId) === id(fixture.orderId) && id(aggregate?.tenantId) === id(fixture.tenantId)
|
||||
&& id(aggregate?.inboundId) === id(fixture.inboundId)
|
||||
id(inbound?.orderId) === id(fixture.orderId) && id(inbound?.tenantId) === id(fixture.tenantId)
|
||||
&& id(inbound?.inboundId) === id(fixture.inboundId)
|
||||
&& id(aggregate?.containerBookingId) === id(fixture.containerBookingId),
|
||||
`orderId=${id(aggregate?.orderId) || 'missing'}, inboundId=${id(aggregate?.inboundId) || 'missing'}, containerBookingId=${id(aggregate?.containerBookingId) || 'missing'}`, OPTIONS)
|
||||
`orderId=${id(inbound?.orderId) || 'missing'}, inboundId=${id(inbound?.inboundId) || 'missing'}, containerBookingId=${id(aggregate?.containerBookingId) || 'missing'}`, OPTIONS)
|
||||
context.assert(MODULE, '统一时间线有序且各子流程包含审计字段', response,
|
||||
timeline.length > 0 && ordered(timeline)
|
||||
&& expectedModules.every((module) => timeline.some((item) => item.module === module))
|
||||
&& timeline.every((item) => Boolean(item.action) && Boolean(item.occurredAt)),
|
||||
`timeline=${timeline.length}, modules=${[...new Set(timeline.map((item) => item.module))].join(',')}`, OPTIONS)
|
||||
|
||||
const other = context.createdResources?.phase3ContainerLoading
|
||||
if (other?.inboundId && other?.containerBookingId
|
||||
&& (id(other.inboundId) !== id(fixture.inboundId) || id(other.containerBookingId) !== id(fixture.containerBookingId))) {
|
||||
const forged = await context.request('GET', `${BASE}/detail?inboundId=${encodeURIComponent(fixture.inboundId)}&containerBookingId=${encodeURIComponent(other.containerBookingId)}`)
|
||||
context.addStep(MODULE, '伪造跨资源入库单与柜次组合不可匹配', forged, rejected(forged),
|
||||
`HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS)
|
||||
}
|
||||
context.assert(MODULE, '拖车和独立船期时间线只作参考', response,
|
||||
['TRUCK_APPOINTMENT', 'SAILING_SCHEDULE'].every((module) => timeline
|
||||
.filter((item) => item.module === module).every((item) => Boolean(item.action) && Boolean(item.occurredAt))),
|
||||
`referenceModules=${timeline.filter((item) => ['TRUCK_APPOINTMENT', 'SAILING_SCHEDULE'].includes(item.module)).length}`, OPTIONS)
|
||||
}
|
||||
|
||||
@@ -60,8 +60,7 @@ export async function run(context) {
|
||||
const detailResponse = await request(context, 'GET', `${BASE}/${encodeURIComponent(containerBookingId)}`)
|
||||
const detail = context.apiSuccess(MODULE, '查询出仓主聚合与整体时间线', detailResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '整体时间线返回稳定数组且聚合定位不漂移', detailResponse,
|
||||
Array.isArray(detail?.timeline) && id(detail?.aggregate?.inboundId) === inboundId
|
||||
&& id(detail?.aggregate?.containerBookingId) === containerBookingId
|
||||
Array.isArray(detail?.timeline) && id(detail?.aggregate?.containerBookingId) === containerBookingId
|
||||
&& detail?.aggregate?.inbounds?.some?.((item) => id(item?.inboundId) === inboundId),
|
||||
`timeline=${detail?.timeline?.length ?? 'n/a'}`, OPTIONS)
|
||||
|
||||
@@ -81,7 +80,6 @@ export async function run(context) {
|
||||
`truck=${aggregate?.truckAppointmentStatus || 'none'}, ocean=${aggregate?.oceanBookingStatus || 'none'}`, OPTIONS)
|
||||
|
||||
const expectedVersion = Number(aggregate.version)
|
||||
const actionBody = { inboundId, containerBookingId, expectedVersion, remark: context.marker }
|
||||
const confirmKey = `${context.marker}-outbound-confirm`.slice(0, 128)
|
||||
if (!gate?.passed) {
|
||||
const blocked = await request(context, 'POST', `${BASE}/${encodeURIComponent(containerBookingId)}/confirm`, { expectedVersion, remark: context.marker },
|
||||
@@ -105,9 +103,9 @@ export async function run(context) {
|
||||
const confirmed = context.apiSuccess(MODULE, '确认国内出仓进入等待装船', confirmResponse, '', OPTIONS)
|
||||
context.assert(MODULE, 'confirm-dispatch 推进 WAITING_FOR_LOADING 并递增版本', confirmResponse,
|
||||
confirmed?.aggregate?.dispatchStatus === 'WAITING_FOR_LOADING'
|
||||
&& confirmed?.aggregate?.inboundStatus === 'LOADED'
|
||||
&& confirmed?.aggregate?.inbounds?.every?.((item) => item?.inboundStatus === 'LOADED')
|
||||
&& Number(confirmed?.aggregate?.version) === expectedVersion + 1,
|
||||
`dispatch=${confirmed?.aggregate?.dispatchStatus || 'missing'}, inbound=${confirmed?.aggregate?.inboundStatus || 'missing'}, version=${confirmed?.aggregate?.version ?? 'missing'}`, OPTIONS)
|
||||
`dispatch=${confirmed?.aggregate?.dispatchStatus || 'missing'}, inbounds=${confirmed?.aggregate?.inbounds?.length ?? 'missing'}, version=${confirmed?.aggregate?.version ?? 'missing'}`, OPTIONS)
|
||||
if (!confirmed?.aggregate) return
|
||||
|
||||
const replayResponse = await request(context, 'POST', `${BASE}/${encodeURIComponent(containerBookingId)}/confirm`, confirmBody,
|
||||
@@ -145,22 +143,22 @@ export async function run(context) {
|
||||
const finalDetail = context.apiSuccess(MODULE, '负向与幂等验证后回查出仓聚合', finalDetailResponse, '', OPTIONS)
|
||||
context.assert(MODULE, '出仓聚合保持 WAITING_FOR_LOADING、LOADED 且无成功撤销历史', finalDetailResponse,
|
||||
finalDetail?.aggregate?.dispatchStatus === 'WAITING_FOR_LOADING'
|
||||
&& finalDetail?.aggregate?.inboundStatus === 'LOADED'
|
||||
&& finalDetail?.aggregate?.inbounds?.every?.((item) => item?.inboundStatus === 'LOADED')
|
||||
&& Number(finalDetail?.aggregate?.version) === Number(confirmed?.aggregate?.version)
|
||||
&& finalDetail?.timeline?.filter?.((item) => item.module === 'OUTBOUND_DISPATCH'
|
||||
&& item.action === 'CONFIRM_DISPATCH').length === 1
|
||||
&& !finalDetail?.timeline?.some?.((item) => item.module === 'OUTBOUND_DISPATCH'
|
||||
&& item.action === 'REVERSE_DISPATCH'),
|
||||
`dispatch=${finalDetail?.aggregate?.dispatchStatus || 'missing'}, inbound=${finalDetail?.aggregate?.inboundStatus || 'missing'}, version=${finalDetail?.aggregate?.version ?? 'missing'}`, OPTIONS)
|
||||
`dispatch=${finalDetail?.aggregate?.dispatchStatus || 'missing'}, inbounds=${finalDetail?.aggregate?.inbounds?.length ?? 'missing'}, version=${finalDetail?.aggregate?.version ?? 'missing'}`, OPTIONS)
|
||||
if (confirmed?.aggregate) {
|
||||
context.createdResources.phase3OutboundDispatch = {
|
||||
marker: context.marker, inboundId, containerBookingId,
|
||||
orderId: id(inboundBinding?.orderId), tenantId: id(inboundBinding?.tenantId),
|
||||
confirmedStatus: confirmed.aggregate.dispatchStatus,
|
||||
confirmedInboundStatus: confirmed.aggregate.inboundStatus,
|
||||
confirmedInboundStatus: confirmed.aggregate.inbounds?.every?.((item) => item?.inboundStatus === 'LOADED') ? 'LOADED' : null,
|
||||
confirmedVersion: Number(confirmed.aggregate.version),
|
||||
finalStatus: confirmed.aggregate.dispatchStatus,
|
||||
finalInboundStatus: confirmed.aggregate.inboundStatus,
|
||||
finalInboundStatus: confirmed.aggregate.inbounds?.every?.((item) => item?.inboundStatus === 'LOADED') ? 'LOADED' : null,
|
||||
finalVersion: Number(confirmed.aggregate.version),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const read = (name) => readFileSync(resolve(here, name), 'utf8')
|
||||
|
||||
test('current-marker departure batch proves four documents photos customs and container-level confirm', () => {
|
||||
const customs = read('phase3-customs.mjs')
|
||||
const outbound = read('phase3-outbound-dispatch.mjs')
|
||||
for (const type of ['DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER']) {
|
||||
assert.match(customs, new RegExp(type))
|
||||
}
|
||||
for (const evidence of ['首次上传', '回显', '下载', '更新', '完成']) assert.match(customs, new RegExp(evidence))
|
||||
assert.match(customs, /photos\.length >= 5/)
|
||||
assert.match(customs, /四类整柜资料必须完成后才允许进入出仓确认/)
|
||||
assert.match(outbound, /CUSTOMS_CLEARANCE/)
|
||||
assert.match(outbound, /拖车与独立船期仅作聚合参考且不参与出仓门禁/)
|
||||
assert.match(outbound, /\$\{BASE\}\/\$\{encodeURIComponent\(containerBookingId\)\}\/confirm/)
|
||||
assert.match(outbound, /confirm-dispatch 幂等重放/)
|
||||
assert.match(outbound, /相同 Idempotency-Key 不同请求拒绝/)
|
||||
assert.match(outbound, /实际DEPARTED_WAREHOUSE节点产生后撤销精确返回A0409/)
|
||||
assert.doesNotMatch(outbound, /\/confirm-dispatch|\/reverse-dispatch|\/check-gate\?/)
|
||||
})
|
||||
|
||||
test('outbound-created query supports automated or manual carrier then reaches PICKUP_READY PDA candidate', () => {
|
||||
const query = read('phase3-carrier-schedule-queries.mjs')
|
||||
const nodes = read('phase4-carrier-nodes.mjs')
|
||||
const pda = read('phase4-overseas-pda.mjs')
|
||||
assert.match(query, /出仓自动建任务兼容自动船司与未知船司人工处理/)
|
||||
assert.match(query, /manualCarrier && task\?\.status === 'MANUAL_REQUIRED'/)
|
||||
assert.match(nodes, /'DISCHARGED_FROM_VESSEL', 'PICKUP_READY'/)
|
||||
assert.match(nodes, /const pickupReadyIndex = STANDARD_NODES\.indexOf\('PICKUP_READY'\)/)
|
||||
assert.match(nodes, /createdResources\.phase4CarrierPickupReady/)
|
||||
assert.match(pda, /targetContainerNo[\s\S]*phase3Loading\?\.containerNo/)
|
||||
assert.match(pda, /id\(carrierQuery\?\.trackingNo\) === targetContainerNo/)
|
||||
assert.match(pda, /seen\.has\(targetContainerNo\)/)
|
||||
assert.match(pda, /targetCorrelated/)
|
||||
assert.match(pda, /deploymentBlocker: true/)
|
||||
assert.doesNotMatch(pda, /addCoverageGap\([^\n]*PICKUP_READY/)
|
||||
})
|
||||
@@ -12,9 +12,9 @@ export async function run(context) {
|
||||
'本轮未取得 phase3-outbound-dispatch 的 marker 隔离聚合,未使用历史资源', 'fixture')
|
||||
return
|
||||
}
|
||||
const selector = `inboundId=${encodeURIComponent(fixture.inboundId)}&containerBookingId=${encodeURIComponent(fixture.containerBookingId)}`
|
||||
const response = await context.request('GET', `${BASE}/detail?${selector}`)
|
||||
const detail = context.apiSuccess(MODULE, '回查 WAITING_FOR_LOADING 状态历史', response, selector, OPTIONS)
|
||||
const response = await context.request('GET', `${BASE}/${encodeURIComponent(fixture.containerBookingId)}`)
|
||||
const detail = context.apiSuccess(MODULE, '回查柜级 WAITING_FOR_LOADING 状态历史', response,
|
||||
`containerBookingId=${fixture.containerBookingId}`, OPTIONS)
|
||||
const history = Array.isArray(detail?.timeline)
|
||||
? detail.timeline.filter((item) => item.module === 'OUTBOUND_DISPATCH') : []
|
||||
const confirmations = history.filter((item) => item.action === 'CONFIRM_DISPATCH')
|
||||
@@ -27,7 +27,8 @@ export async function run(context) {
|
||||
&& Boolean(confirmed?.operatorId) && Boolean(confirmed?.operatorName) && Boolean(confirmed?.occurredAt),
|
||||
`confirmed=${confirmed?.fromStatus || 'missing'}->${confirmed?.toStatus || 'missing'}, version=${fixture.confirmedVersion ?? 'missing'}`, OPTIONS)
|
||||
context.assert(MODULE, '实际离仓节点冻结后保持一次确认且零次成功撤销', response,
|
||||
detail?.aggregate?.dispatchStatus === 'WAITING_FOR_LOADING' && detail?.aggregate?.inboundStatus === 'LOADED'
|
||||
detail?.aggregate?.dispatchStatus === 'WAITING_FOR_LOADING'
|
||||
&& detail?.aggregate?.inbounds?.every?.((item) => item?.inboundStatus === 'LOADED')
|
||||
&& confirmations.length === 1 && reversed.length === 0,
|
||||
`current=${detail?.aggregate?.dispatchStatus || 'missing'}, confirmations=${confirmations.length}, reversals=${reversed.length}`, OPTIONS)
|
||||
}
|
||||
|
||||
@@ -47,7 +47,9 @@ test('PDA module covers the container-number receiving contract and excludes out
|
||||
assert.match(source, /模式硬拒绝/)
|
||||
assert.match(source, /current返回404正常空态/)
|
||||
assert.match(source, /安全扫描返回A0400/)
|
||||
assert.match(source, /customerId: 0/)
|
||||
assert.match(source, /warehouse-admin\?tenantId=0&.*capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE/)
|
||||
assert.match(source, /193000000000200003.*193000000000200004/)
|
||||
assert.doesNotMatch(source, /requestOverseas\('POST', '\/api\/platform\/v1\/warehouse-admin'/)
|
||||
assert.match(source, /overseasWarehouseId, password/)
|
||||
assert.match(source, /inbound\?\.overseasWarehouseId/)
|
||||
assert.doesNotMatch(source, /const warehouseId = id\(inbound\?\.destinationWarehouseId\)/)
|
||||
@@ -120,6 +122,10 @@ test('empty candidate response cannot become a zero-business-request pass', asyn
|
||||
if (method === 'GET' && requestPath.endsWith('/permissions')) return response([
|
||||
{ id: permissionId, permissionCode: 'lastleg:pda:operate' },
|
||||
], requestPath, method)
|
||||
if (method === 'GET' && requestPath.startsWith('/api/platform/v1/warehouse-admin?')) return response({
|
||||
records: [{ id: '193000000000200003', warehouseName: '洛杉矶仓', warehouseType: 'OVERSEAS', status: 'ACTIVE' }],
|
||||
page: 1, size: 200, total: 1, pages: 1,
|
||||
}, requestPath, method)
|
||||
if (method === 'POST' && requestPath.endsWith('/users')) return response({
|
||||
membershipId: String(9000 + userSequence), username: body.username, tenantId: '0', roleIds: [roleId],
|
||||
overseasWarehouseId: String(body.overseasWarehouseId), status: 'ACTIVE',
|
||||
@@ -130,7 +136,6 @@ test('empty candidate response cannot become a zero-business-request pass', asyn
|
||||
},
|
||||
async requestOverseas(method, requestPath, body, headers, token) {
|
||||
calls.push({ channel: 'overseas', method, requestPath })
|
||||
if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('900', requestPath, method)
|
||||
if (requestPath.endsWith('/current')) return { method, path: requestPath, http: 200,
|
||||
body: { code: 'A0404', message: 'not found' }, requestId: 'req-current', traceId: 'trace-current' }
|
||||
return response([], requestPath, method)
|
||||
|
||||
@@ -559,9 +559,9 @@ export async function run(context) {
|
||||
const merchantToken = await loginMerchantForNegativeProbe(context)
|
||||
const pickerAccess = await resolvePickerAccess(context)
|
||||
if (!pickerAccess) return
|
||||
const sharedWarehouse = await createSharedWarehouse(context)
|
||||
const sharedWarehouse = await resolveSharedWarehouse(context)
|
||||
if (!sharedWarehouse) {
|
||||
blockerGap(context, '海外PDA共享实际收货仓', '无法通过平台正式API创建customerId=0共享物理仓')
|
||||
blockerGap(context, '海外PDA共享实际收货仓', '国内Warehouse主数据没有ACTIVE且启用RECEIVING的OVERSEAS固定作业仓')
|
||||
return
|
||||
}
|
||||
context.createdResources.phase4OverseasPda.overseasWarehouseId = sharedWarehouse.id
|
||||
@@ -623,21 +623,14 @@ export async function run(context) {
|
||||
}
|
||||
}
|
||||
|
||||
async function createSharedWarehouse(context) {
|
||||
const suffix = markerSuffix(context)
|
||||
const payload = {
|
||||
customerId: 0, warehouseCode: `PDA-${suffix}`.slice(0, 50),
|
||||
warehouseName: `PDA共享收货仓-${suffix}`.slice(0, 120), warehouseType: 'OVERSEAS',
|
||||
countryCode: 'US', timezone: 'America/Los_Angeles', contactName: 'PDA Acceptance',
|
||||
contactPhone: '+1-213-555-0198', contactEmail: `pda-${suffix.toLowerCase()}@example.com`,
|
||||
status: 'ACTIVE', remark: `${context.marker}-shared-receiving`,
|
||||
address: { addressType: 'WAREHOUSE', countryCode: 'US', stateProvince: 'CA', city: 'Los Angeles',
|
||||
district: 'Commerce', postalCode: '90040', addressLine1: '1 Acceptance Logistics Ave' },
|
||||
capabilities: [{ capabilityCode: 'RECEIVING', capabilityName: '收货', enabled: true }],
|
||||
}
|
||||
const created = await context.requestOverseas('POST', '/api/platform/v1/warehouse-admin', payload)
|
||||
const warehouseId = id(created?.body?.data)
|
||||
context.addStep(MODULE, '创建本轮PDA共享实际收货仓', created,
|
||||
success(created) && longId(warehouseId), `warehouseId=${warehouseId || 'missing'}; ${trace(created)}`, OPTIONS)
|
||||
return longId(warehouseId) ? { id: warehouseId, name: payload.warehouseName } : null
|
||||
async function resolveSharedWarehouse(context) {
|
||||
const path = '/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE'
|
||||
const response = await context.request('GET', path)
|
||||
const rows = records(response?.body?.data)
|
||||
const fixedIds = new Set(['193000000000200003', '193000000000200004'])
|
||||
const warehouse = success(response) ? rows.find((item) => fixedIds.has(id(item?.id))) : null
|
||||
context.addStep(MODULE, '读取国内Warehouse权威海外PDA实际收货仓', response,
|
||||
Boolean(warehouse) && warehouse?.warehouseType === 'OVERSEAS' && warehouse?.status === 'ACTIVE',
|
||||
`warehouseId=${id(warehouse?.id) || 'missing'}, warehouseName=${warehouse?.warehouseName || warehouse?.name || 'missing'}; ${trace(response)}`, OPTIONS)
|
||||
return warehouse ? { id: id(warehouse.id), name: warehouse.warehouseName || warehouse.name } : null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user