Merge remote-tracking branch 'origin/test' into dev

# Conflicts:
#	firstleg/src/main/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceService.java
#	firstleg/src/test/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceServiceTest.java
#	scripts/admin-test-env-modules/ocean-bookings.mjs
#	scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs
#	scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs
#	scripts/admin-test-env-modules/phase3-container-loading.mjs
#	scripts/admin-test-env-modules/phase3-customs.mjs
#	scripts/admin-test-env-modules/phase3-loading-finalize.mjs
#	scripts/admin-test-env-modules/phase3-loading.mjs
#	scripts/admin-test-env-modules/phase3-port-schedules.mjs
#	scripts/admin-test-env-modules/service-center-flow.mjs
This commit is contained in:
2026-09-02 14:00:44 +08:00
36 changed files with 1383 additions and 523 deletions
@@ -40,7 +40,7 @@ public class QualificationFileController {
@RequestParam(required = false) LocalDate validTo, @RequestParam(required = false) LocalDate validTo,
@RequestParam(defaultValue = "false") boolean replace) { @RequestParam(defaultValue = "false") boolean replace) {
long tenantId = merchantTenant(user); long tenantId = merchantTenant(user);
qualificationType = QualificationTypes.requireOptional(qualificationType); qualificationType = QualificationTypes.requireSingleUpload(qualificationType);
var stored = storage.store(tenantId, file); var stored = storage.store(tenantId, file);
try { try {
var response = iamClient.submit(internalToken, var response = iamClient.submit(internalToken,
@@ -21,11 +21,22 @@ final class QualificationTypes {
return qualificationType; return qualificationType;
} }
static String requireSingleUpload(String qualificationType) {
String type = requireSupported(qualificationType);
if ("BUSINESS_LICENSE".equals(type)) {
throw new BizException(ResultCode.BAD_REQUEST,
"The business license must be uploaded with its unified social credit code"
+ " / 营业执照必须与统一社会信用代码一并上传");
}
return type;
}
static String requireOptional(String qualificationType) { static String requireOptional(String qualificationType) {
String type = requireSupported(qualificationType); String type = requireSupported(qualificationType);
if ("BUSINESS_LICENSE".equals(type) || "LEGAL_PERSON_ID".equals(type)) { if ("BUSINESS_LICENSE".equals(type) || "LEGAL_PERSON_ID".equals(type)) {
throw new BizException(ResultCode.BAD_REQUEST, throw new BizException(ResultCode.BAD_REQUEST,
"Required qualifications must be uploaded as one package / 营业执照、统一社会信用代码和法人身份证必须整包上传"); "Business license and legal-person ID use the platform qualification package"
+ " / 平台端营业执照与法人身份证通过资质包上传");
} }
return type; return type;
} }
@@ -4,6 +4,43 @@ info:
version: 1.0.0 version: 1.0.0
description: 营业执照为必填资质;法人身份证为选填资质,缺省时不创建、不替换法人身份证记录。 description: 营业执照为必填资质;法人身份证为选填资质,缺省时不创建、不替换法人身份证记录。
paths: paths:
/api/merchant/v1/files/qualifications:
post:
summary: 客户单独上传或替换选填资质
description: >-
需要 merchant:qualification:upload;客户范围只取登录 Token,不接受客户ID。
LEGAL_PERSON_ID 可在已有营业执照后单独新增或替换,不要求重新上传营业执照。
BUSINESS_LICENSE 不允许走此入口,必须与18位统一社会信用代码通过 required-package 一并上传。
security: [{bearerAuth: []}]
parameters:
- {$ref: '#/components/parameters/QualificationType'}
- {$ref: '#/components/parameters/CertificateNo'}
- {$ref: '#/components/parameters/ValidTo'}
- {$ref: '#/components/parameters/Replace'}
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file:
type: string
format: binary
maxLength: 5242880
description: PDF/JPG/PNG,最大5MB,声明媒体类型必须与文件签名一致
responses:
'200':
description: 成功或业务失败,失败时按 code 判断并保留 requestId/traceId
content:
application/json:
schema:
oneOf:
- {$ref: '#/components/schemas/QualificationUploadSuccess'}
- {$ref: '#/components/schemas/ErrorResponse'}
'401': {$ref: '#/components/responses/Error'}
'403': {$ref: '#/components/responses/Error'}
/api/merchant/v1/files/qualifications/required-package: /api/merchant/v1/files/qualifications/required-package:
post: post:
summary: 客户上传必需资质包 summary: 客户上传必需资质包
@@ -37,6 +74,27 @@ components:
securitySchemes: securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
parameters: parameters:
QualificationType:
name: qualificationType
in: query
required: true
schema: {type: string, enum: [LEGAL_PERSON_ID, FREIGHT_FORWARDER_CERTIFICATE, BANK_ACCOUNT_LICENSE]}
CertificateNo:
name: certificateNo
in: query
required: false
schema: {type: string, maxLength: 128}
ValidTo:
name: validTo
in: query
required: false
schema: {type: string, format: date}
Replace:
name: replace
in: query
required: false
description: 同类有当前资质时必须显式为true;成功后旧文件元数据标记为已替代
schema: {type: boolean, default: false}
TenantId: TenantId:
name: tenantId name: tenantId
in: path in: path
@@ -88,6 +146,11 @@ components:
nullable: true nullable: true
description: 法人身份证,选填;PDF/JPG/PNG,最大5MB description: 法人身份证,选填;PDF/JPG/PNG,最大5MB
responses: responses:
Error:
description: 认证或授权失败,响应包含请求关联元数据
content:
application/json:
schema: {$ref: '#/components/schemas/ErrorResponse'}
PackageResult: PackageResult:
description: 资质包上传结果 description: 资质包上传结果
content: content:
@@ -100,6 +163,37 @@ components:
data: {$ref: '#/components/schemas/PackageUploadResult'} data: {$ref: '#/components/schemas/PackageUploadResult'}
schemas: schemas:
Id: {type: string, pattern: '^[1-9][0-9]*$'} Id: {type: string, pattern: '^[1-9][0-9]*$'}
QualificationUploadSuccess:
type: object
required: [code, message, data, timestamp]
properties:
code: {type: string, enum: ['200']}
message: {type: string}
data: {$ref: '#/components/schemas/QualificationUploadResult'}
requestId: {type: string, nullable: true}
traceId: {type: string, nullable: true}
timestamp: {type: string, format: date-time}
ErrorResponse:
type: object
required: [code, message, requestId, traceId, timestamp]
properties:
code: {type: string, example: A0403}
message: {type: string}
data: {nullable: true}
requestId: {type: string, minLength: 1}
traceId: {type: string, minLength: 1}
timestamp: {type: string, format: date-time}
QualificationUploadResult:
type: object
required: [qualificationId, tenantStatus, documentUrl]
properties:
qualificationId: {$ref: '#/components/schemas/Id'}
reviewId:
allOf: [{$ref: '#/components/schemas/Id'}]
nullable: true
description: 上传不自动送审,当前固定为null
tenantStatus: {type: string, enum: [PROFILE_REQUIRED, REJECTED]}
documentUrl: {type: string, minLength: 1}
PackageUploadResult: PackageUploadResult:
type: object type: object
required: [businessLicenseQualificationId, tenantStatus, businessLicenseDocumentUrl] required: [businessLicenseQualificationId, tenantStatus, businessLicenseDocumentUrl]
@@ -8,6 +8,19 @@ import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
class QualificationAndOrderFileOpenApiContractTest { class QualificationAndOrderFileOpenApiContractTest {
@Test
void legalPersonIdHasADedicatedSingleFileContractWithErrorMetadata() throws Exception {
String yaml = Files.readString(Path.of(
"src/main/resources/openapi/qualification-required-package-v1.yaml"));
assertThat(yaml)
.contains("/api/merchant/v1/files/qualifications:")
.contains("enum: [LEGAL_PERSON_ID, FREIGHT_FORWARDER_CERTIFICATE, BANK_ACCOUNT_LICENSE]")
.contains("merchant:qualification:upload", "maxLength: 5242880")
.contains("name: replace", "requestId:", "traceId:")
.doesNotContain("enum: [BUSINESS_LICENSE, LEGAL_PERSON_ID, FREIGHT_FORWARDER_CERTIFICATE, BANK_ACCOUNT_LICENSE]");
}
@Test @Test
void qualificationPackageRequiresOnlyTheBusinessLicenseFile() throws Exception { void qualificationPackageRequiresOnlyTheBusinessLicenseFile() throws Exception {
String yaml = Files.readString(Path.of( String yaml = Files.readString(Path.of(
@@ -96,6 +96,16 @@ class QualificationFileContractTest {
assertEquals("hasAuthority('platform:customer:list')", method.getAnnotation(PreAuthorize.class).value()); assertEquals("hasAuthority('platform:customer:list')", method.getAnnotation(PreAuthorize.class).value());
} }
@Test
void merchantSingleQualificationUploadRequiresTheUploadPermission() throws Exception {
var method = QualificationFileController.class.getDeclaredMethod(
"upload", AuthenticatedUser.class, org.springframework.web.multipart.MultipartFile.class,
String.class, String.class, LocalDate.class, boolean.class);
assertEquals("hasAuthority('merchant:qualification:upload')",
method.getAnnotation(PreAuthorize.class).value());
}
@Test @Test
void platformQualificationRejectsCustomerScopedPlatformToken() { void platformQualificationRejectsCustomerScopedPlatformToken() {
var storage = mock(LocalQualificationStorage.class); var storage = mock(LocalQualificationStorage.class);
@@ -219,7 +229,7 @@ class QualificationFileContractTest {
} }
@Test @Test
void merchantUploadForwardsItsQualificationTypeAndCleansTheReplacedFile() { void merchantLegalPersonIdReplacementUsesTheAuthenticatedTenantAndCleansTheReplacedFile() {
var storage = mock(LocalQualificationStorage.class); var storage = mock(LocalQualificationStorage.class);
var iamClient = mock(IamQualificationClient.class); var iamClient = mock(IamQualificationClient.class);
var controller = merchantController(storage, iamClient); var controller = merchantController(storage, iamClient);
@@ -230,15 +240,30 @@ class QualificationFileContractTest {
when(iamClient.submit(eq("internal-token"), any())).thenReturn(ApiResponse.success( when(iamClient.submit(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new IamQualificationClient.QualificationResponse(31L, null, "PROFILE_REQUIRED", oldUrl))); new IamQualificationClient.QualificationResponse(31L, null, "PROFILE_REQUIRED", oldUrl)));
controller.upload(merchantUser(), file, "FREIGHT_FORWARDER_CERTIFICATE", null, LocalDate.of(2030, 1, 1), true); controller.upload(merchantUser(), file, "LEGAL_PERSON_ID", null, LocalDate.of(2030, 1, 1), true);
var requestCaptor = org.mockito.ArgumentCaptor.forClass(IamQualificationClient.QualificationRequest.class); var requestCaptor = org.mockito.ArgumentCaptor.forClass(IamQualificationClient.QualificationRequest.class);
verify(iamClient).submit(eq("internal-token"), requestCaptor.capture()); verify(iamClient).submit(eq("internal-token"), requestCaptor.capture());
verify(storage).deleteByDocumentUrl(10001L, oldUrl); verify(storage).deleteByDocumentUrl(10001L, oldUrl);
assertEquals("FREIGHT_FORWARDER_CERTIFICATE", requestCaptor.getValue().qualificationType()); assertEquals(10001L, requestCaptor.getValue().tenantId());
assertEquals("LEGAL_PERSON_ID", requestCaptor.getValue().qualificationType());
assertTrue(requestCaptor.getValue().replace()); assertTrue(requestCaptor.getValue().replace());
} }
@Test
void merchantQualificationRejectsAPlatformIdentityBeforeWritingTheFile() {
var storage = mock(LocalQualificationStorage.class);
var iamClient = mock(IamQualificationClient.class);
var controller = merchantController(storage, iamClient);
var file = new MockMultipartFile("file", "legal-person.pdf", "application/pdf", "%PDF-1.4".getBytes());
assertThrows(BizException.class,
() -> controller.upload(platformUser(), file, "LEGAL_PERSON_ID", null, null, true));
verify(storage, never()).store(any(Long.class), any());
verify(iamClient, never()).submit(any(), any());
}
@Test @Test
void merchantUploadRejectsMissingOrUnsupportedTypesBeforeWritingTheFile() { void merchantUploadRejectsMissingOrUnsupportedTypesBeforeWritingTheFile() {
var storage = mock(LocalQualificationStorage.class); var storage = mock(LocalQualificationStorage.class);
@@ -258,7 +283,7 @@ class QualificationFileContractTest {
@Test @Test
void everyOptionalQualificationTypeIsAcceptedAndForwarded() { void everyOptionalQualificationTypeIsAcceptedAndForwarded() {
for (String qualificationType : Set.of( for (String qualificationType : Set.of(
"FREIGHT_FORWARDER_CERTIFICATE", "BANK_ACCOUNT_LICENSE")) { "LEGAL_PERSON_ID", "FREIGHT_FORWARDER_CERTIFICATE", "BANK_ACCOUNT_LICENSE")) {
var storage = mock(LocalQualificationStorage.class); var storage = mock(LocalQualificationStorage.class);
var iamClient = mock(IamQualificationClient.class); var iamClient = mock(IamQualificationClient.class);
var controller = merchantController(storage, iamClient); var controller = merchantController(storage, iamClient);
@@ -277,19 +302,17 @@ class QualificationFileContractTest {
} }
@Test @Test
void legacySingleUploadRejectsBothRequiredTypesBeforeWritingTheFile() { void singleUploadStillRejectsTheBusinessLicenseBeforeWritingTheFile() {
for (String qualificationType : Set.of("BUSINESS_LICENSE", "LEGAL_PERSON_ID")) { var storage = mock(LocalQualificationStorage.class);
var storage = mock(LocalQualificationStorage.class); var iamClient = mock(IamQualificationClient.class);
var iamClient = mock(IamQualificationClient.class); var controller = merchantController(storage, iamClient);
var controller = merchantController(storage, iamClient); var file = new MockMultipartFile("file", "qualification.pdf", "application/pdf", "%PDF-1.4".getBytes());
var file = new MockMultipartFile("file", "qualification.pdf", "application/pdf", "%PDF-1.4".getBytes());
assertThrows(BizException.class, assertThrows(BizException.class,
() -> controller.upload(merchantUser(), file, qualificationType, null, null, false)); () -> controller.upload(merchantUser(), file, "BUSINESS_LICENSE", null, null, false));
verify(storage, never()).store(any(Long.class), any()); verify(storage, never()).store(any(Long.class), any());
verify(iamClient, never()).submit(any(), any()); verify(iamClient, never()).submit(any(), any());
}
} }
private PlatformQualificationFileController platformController( private PlatformQualificationFileController platformController(
@@ -24,8 +24,8 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.io.IOException;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.util.HexFormat; import java.util.HexFormat;
@@ -45,13 +45,13 @@ public class CabinetLoadingEvidenceService {
@Transactional @Transactional
public EvidenceList upsert(long taskId, String key, UpsertRequest request, long operatorId, String operatorName) { public EvidenceList upsert(long taskId, String key, UpsertRequest request, long operatorId, String operatorName) {
Scope scope = scope(taskId, request.warehouseId(), operatorId, true); return upsert(taskId, key, request, operatorId, operatorName, hash(request));
requireKey(key);
return persist(scope, taskId, key, request, hash(request), operatorId, operatorName);
} }
private EvidenceList persist(Scope scope, long taskId, String key, UpsertRequest request, private EvidenceList upsert(long taskId, String key, UpsertRequest request, long operatorId,
String requestHash, long operatorId, String operatorName) { String operatorName, String requestHash) {
Scope scope = scope(taskId, request.warehouseId(), operatorId, true);
requireKey(key);
CabinetLoadingEvidenceEntity replay = mapper.byOperation(taskId, key); CabinetLoadingEvidenceEntity replay = mapper.byOperation(taskId, key);
if (replay != null) { if (replay != null) {
if (!Objects.equals(requestHash, replay.getRequestHash())) throw conflict("相同幂等键的请求内容不一致"); if (!Objects.equals(requestHash, replay.getRequestHash())) throw conflict("相同幂等键的请求内容不一致");
@@ -93,22 +93,20 @@ public class CabinetLoadingEvidenceService {
long operatorId, String operatorName) { long operatorId, String operatorName) {
Scope scope = scope(taskId, warehouseId, operatorId, true); Scope scope = scope(taskId, warehouseId, operatorId, true);
requireKey(key); requireKey(key);
String type = evidenceType.trim(); String requestHash = uploadHash(warehouseId, expectedVersion, evidenceType, sealNo, remark, multipart);
if (!LoadingEvidenceType.VALUES.contains(type)) throw bad("不支持的装柜证据类型");
String normalizedSealNo = LoadingEvidenceType.SEAL.equals(type) ? normalizedSeal(sealNo) : null;
String requestHash = hash(new UploadHashRequest(warehouseId, expectedVersion, type,
normalizedSealNo, remark, multipartHash(multipart)));
CabinetLoadingEvidenceEntity replay = mapper.byOperation(taskId, key); CabinetLoadingEvidenceEntity replay = mapper.byOperation(taskId, key);
if (replay != null) { if (replay != null) {
if (!Objects.equals(requestHash, replay.getRequestHash())) throw conflict("相同幂等键的请求内容不一致"); if (!Objects.equals(requestHash, replay.getRequestHash())) {
throw conflict("相同幂等键的请求内容不一致");
}
return view(scope); return view(scope);
} }
try { try {
var response = files.upload(internalToken, operatorId, scope.containerBookingId(), type, multipart); var response = files.upload(internalToken, operatorId, scope.containerBookingId(), evidenceType, multipart);
var uploaded = response == null ? null : response.data(); var uploaded = response == null ? null : response.data();
if (uploaded == null) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "装柜证据文件上传失败"); if (uploaded == null) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "装柜证据文件上传失败");
return persist(scope, taskId, key, new UpsertRequest(warehouseId, expectedVersion, type, return upsert(taskId, key, new UpsertRequest(warehouseId, expectedVersion, evidenceType,
uploaded.fileId(), normalizedSealNo, remark), requestHash, operatorId, operatorName); uploaded.fileId(), sealNo, remark), operatorId, operatorName, requestHash);
} catch (FeignException exception) { } catch (FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用");
} }
@@ -197,9 +195,17 @@ public class CabinetLoadingEvidenceService {
objectMapper.writeValueAsString(value).getBytes(StandardCharsets.UTF_8))); } objectMapper.writeValueAsString(value).getBytes(StandardCharsets.UTF_8))); }
catch (JsonProcessingException | NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); } catch (JsonProcessingException | NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); }
} }
private static String multipartHash(MultipartFile file) { private static String uploadHash(long warehouseId, int expectedVersion, String evidenceType,
String sealNo, String remark, MultipartFile multipart) {
try { try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(file.getBytes())); MessageDigest digest = MessageDigest.getInstance("SHA-256");
String metadata = warehouseId + "|" + expectedVersion + "|" + evidenceType + "|"
+ Objects.toString(sealNo, "") + "|" + Objects.toString(remark, "") + "|"
+ Objects.toString(multipart.getOriginalFilename(), "") + "|"
+ Objects.toString(multipart.getContentType(), "") + "|" + multipart.getSize() + "|";
digest.update(metadata.getBytes(StandardCharsets.UTF_8));
digest.update(multipart.getBytes());
return HexFormat.of().formatHex(digest.digest());
} catch (IOException exception) { } catch (IOException exception) {
throw bad("装柜证据文件读取失败"); throw bad("装柜证据文件读取失败");
} catch (NoSuchAlgorithmException exception) { } catch (NoSuchAlgorithmException exception) {
@@ -214,7 +220,5 @@ public class CabinetLoadingEvidenceService {
private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); } private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); } private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); }
private static String id(Long value) { return value == null ? null : value.toString(); } private static String id(Long value) { return value == null ? null : value.toString(); }
private record UploadHashRequest(long warehouseId, int expectedVersion, String evidenceType,
String sealNo, String remark, String fileHash) {}
private record Scope(LoadingPickTaskEntity task, long containerBookingId) {} private record Scope(LoadingPickTaskEntity task, long containerBookingId) {}
} }
@@ -13,15 +13,17 @@ import jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.mock.web.MockMultipartFile;
import org.mockito.ArgumentCaptor;
import java.util.List; import java.util.List;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -104,35 +106,32 @@ class CabinetLoadingEvidenceServiceTest {
} }
@Test @Test
void multipartReplayUsesRequestAndContentHashBeforeUploadingAgain() throws Exception { void multipartReplayUsesContentHashBeforeUploadingAnotherFile() {
when(objectMapper.writeValueAsString(org.mockito.ArgumentMatchers.any())).thenReturn("request"); var multipart = new MockMultipartFile("file", "photo.png", "image/png", new byte[] {1, 2, 3});
CabinetLoadingEvidenceEntity persisted = evidence("PHOTO_STEP_1");
persisted.setId(401L); persisted.setSourceTaskId(20L); persisted.setFileId(301L);
persisted.setOperationKey("photo-replay");
persisted.setRequestHash(HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest("request".getBytes(StandardCharsets.UTF_8))));
persisted.setVersion(0);
when(mapper.byOperation(20L, "photo-replay")).thenReturn(null, null, persisted);
when(mapper.byContainer(99L)).thenReturn(List.of(persisted));
var upload = new LoadingEvidenceFileClient.UploadResult(301L, "PLATFORM", 0L, var upload = new LoadingEvidenceFileClient.UploadResult(301L, "PLATFORM", 0L,
"LOADING_PHOTO_STEP_1", "99", "photo.png", "image/png", 5L); "LOADING_PHOTO_STEP_1", "99", "photo.png", "image/png", 3L);
when(files.upload(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.eq(9L),
org.mockito.ArgumentMatchers.eq(99L), org.mockito.ArgumentMatchers.eq("PHOTO_STEP_1"),
org.mockito.ArgumentMatchers.any())).thenReturn(com.baiyue.common.core.api.ApiResponse.success(upload));
var metadata = new LoadingEvidenceFileClient.FileMetadata(301L, "PLATFORM", 0L, var metadata = new LoadingEvidenceFileClient.FileMetadata(301L, "PLATFORM", 0L,
"LOADING_PHOTO_STEP_1", "99", "photo.png", "image/png"); "LOADING_PHOTO_STEP_1", "99", "photo.png", "image/png");
when(files.validate(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.eq(99L), when(files.upload(anyString(), anyLong(), anyLong(), anyString(), any()))
org.mockito.ArgumentMatchers.eq("PHOTO_STEP_1"), org.mockito.ArgumentMatchers.eq(301L))) .thenReturn(com.baiyue.common.core.api.ApiResponse.success(upload));
when(files.validate(anyString(), anyLong(), anyString(), anyLong()))
.thenReturn(com.baiyue.common.core.api.ApiResponse.success(metadata)); .thenReturn(com.baiyue.common.core.api.ApiResponse.success(metadata));
var file = new MockMultipartFile("file", "photo.png", "image/png", "photo".getBytes(StandardCharsets.UTF_8)); when(mapper.byContainer(99L)).thenReturn(List.of());
service.upload(20L, 8L, "photo-replay", 0, "PHOTO_STEP_1", null, "remark", file, 9L, "operator"); service.upload(20L, 8L, "same-key", 0, "PHOTO_STEP_1", null, "remark",
service.upload(20L, 8L, "photo-replay", 0, "PHOTO_STEP_1", null, "remark", file, 9L, "operator"); multipart, 9L, "operator");
verify(files, times(1)).upload(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.eq(9L), ArgumentCaptor<CabinetLoadingEvidenceEntity> inserted =
org.mockito.ArgumentMatchers.eq(99L), org.mockito.ArgumentMatchers.eq("PHOTO_STEP_1"), ArgumentCaptor.forClass(CabinetLoadingEvidenceEntity.class);
org.mockito.ArgumentMatchers.any()); verify(mapper).insert(inserted.capture());
verify(mapper, times(1)).insert(org.mockito.ArgumentMatchers.any(CabinetLoadingEvidenceEntity.class)); when(mapper.byOperation(20L, "same-key")).thenReturn(inserted.getValue());
when(mapper.byContainer(99L)).thenReturn(List.of(inserted.getValue()));
var replay = service.upload(20L, 8L, "same-key", 0, "PHOTO_STEP_1", null, "remark",
multipart, 9L, "operator");
assertThat(replay.evidences()).hasSize(1);
verify(files, times(1)).upload(anyString(), anyLong(), anyLong(), anyString(), any());
} }
private static CabinetLoadingEvidenceEntity evidence(String type) { private static CabinetLoadingEvidenceEntity evidence(String type) {
@@ -58,6 +58,10 @@ class SmartLoadingCandidateMapperContractTest {
.contains("SELECT SUM(li.package_count)") .contains("SELECT SUM(li.package_count)")
.contains("li.deleted=0") .contains("li.deleted=0")
.contains("ORDER BY o.inbounded_at ASC, o.id ASC, i.inbound_detail_id ASC"); .contains("ORDER BY o.inbounded_at ASC, o.id ASC, i.inbound_detail_id ASC");
assertThat(sql.indexOf("NULL blockReason"))
.as("record constructor projection must keep status fields in their trailing positions")
.isLessThan(sql.indexOf("o.order_status_snapshot orderStatusSnapshot"));
} }
@Test @Test
@@ -90,6 +94,10 @@ class SmartLoadingCandidateMapperContractTest {
.contains("source.fulfillment_type='ONE_CLICK_DROPSHIP'") .contains("source.fulfillment_type='ONE_CLICK_DROPSHIP'")
.contains("i.inbound_id=o.id") .contains("i.inbound_id=o.id")
.contains("FOR UPDATE"); .contains("FOR UPDATE");
assertThat(sql.indexOf("NULL blockReason"))
.as("record constructor projection must keep status fields in their trailing positions")
.isLessThan(sql.indexOf("o.order_status_snapshot orderStatusSnapshot"));
} }
@Test @Test
@@ -46,7 +46,8 @@ class FinanceG1G2PermissionSqlContractTest {
@Test @Test
void unifiedSqlMirrorsCatalogAndGrantsTheFinanceCatalogToPlatformAdmin() throws Exception { void unifiedSqlMirrorsCatalogAndGrantsTheFinanceCatalogToPlatformAdmin() throws Exception {
String sql = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8); String sql = Files.readString(resolveUnifiedSql(), StandardCharsets.UTF_8)
.replace("\r\n", "\n");
String financeSection = sql.substring(sql.indexOf("-- Finance F1 人工财务权限"), String financeSection = sql.substring(sql.indexOf("-- Finance F1 人工财务权限"),
sql.indexOf("-- Admin 原型菜单与财务五页权限补充")); sql.indexOf("-- Admin 原型菜单与财务五页权限补充"));
@@ -1,11 +1,14 @@
package com.baiyue.iam.service; package com.baiyue.iam.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionRequest; import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionRequest;
import com.baiyue.iam.dto.OnboardingDtos.RequiredQualificationPackageRequest; import com.baiyue.iam.dto.OnboardingDtos.RequiredQualificationPackageRequest;
import com.baiyue.iam.entity.IamTenant; import com.baiyue.iam.entity.IamTenant;
import com.baiyue.iam.entity.IamTenantQualification;
import com.baiyue.iam.repository.ProfileResourceRepository; import com.baiyue.iam.repository.ProfileResourceRepository;
import com.baiyue.iam.repository.TenantRepository; import com.baiyue.iam.repository.TenantRepository;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
@@ -22,7 +25,6 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import com.baiyue.common.core.exception.BizException;
@SpringJUnitConfig(ProfileResourceService.class) @SpringJUnitConfig(ProfileResourceService.class)
class MerchantQualificationUploadTest { class MerchantQualificationUploadTest {
@@ -49,6 +51,38 @@ class MerchantQualificationUploadTest {
verify(tenantRepository, never()).insertReview(any()); verify(tenantRepository, never()).insertReview(any());
} }
@Test
void replacesOnlyTheLegalPersonIdWithoutTouchingTheBusinessLicense() {
IamTenant tenant = new IamTenant();
tenant.setId(10001L);
tenant.setTenantType("MERCHANT");
tenant.setStatus("REJECTED");
when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant);
IamTenantQualification businessLicense = qualification(
"BUSINESS_LICENSE", "/api/merchant/v1/files/qualifications/license.png");
IamTenantQualification oldLegalPersonId = qualification(
"LEGAL_PERSON_ID", "/api/merchant/v1/files/qualifications/old-legal-person.png");
when(profileRepository.findQualificationsForUpdate(10001L))
.thenReturn(List.of(businessLicense, oldLegalPersonId));
var response = service.submitQualification(new QualificationSubmissionRequest(
10001L, "LEGAL_PERSON_ID", null,
"/api/merchant/v1/files/qualifications/new-legal-person.png",
LocalDate.of(2035, 12, 31), true));
assertEquals("/api/merchant/v1/files/qualifications/old-legal-person.png",
response.replacedDocumentUrl());
assertEquals("SUPERSEDED", oldLegalPersonId.getStatus());
assertEquals("PENDING", businessLicense.getStatus());
ArgumentCaptor<IamTenantQualification> captor = ArgumentCaptor.forClass(IamTenantQualification.class);
verify(profileRepository, times(2)).saveQualification(captor.capture());
IamTenantQualification replacement = captor.getAllValues().get(1);
assertEquals("LEGAL_PERSON_ID", replacement.getQualificationType());
assertEquals(LocalDate.of(2035, 12, 31), replacement.getValidTo());
assertNull(replacement.getCertificateNo());
verify(tenantRepository, never()).saveTenant(any());
}
@Test @Test
void requiredQualificationsRejectTheLegacySingleFilePath() { void requiredQualificationsRejectTheLegacySingleFilePath() {
IamTenant tenant = new IamTenant(); IamTenant tenant = new IamTenant();
@@ -118,4 +152,14 @@ class MerchantQualificationUploadTest {
verify(profileRepository, never()).insertQualification(any()); verify(profileRepository, never()).insertQualification(any());
} }
private static IamTenantQualification qualification(String type, String documentUrl) {
IamTenantQualification qualification = new IamTenantQualification();
qualification.setId(1L);
qualification.setTenantId(10001L);
qualification.setQualificationType(type);
qualification.setDocumentUrl(documentUrl);
qualification.setStatus("PENDING");
return qualification;
}
} }
@@ -297,8 +297,11 @@ public class UnifiedOrderQueryService {
order.getCreatedAt(), order.getUpdatedAt(), order.getRequestedPickupAt()); order.getCreatedAt(), order.getUpdatedAt(), order.getRequestedPickupAt());
} }
FirstlegUnifiedOrderClient.Inbound inbound = summary.inbound(); FirstlegUnifiedOrderClient.Inbound inbound = summary.inbound();
boolean pendingReview = order.getStatus() == OrderStatus.SUBMITTED;
String mainStatus = pendingReview ? "PENDING_REVIEW" : summary.mainStatus();
String nextAction = pendingReview ? "APPROVE_ORDER" : summary.nextAction();
return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(), return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(),
summary.mainStatus(), summary.nextAction(), relation(order, childCounts), order.getStatus(), mainStatus, nextAction, relation(order, childCounts), order.getStatus(),
order.getCustomerId(), order.getCustomerName(), order.getCustomerId(), order.getCustomerName(),
order.getChannelCode(), order.getChannelName(), Boolean.TRUE.equals(order.getChannelChanged()), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(), order.getChannelCode(), order.getChannelName(), Boolean.TRUE.equals(order.getChannelChanged()), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(),
order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), inbound.id(), inbound.version(), inbound.inboundCode(), order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), inbound.id(), inbound.version(), inbound.inboundCode(),
@@ -81,6 +81,26 @@ class UnifiedOrderQueryServiceTest {
verify(firstleg, never()).summaries(any(), any()); verify(firstleg, never()).summaries(any(), any());
} }
@Test
void submittedOrderRemainsPendingReviewWhenPreparingInboundAlreadyExists() {
UnifiedOrderQuery query = query(null);
OrderEntity order = order(OrderStatus.SUBMITTED);
var summary = summary(null, null);
when(summary.inbound().processStatus()).thenReturn("PREPARING");
when(summary.inbound().orderStatusSnapshot()).thenReturn("SUBMITTED");
when(orders.unifiedPage(eq(10001L), eq(query), any()))
.thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1));
when(firstleg.summaries(eq("internal-token"), any()))
.thenReturn(ApiResponse.success(List.of(summary)));
assertThat(service.page(10001L, query).records()).singleElement().satisfies(row -> {
assertThat(row.mainStatus()).isEqualTo("PENDING_REVIEW");
assertThat(row.nextAction()).isEqualTo("APPROVE_ORDER");
assertThat(row.inboundId()).isEqualTo(88L);
assertThat(row.internalOrderStatus()).isEqualTo(OrderStatus.SUBMITTED);
});
}
@Test @Test
void newlyCreatedOrderUsesItsOwnCargoPackageCountBeforeInboundExists() { void newlyCreatedOrderUsesItsOwnCargoPackageCountBeforeInboundExists() {
UnifiedOrderQuery query = query(null); UnifiedOrderQuery query = query(null);
+35 -17
View File
@@ -336,7 +336,8 @@ export function parseArguments(argv) {
channelCode: values.get('channel-code') || '', channelCode: values.get('channel-code') || '',
orderProfileMode: values.get('order-profile-mode') || 'fixed', orderProfileMode: values.get('order-profile-mode') || 'fixed',
waitSplitCount: Number(values.get('wait-split-count') || process.env.ADMIN_WAIT_SPLIT_COUNT waitSplitCount: Number(values.get('wait-split-count') || process.env.ADMIN_WAIT_SPLIT_COUNT
|| (modules.includes('manual-order-splitting-fixture') ? 6 : 1)), || (modules.includes('coload-groups') ? 8
: modules.includes('manual-order-splitting-fixture') ? 6 : 1)),
waitSplitPackageCount: Number(values.get('wait-split-package-count') || 0), waitSplitPackageCount: Number(values.get('wait-split-package-count') || 0),
waitSplitSequence: Number(values.get('wait-split-sequence') || 1), waitSplitSequence: Number(values.get('wait-split-sequence') || 1),
waitSplitRandomValue: Number(values.get('wait-split-random-value') || values.get('wait-split-sequence') || 1), waitSplitRandomValue: Number(values.get('wait-split-random-value') || values.get('wait-split-sequence') || 1),
@@ -522,9 +523,12 @@ export class AcceptanceRun {
const headers = { Accept: 'application/json', ...extraHeaders } const headers = { Accept: 'application/json', ...extraHeaders }
if (token) headers.Authorization = `Bearer ${token}` if (token) headers.Authorization = `Bearer ${token}`
if (body !== undefined) headers['Content-Type'] = 'application/json; charset=utf-8' if (body !== undefined) headers['Content-Type'] = 'application/json; charset=utf-8'
const requestBody = path === '/api/auth/login' && body?.username && !body?.loginId
? { ...body, loginId: body.username, username: undefined }
: body
try { try {
const response = await fetch(`${this.options.gateway}${path}`, { const response = await fetch(`${this.options.gateway}${path}`, {
method, headers, body: body === undefined ? undefined : JSON.stringify(body), method, headers, body: requestBody === undefined ? undefined : JSON.stringify(requestBody),
signal: AbortSignal.timeout(30_000), signal: AbortSignal.timeout(30_000),
}) })
const content = await response.text() const content = await response.text()
@@ -824,7 +828,7 @@ export class AcceptanceRun {
this.context.phase3LoadingIncrementalPackageCount = variant.packageCountPerSku this.context.phase3LoadingIncrementalPackageCount = variant.packageCountPerSku
const profile = this.options.orderProfileMode === 'randomized' const profile = this.options.orderProfileMode === 'randomized'
? this.randomizedOrderProfile(sequence) ? this.randomizedOrderProfile(sequence)
: { destinationType: 'FBA', fulfillmentType: 'STANDARD', pickupMethod: 'PICKUP', : { destinationType: 'FBA', fulfillmentType: 'STANDARD', pickupMethod: 'SELF_EXPRESS',
taxMode: 'BUY_DECLARATION', containerMode: 'LCL', customsInspection: false } taxMode: 'BUY_DECLARATION', containerMode: 'LCL', customsInspection: false }
this.context.orderProfile = profile this.context.orderProfile = profile
this.context.orderDestinationType = profile.destinationType this.context.orderDestinationType = profile.destinationType
@@ -893,13 +897,12 @@ export class AcceptanceRun {
} }
async findAvailablePhone(module = 'customers') { async findAvailablePhone(module = 'customers') {
const seed = BigInt(Date.now() % 10_000_000_000) this.phoneSequence = Number(this.phoneSequence || 0) + 1
for (let attempt = 0; attempt < 20; attempt += 1) { const markerDigits = this.marker.replace(/\D/g, '').slice(-8).padStart(8, '0')
const phone = `1${((seed + BigInt(attempt)) % 10_000_000_000n).toString().padStart(10, '0')}` const phone = `18${markerDigits}${String(this.phoneSequence % 10).padStart(1, '0')}`
const response = await this.request('GET', `/api/platform/v1/iam/tenants/contact-phone-availability?contactPhone=${phone}`) this.addStep(module, '生成本轮隔离测试联系电话', null, /^1\d{10}$/.test(phone),
if (this.apiSuccess(module, '检查测试联系电话可用性', response, `attempt=${attempt + 1}`) === true) return phone `sequence=${this.phoneSequence}`)
} return phone
throw new Error('连续 20 个测试联系电话均不可用')
} }
async runCustomerDimensionRules(tenantId) { async runCustomerDimensionRules(tenantId) {
@@ -1057,15 +1060,30 @@ export class AcceptanceRun {
&& /[A-Za-z]/.test(createData.temporaryPassword) && /\d/.test(createData.temporaryPassword), && /[A-Za-z]/.test(createData.temporaryPassword) && /\d/.test(createData.temporaryPassword),
`generated=${typeof createData.temporaryPassword === 'string'}, length=${createData.temporaryPassword?.length || 0}`, `generated=${typeof createData.temporaryPassword === 'string'}, length=${createData.temporaryPassword?.length || 0}`,
ownerProvisioningOptions) ownerProvisioningOptions)
this.assert(module, '管理员用户名默认使用联系电话', createResponse, const administratorUsername = String(createData.administratorUsername || '')
createData.administratorUsername === phone, this.assert(module, '管理员用户名作为独立登录标识返回', createResponse,
`matches=${createData.administratorUsername === phone}`, administratorUsername.length > 0,
`present=${administratorUsername.length > 0}`,
ownerProvisioningOptions) ownerProvisioningOptions)
this.assert(module, '待完善客户的审核字段保持为空', createResponse, this.assert(module, '待完善客户的审核字段保持为空', createResponse,
customer.unifiedSocialCreditCode == null && customer.approvedAt == null, customer.unifiedSocialCreditCode == null && customer.approvedAt == null,
`creditCode=${customer.unifiedSocialCreditCode ?? 'omitted'}, approvedAt=${customer.approvedAt ?? 'omitted'}`) `creditCode=${customer.unifiedSocialCreditCode ?? 'omitted'}, approvedAt=${customer.approvedAt ?? 'omitted'}`)
await this.runCustomerDimensionRules(tenantId) const dimensionInitializationPath = `/api/platform/v1/iam/tenants/${tenantId}/dimension-initialization`
let dimensionInitializationResponse = null
let dimensionInitialization = null
for (let attempt = 0; attempt < 30; attempt += 1) {
dimensionInitializationResponse = await this.request('GET', dimensionInitializationPath)
dimensionInitialization = dimensionInitializationResponse.http === 200
&& String(dimensionInitializationResponse.body?.code) === '200'
? dimensionInitializationResponse.body?.data : null
if (dimensionInitialization?.status === 'READY') break
if (attempt < 29) await new Promise((resolve) => setTimeout(resolve, 1_000))
}
this.assert(module, '客户默认尺寸异步初始化完成', dimensionInitializationResponse,
dimensionInitialization?.status === 'READY',
`status=${dimensionInitialization?.status || 'missing'}`, ownerProvisioningOptions)
if (dimensionInitialization?.status === 'READY') await this.runCustomerDimensionRules(tenantId)
const listResponse = await this.request('GET', `/api/platform/v1/iam/tenants?page=1&size=20&keyword=${encodeURIComponent(payload.tenantName)}`) const listResponse = await this.request('GET', `/api/platform/v1/iam/tenants?page=1&size=20&keyword=${encodeURIComponent(payload.tenantName)}`)
const page = this.apiSuccess(module, '查询客户列表', listResponse); const listed = page?.records?.find((item) => String(item.id) === tenantId) const page = this.apiSuccess(module, '查询客户列表', listResponse); const listed = page?.records?.find((item) => String(item.id) === tenantId)
@@ -1135,9 +1153,9 @@ export class AcceptanceRun {
} }
const memberResponse = await this.request('GET', `${detailPath}/members`); const members = this.apiSuccess(module, '业务概况查询成员', memberResponse) const memberResponse = await this.request('GET', `${detailPath}/members`); const members = this.apiSuccess(module, '业务概况查询成员', memberResponse)
const owner = members?.find?.((item) => item.memberType === 'OWNER') const owner = members?.find?.((item) => item.memberType === 'OWNER')
this.assert(module, '联系电话账号是企业管理员', memberResponse, this.assert(module, '独立用户名账号是企业管理员且联系电话不承担用户名约束', memberResponse,
owner?.username === phone, owner?.username === administratorUsername,
`ownerFound=${Boolean(owner)}, matches=${owner?.username === phone}`, `ownerFound=${Boolean(owner)}, matches=${owner?.username === administratorUsername}`,
ownerProvisioningOptions) ownerProvisioningOptions)
const countResponse = await this.request('GET', `/api/platform/v1/orders/first-leg/tenant-counts?tenantIds=${tenantId}`); const counts = this.apiSuccess(module, '业务概况查询运单数量', countResponse); const count = counts?.find?.((item) => String(item.tenantId) === tenantId) const countResponse = await this.request('GET', `/api/platform/v1/orders/first-leg/tenant-counts?tenantIds=${tenantId}`); const counts = this.apiSuccess(module, '业务概况查询运单数量', countResponse); const count = counts?.find?.((item) => String(item.tenantId) === tenantId)
this.assert(module, '业务概况返回当前客户运单数', countResponse, count && Number.isFinite(Number(count.orderCount)), `orderCount=${count?.orderCount ?? 'missing'}`) this.assert(module, '业务概况返回当前客户运单数', countResponse, count && Number.isFinite(Number(count.orderCount)), `orderCount=${count?.orderCount ?? 'missing'}`)
@@ -9,7 +9,7 @@ test('同柜不可拆组HTTP验收锁定公开接口与防绕过矩阵', async (
'/api/platform/v1/first-leg/coload-groups/by-inbound-code/', '/api/platform/v1/first-leg/coload-groups/by-inbound-code/',
'/group-assignments', '/group-assignments',
'/inbound-assignments', '/inbound-assignments',
'/push-preload', '/plans/by-booking/',
'创建同键同载荷精确重放', '创建同键同载荷精确重放',
'创建同键异载荷返回并发冲突', '创建同键异载荷返回并发冲突',
'不同目的仓港仍可创建同柜不可拆组', '不同目的仓港仍可创建同柜不可拆组',
@@ -31,7 +31,7 @@ test('同柜不可拆组HTTP验收锁定公开接口与防绕过矩阵', async (
'短拣完成拒绝后任务、父子树和组锁零漂移', '短拣完成拒绝后任务、父子树和组锁零漂移',
'同柜组足量完成命令可靠受理', '同柜组足量完成命令可靠受理',
'足量完成整组已消费、全部入库完成且不生成余量子单', '足量完成整组已消费、全部入库完成且不生成余量子单',
'未成组普通单推送预装不回归', '未成组普通单自动预装不回归',
'未成组普通单仍按既有规则生成唯一余量子单', '未成组普通单仍按既有规则生成唯一余量子单',
'拆分余量子单仍可与同仓同渠道订单创建同柜组', '拆分余量子单仍可与同仓同渠道订单创建同柜组',
'拆分身份不影响当前WAIT_SPLIT片段完整纳组', '拆分身份不影响当前WAIT_SPLIT片段完整纳组',
@@ -42,4 +42,5 @@ test('同柜不可拆组HTTP验收锁定公开接口与防绕过矩阵', async (
assert.doesNotMatch(source, /跨仓库渠道或目的仓港创建整组被拒绝/) assert.doesNotMatch(source, /跨仓库渠道或目的仓港创建整组被拒绝/)
assert.doesNotMatch(source, /已有父子拆分订单创建同柜组被拒绝/) assert.doesNotMatch(source, /已有父子拆分订单创建同柜组被拒绝/)
assert.doesNotMatch(source, /push-preload/)
}) })
@@ -99,10 +99,8 @@ async function createAssignedPushedPlan(context, fixture, groupId, suffix) {
evidence(simulationResponse), OPTIONS) evidence(simulationResponse), OPTIONS)
const bookingId = id(simulation?.id) const bookingId = id(simulation?.id)
if (!bookingId) return null if (!bookingId) return null
const planResponse = await context.request('POST', `${SMART}/plans`, { const planResponse = await context.request('GET', `${SMART}/plans/by-booking/${encodeURIComponent(bookingId)}`)
bookingId, remark: `${context.marker}-${suffix}`, let plan = context.apiSuccess(MODULE, `${suffix} 读取模拟柜自动创建的组装柜计划`, planResponse,
})
let plan = context.apiSuccess(MODULE, `${suffix} 创建组装柜计划`, planResponse,
evidence(planResponse), OPTIONS) evidence(planResponse), OPTIONS)
const planId = id(plan?.id) const planId = id(plan?.id)
if (!planId) return null if (!planId) return null
@@ -112,10 +110,8 @@ async function createAssignedPushedPlan(context, fixture, groupId, suffix) {
}) })
plan = context.apiSuccess(MODULE, `${suffix} 整组原子分配`, assignResponse, plan = context.apiSuccess(MODULE, `${suffix} 整组原子分配`, assignResponse,
evidence(assignResponse, `groupId=${groupId}`), OPTIONS) evidence(assignResponse, `groupId=${groupId}`), OPTIONS)
const pushResponse = await context.request('POST', `${SMART}/plans/${encodeURIComponent(planId)}/push-preload`, { const pushResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(planId)}`)
expectedVersion: Number(plan?.version), const pushed = context.apiSuccess(MODULE, `${suffix} 整组分配后自动建立预装关联`, pushResponse,
}, undefined, { 'Idempotency-Key': `${context.marker}-${suffix}-push`.slice(0, 128) })
const pushed = context.apiSuccess(MODULE, `${suffix} 整组完整推送预装`, pushResponse,
evidence(pushResponse), OPTIONS) evidence(pushResponse), OPTIONS)
return pushed ? { containerBookingId: bookingId, planId, plan: pushed, simulation } : null return pushed ? { containerBookingId: bookingId, planId, plan: pushed, simulation } : null
} }
@@ -129,10 +125,8 @@ async function createNormalPushedPlan(context, fixture, suffix) {
evidence(simulationResponse), OPTIONS) evidence(simulationResponse), OPTIONS)
const containerBookingId = id(simulation?.id) const containerBookingId = id(simulation?.id)
if (!containerBookingId) return null if (!containerBookingId) return null
const planResponse = await context.request('POST', `${SMART}/plans`, { const planResponse = await context.request('GET', `${SMART}/plans/by-booking/${encodeURIComponent(containerBookingId)}`)
bookingId: containerBookingId, remark: `${context.marker}-${suffix}`, let plan = context.apiSuccess(MODULE, `${suffix} 读取模拟柜自动创建的普通单装柜计划`, planResponse,
})
let plan = context.apiSuccess(MODULE, `${suffix} 创建普通单装柜计划`, planResponse,
evidence(planResponse), OPTIONS) evidence(planResponse), OPTIONS)
const planId = id(plan?.id) const planId = id(plan?.id)
if (!planId) return null if (!planId) return null
@@ -149,10 +143,8 @@ async function createNormalPushedPlan(context, fixture, suffix) {
context.assert(MODULE, `${suffix} 普通单维持既有部分分配语义`, assignResponse, context.assert(MODULE, `${suffix} 普通单维持既有部分分配语义`, assignResponse,
assignedTotal === assignedCount && assignedCount < Number(fixture.packages), assignedTotal === assignedCount && assignedCount < Number(fixture.packages),
`assigned=${assignedTotal}, source=${fixture.packages}`, OPTIONS) `assigned=${assignedTotal}, source=${fixture.packages}`, OPTIONS)
const pushResponse = await context.request('POST', `${SMART}/plans/${encodeURIComponent(planId)}/push-preload`, { const pushResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(planId)}`)
expectedVersion: Number(plan?.version), const pushed = context.apiSuccess(MODULE, `${suffix} 未成组普通单自动预装不回归`, pushResponse,
}, undefined, { 'Idempotency-Key': `${context.marker}-${suffix}-push`.slice(0, 128) })
const pushed = context.apiSuccess(MODULE, `${suffix} 未成组普通单推送预装不回归`, pushResponse,
evidence(pushResponse), OPTIONS) evidence(pushResponse), OPTIONS)
return pushed ? { containerBookingId, planId, plan: pushed, simulation, assignedCount } : null return pushed ? { containerBookingId, planId, plan: pushed, simulation, assignedCount } : null
} }
@@ -344,7 +336,7 @@ async function createRouteVariantFixture(context, base, variant) {
context.marker = `${originalMarker}-${variant === 'DESTINATION' ? 'DEST' : 'ROUTE'}-${attempt}` context.marker = `${originalMarker}-${variant === 'DESTINATION' ? 'DEST' : 'ROUTE'}-${attempt}`
context.context.orderProfile = { context.context.orderProfile = {
destinationType: variant === 'DESTINATION' && attempt >= 3 ? 'OVERSEAS' : 'FBA', fulfillmentType: 'STANDARD', destinationType: variant === 'DESTINATION' && attempt >= 3 ? 'OVERSEAS' : 'FBA', fulfillmentType: 'STANDARD',
pickupMethod: 'PICKUP', taxMode: 'BUY_DECLARATION', containerMode: 'LCL', customsInspection: false, pickupMethod: 'SELF_EXPRESS', taxMode: 'BUY_DECLARATION', containerMode: 'LCL', customsInspection: false,
} }
context.context.orderDestinationType = context.context.orderProfile.destinationType context.context.orderDestinationType = context.context.orderProfile.destinationType
context.context.orderFulfillmentType = 'STANDARD' context.context.orderFulfillmentType = 'STANDARD'
@@ -510,10 +502,8 @@ export async function run(context) {
evidence(simulationResponse), OPTIONS) evidence(simulationResponse), OPTIONS)
let bookingId = id(simulation?.id) let bookingId = id(simulation?.id)
if (!bookingId) return if (!bookingId) return
const planResponse = await context.request('POST', '/api/platform/v1/first-leg/smart-loading/plans', { const planResponse = await context.request('GET', `${SMART}/plans/by-booking/${encodeURIComponent(bookingId)}`)
bookingId, remark: `${context.marker} 同柜不可拆组验收`, let plan = context.apiSuccess(MODULE, '读取容量拒绝专用自动空计划', planResponse,
})
let plan = context.apiSuccess(MODULE, '创建容量拒绝专用空计划', planResponse,
evidence(planResponse, `bookingId=${bookingId}`), OPTIONS) evidence(planResponse, `bookingId=${bookingId}`), OPTIONS)
let planId = id(plan?.id) let planId = id(plan?.id)
if (!planId) return if (!planId) return
@@ -580,10 +570,8 @@ export async function run(context) {
recommendSimulationResponse, evidence(recommendSimulationResponse), OPTIONS) recommendSimulationResponse, evidence(recommendSimulationResponse), OPTIONS)
const recommendBookingId = id(recommendSimulation?.id) const recommendBookingId = id(recommendSimulation?.id)
if (!recommendBookingId) return if (!recommendBookingId) return
const recommendPlanResponse = await context.request('POST', `${SMART}/plans`, { const recommendPlanResponse = await context.request('GET', `${SMART}/plans/by-booking/${encodeURIComponent(recommendBookingId)}`)
bookingId: recommendBookingId, remark: `${context.marker} 智能推荐整组全取`, let recommendPlan = context.apiSuccess(MODULE, '读取智能推荐整组成功专用自动计划', recommendPlanResponse,
})
let recommendPlan = context.apiSuccess(MODULE, '创建智能推荐整组成功专用计划', recommendPlanResponse,
evidence(recommendPlanResponse), OPTIONS) evidence(recommendPlanResponse), OPTIONS)
const recommendPlanId = id(recommendPlan?.id) const recommendPlanId = id(recommendPlan?.id)
if (!recommendPlanId) return if (!recommendPlanId) return
@@ -628,10 +616,8 @@ export async function run(context) {
evidence(workingSimulationResponse), OPTIONS) evidence(workingSimulationResponse), OPTIONS)
bookingId = id(workingSimulation?.id) bookingId = id(workingSimulation?.id)
if (!bookingId) return if (!bookingId) return
const workingPlanResponse = await context.request('POST', `${SMART}/plans`, { const workingPlanResponse = await context.request('GET', `${SMART}/plans/by-booking/${encodeURIComponent(bookingId)}`)
bookingId, remark: `${context.marker} 同柜不可拆组PDA主链`, plan = context.apiSuccess(MODULE, '读取PDA主链自动创建的同柜装柜计划', workingPlanResponse,
})
plan = context.apiSuccess(MODULE, '创建PDA主链同柜装柜计划', workingPlanResponse,
evidence(workingPlanResponse, `bookingId=${bookingId}`), OPTIONS) evidence(workingPlanResponse, `bookingId=${bookingId}`), OPTIONS)
planId = id(plan?.id) planId = id(plan?.id)
if (!planId) return if (!planId) return
@@ -718,12 +704,8 @@ export async function run(context) {
evidence(manualGroupResponse), OPTIONS) evidence(manualGroupResponse), OPTIONS)
if (!plan) return if (!plan) return
const pushKey = `${context.marker}-coload-push`.slice(0, 128) const pushResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(planId)}`)
const pushResponse = await context.request('POST', const pushed = context.apiSuccess(MODULE, '整组完整时自动预装关联可读', pushResponse,
`/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(planId)}/push-preload`, {
expectedVersion: Number(plan.version),
}, undefined, { 'Idempotency-Key': pushKey })
const pushed = context.apiSuccess(MODULE, '整组完整时推送预装成功', pushResponse,
evidence(pushResponse), OPTIONS) evidence(pushResponse), OPTIONS)
const pushedTotals = new Map() const pushedTotals = new Map()
for (const item of pushed?.items || []) pushedTotals.set(id(item?.inboundId), for (const item of pushed?.items || []) pushedTotals.set(id(item?.inboundId),
@@ -956,10 +938,9 @@ export async function run(context) {
const uiSimulation = context.apiSuccess(MODULE, '为Admin页面保留可编辑智能装柜柜次', uiSimulationResponse, const uiSimulation = context.apiSuccess(MODULE, '为Admin页面保留可编辑智能装柜柜次', uiSimulationResponse,
evidence(uiSimulationResponse), OPTIONS) evidence(uiSimulationResponse), OPTIONS)
const uiBookingId = id(uiSimulation?.id) const uiBookingId = id(uiSimulation?.id)
const uiPlanResponse = uiBookingId ? await context.request('POST', `${SMART}/plans`, { const uiPlanResponse = uiBookingId
bookingId: uiBookingId, remark: `${context.marker} Admin整组候选人工验收保留`, ? await context.request('GET', `${SMART}/plans/by-booking/${encodeURIComponent(uiBookingId)}`) : null
}) : null const uiPlan = uiPlanResponse ? context.apiSuccess(MODULE, '为Admin页面保留自动创建的未分配空计划', uiPlanResponse,
const uiPlan = uiPlanResponse ? context.apiSuccess(MODULE, '为Admin页面保留未分配空计划', uiPlanResponse,
evidence(uiPlanResponse, `bookingId=${uiBookingId}`), OPTIONS) : null evidence(uiPlanResponse, `bookingId=${uiBookingId}`), OPTIONS) : null
const uiMembers = memberFacts(uiGroup) const uiMembers = memberFacts(uiGroup)
context.assert(MODULE, '页面验收保留组仍为ACTIVE且未锁定', uiGroupResponse, context.assert(MODULE, '页面验收保留组仍为ACTIVE且未锁定', uiGroupResponse,
@@ -7,7 +7,7 @@ const source = readFileSync(new URL('./manual-state-loading-pda.mjs', import.met
test('retains all clickable container, loading-list and PDA workflow states through public APIs', () => { test('retains all clickable container, loading-list and PDA workflow states through public APIs', () => {
for (const status of ['WAIT_LOADING', 'EDITING', 'LOADING', 'COMPLETED', for (const status of ['WAIT_LOADING', 'EDITING', 'LOADING', 'COMPLETED',
'UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW']) assert.match(source, new RegExp(`['"]${status}['"]`)) 'UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW']) assert.match(source, new RegExp(`['"]${status}['"]`))
for (const endpoint of ['/ocean-bookings', '/smart-loading', '/push-preload', '/inventory-snapshot', for (const endpoint of ['/ocean-bookings', '/smart-loading', '/plans/by-booking/', '/inventory-snapshot',
'/batch', '/complete', '/loading-plans', '/loading-lists']) assert.match(source, new RegExp(endpoint)) '/batch', '/complete', '/loading-plans', '/loading-lists']) assert.match(source, new RegExp(endpoint))
assert.match(source, /requiredStatuses/) assert.match(source, /requiredStatuses/)
assert.match(source, /detailPath/) assert.match(source, /detailPath/)
@@ -18,7 +18,7 @@ test('retains all clickable container, loading-list and PDA workflow states thro
assert.match(source, /\.\/inbound-measurement\.mjs/) assert.match(source, /\.\/inbound-measurement\.mjs/)
assert.match(source, /context\.request\('POST', CONTAINERS/) assert.match(source, /context\.request\('POST', CONTAINERS/)
assert.match(source, /summary\?\.processStatus === 'WAIT_SPLIT'/) assert.match(source, /summary\?\.processStatus === 'WAIT_SPLIT'/)
const pushIndex = source.indexOf('`${SMART}/plans/${plan.id}/push-preload`') const pushIndex = source.indexOf('`${SMART}/plans/${plan.id}`')
const transportIndex = source.indexOf('ocean-booking-link') const transportIndex = source.indexOf('ocean-booking-link')
assert.ok(transportIndex >= 0 && pushIndex > transportIndex) assert.ok(transportIndex >= 0 && pushIndex > transportIndex)
const unconfirmedTaskIndex = source.indexOf('`${PDA}/tasks?loadingPlanId=${planId}&warehouseId=${source.warehouseId}`', pushIndex) const unconfirmedTaskIndex = source.indexOf('`${PDA}/tasks?loadingPlanId=${planId}&warehouseId=${source.warehouseId}`', pushIndex)
@@ -26,6 +26,7 @@ test('retains all clickable container, loading-list and PDA workflow states thro
const canonicalTaskIndex = source.indexOf('`${PDA}/tasks?loadingPlanId=${planId}&warehouseId=${source.warehouseId}`', unconfirmedTaskIndex + 1) const canonicalTaskIndex = source.indexOf('`${PDA}/tasks?loadingPlanId=${planId}&warehouseId=${source.warehouseId}`', unconfirmedTaskIndex + 1)
assert.ok(unconfirmedTaskIndex > pushIndex && confirmIndex > unconfirmedTaskIndex assert.ok(unconfirmedTaskIndex > pushIndex && confirmIndex > unconfirmedTaskIndex
&& canonicalTaskIndex > confirmIndex) && canonicalTaskIndex > confirmIndex)
assert.doesNotMatch(source, /push-preload/)
assert.match(source, /计划确认前不生成 PDA 任务/) assert.match(source, /计划确认前不生成 PDA 任务/)
assert.match(source, /summary\?\.readiness\?\.canConfirm === true/) assert.match(source, /summary\?\.readiness\?\.canConfirm === true/)
assert.match(source, /Object\.freeze\(\{ expectedVersion: confirmableVersion \}\)/) assert.match(source, /Object\.freeze\(\{ expectedVersion: confirmableVersion \}\)/)
@@ -36,7 +37,7 @@ test('retains all clickable container, loading-list and PDA workflow states thro
assert.doesNotMatch(source, /\/preload\/confirming|\/preload\/confirm\?tenantId=/) assert.doesNotMatch(source, /\/preload\/confirming|\/preload\/confirm\?tenantId=/)
assert.match(source, /linked\?\.cabinetNo !== simulation\.cabinetNo/) assert.match(source, /linked\?\.cabinetNo !== simulation\.cabinetNo/)
assert.match(source, /iso6346ContainerNumber/) assert.match(source, /iso6346ContainerNumber/)
assert.match(source, /shippingCompanyId: id\(agent\.id\), shippingCompanyName: agent\.name/) assert.match(source, /shippingCompanyId: id\(carrier\.id\), shippingCompanyName: carrier\.name/)
assert.match(source, /bookingAgentId: id\(agent\.id\), bookingAgentName: agent\.name/) assert.match(source, /bookingAgentId: id\(agent\.id\), bookingAgentName: agent\.name/)
assert.match(source, /billOfLadingNo: `BL-\$\{value\}`/) assert.match(source, /billOfLadingNo: `BL-\$\{value\}`/)
const batch = source.slice(source.indexOf('async function pickAll'), source.indexOf('async function complete')) const batch = source.slice(source.indexOf('async function pickAll'), source.indexOf('async function complete'))
@@ -56,7 +57,7 @@ test('fails closed when invoked outside the manual matrix wrapper', () => {
assert.match(source, /missing=manualStateMatrix\.pages/) assert.match(source, /missing=manualStateMatrix\.pages/)
}) })
test('covers simplified clearance and customs actions through public multipart upload and complete APIs', () => { test('covers clearance through its public API and keeps customs on the dedicated customs-v1 flow', () => {
assert.match(source, /new FormData\(\)/) assert.match(source, /new FormData\(\)/)
assert.match(source, /form\.append\('file'/) assert.match(source, /form\.append\('file'/)
assert.match(source, /form\.append\('expectedVersion'/) assert.match(source, /form\.append\('expectedVersion'/)
@@ -64,10 +65,9 @@ test('covers simplified clearance and customs actions through public multipart u
assert.match(source, /'Idempotency-Key'/) assert.match(source, /'Idempotency-Key'/)
assert.match(source, /clearancePath\}\/document\/upload/) assert.match(source, /clearancePath\}\/document\/upload/)
assert.match(source, /clearancePath\}\/complete/) assert.match(source, /clearancePath\}\/complete/)
assert.match(source, /customsPath\}\/document\/upload\?\$\{warehouse\}/)
assert.match(source, /customsPath\}\/complete\?\$\{warehouse\}/)
assert.match(source, /matrix\.pages\.containerLoading\.actions/) assert.match(source, /matrix\.pages\.containerLoading\.actions/)
assert.match(source, /matrix\.pages\.loadingLists\.actions/) assert.doesNotMatch(source, /CUSTOMS_DECLARATION|loading-customs|customsPath/)
assert.match(source, /tasks\/\$\{taskId\}\/evidence/)
assert.doesNotMatch(source, /internal\/file|business-files/) assert.doesNotMatch(source, /internal\/file|business-files/)
assert.doesNotMatch(source, /\{\s*fileId\s*:/) assert.doesNotMatch(source, /\{\s*fileId\s*:/)
}) })
@@ -49,31 +49,31 @@ async function uploadDocument(context, path, expectedVersion, operationReason, l
} }
async function ensureCompletionEvidence(context, chain, label) { async function ensureCompletionEvidence(context, chain, label) {
const tenantId = chain.source.tenantId const taskId = id(chain.task?.id)
const inboundId = chain.source.inboundId const warehouseId = id(chain.source?.warehouseId)
const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130]) const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130])
for (const evidenceType of ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']) { for (const evidenceType of ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']) {
const uploadPath = `/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}?evidenceType=${encodeURIComponent(evidenceType)}` const query = new URLSearchParams({ warehouseId, expectedVersion: '0', evidenceType,
remark: context.marker })
if (evidenceType === 'SEAL') query.set('sealNo', `SEAL-${suffix(context, label)}`.slice(0, 64))
const uploadPath = `${PDA}/tasks/${taskId}/evidence?${query}`
const form = new FormData() const form = new FormData()
form.append('file', new Blob([png], { type: 'image/png' }), `${label}-${evidenceType}.png`) form.append('file', new Blob([png], { type: 'image/png' }), `${label}-${evidenceType}.png`)
const rawUpload = await fetch(`${context.options.gateway}${uploadPath}`, { const rawUpload = await fetch(`${context.options.gateway}${uploadPath}`, {
method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` }, method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`,
'Idempotency-Key': operationId(context, `${label}-evidence-${evidenceType}`) },
body: form, signal: AbortSignal.timeout(60_000), body: form, signal: AbortSignal.timeout(60_000),
}) })
const uploadBody = await rawUpload.json() const uploadBody = await rawUpload.json()
const upload = { method: 'POST', path: uploadPath, http: rawUpload.status, body: uploadBody, const upload = { method: 'POST', path: uploadPath, http: rawUpload.status, body: uploadBody,
requestId: uploadBody?.requestId || rawUpload.headers.get('x-request-id'), requestId: uploadBody?.requestId || rawUpload.headers.get('x-request-id'),
traceId: uploadBody?.traceId || rawUpload.headers.get('x-trace-id') } traceId: uploadBody?.traceId || rawUpload.headers.get('x-trace-id') }
const file = context.apiSuccess(MODULE, `${label} 上传 ${evidenceType} 装柜证据`, upload, '', OPTIONS) const evidence = context.apiSuccess(MODULE, `${label} 上传并绑定 ${evidenceType} 装柜证据`, upload, '', OPTIONS)
const fileId = id(file?.id || file?.fileId) const active = evidence?.evidences?.find?.((item) => item.evidenceType === evidenceType
if (!fileId) throw new Error(`${label} ${evidenceType} 未返回文件ID`) && item.status !== 'VOID')
const bind = await post(context, if (!active?.evidenceId || id(evidence?.taskId) !== taskId) {
`/api/platform/v1/inbounds/${inboundId}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, { throw new Error(`${label} ${evidenceType} 未返回任务证据ID`)
expectedVersion: 0, evidenceType, fileId, }
sealNo: evidenceType === 'SEAL' ? `SEAL-${suffix(context, label)}`.slice(0, 64) : null,
remark: context.marker,
}, `${label}-evidence-${evidenceType}`)
context.apiSuccess(MODULE, `${label} 绑定 ${evidenceType} 装柜证据`, bind, '', OPTIONS)
} }
} }
@@ -133,11 +133,14 @@ async function createConfirmedContainer(context, source, label) {
const agentPage = context.apiSuccess(MODULE, `${label} 读取 ACTIVE 订舱代理`, agentResponse, '', OPTIONS) const agentPage = context.apiSuccess(MODULE, `${label} 读取 ACTIVE 订舱代理`, agentResponse, '', OPTIONS)
const agent = agentPage?.records?.find?.((item) => item?.id && item?.name) const agent = agentPage?.records?.find?.((item) => item?.id && item?.name)
if (!agent) throw new Error(`${label} 未取得 ACTIVE 订舱代理`) if (!agent) throw new Error(`${label} 未取得 ACTIVE 订舱代理`)
const { ensureMatsonCarrierPartner } = await import('./carrier-partner-fixture.mjs')
const carrier = await ensureMatsonCarrierPartner(context, MODULE, OPTIONS)
if (!carrier?.id) throw new Error(`${label} 未取得 ACTIVE 船公司`)
const draft = { const draft = {
customerId: source.tenantId, customerName: summary.customerName || 'IT 测试客户', customerId: source.tenantId, customerName: summary.customerName || 'IT 测试客户',
channelCode: summary.channelCode, warehouseId: source.warehouseId, channelCode: summary.channelCode, warehouseId: source.warehouseId,
billOfLadingNo: `BL-${value}`.slice(0, 128), billOfLadingNo: `BL-${value}`.slice(0, 128),
shippingCompanyId: id(agent.id), shippingCompanyName: agent.name, carrierCode: 'OTHER', shippingCompanyId: id(carrier.id), shippingCompanyName: carrier.name, carrierCode: carrier.carrierCode,
bookingAgentId: id(agent.id), bookingAgentName: agent.name, departurePortCode: 'CNSZX', bookingAgentId: id(agent.id), bookingAgentName: agent.name, departurePortCode: 'CNSZX',
vesselName: `人工船-${value}`.slice(0, 128), voyageNo: `V-${value}`.slice(0, 64), vesselName: `人工船-${value}`.slice(0, 128), voyageNo: `V-${value}`.slice(0, 64),
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
@@ -161,8 +164,20 @@ async function createConfirmedContainer(context, source, label) {
{ expectedVersion: 0, data: draft }, `${label}-booking-create`) { expectedVersion: 0, data: draft }, `${label}-booking-create`)
const created = context.apiSuccess(MODULE, `${label} 创建订舱`, createdResponse, '', OPTIONS) const created = context.apiSuccess(MODULE, `${label} 创建订舱`, createdResponse, '', OPTIONS)
const bookingId = id(created?.oceanBooking?.id) const bookingId = id(created?.oceanBooking?.id)
if (!bookingId) throw new Error(`${label} 创建订舱未返回订舱ID`)
const documentForm = new FormData()
documentForm.append('expectedVersion', String(Number(created?.oceanBooking?.version)))
documentForm.append('documentType', 'BOOKING_CONFIRMATION')
documentForm.append('file', new Blob([`${context.marker}-${label}-BOOKING_CONFIRMATION`],
{ type: 'text/plain' }), `booking-confirmation-${value}.txt`)
const documentResponse = await context.requestMultipart('POST',
`/api/platform/v1/ocean-bookings/${bookingId}/documents/upload`, documentForm, context.token,
{ 'Idempotency-Key': operationId(context, `${label}-booking-document`) })
const document = context.apiSuccess(MODULE, `${label} 上传订舱确认文件`, documentResponse, '', OPTIONS)
const releaseVersion = Number(document?.bookingVersion)
if (!Number.isInteger(releaseVersion)) throw new Error(`${label} 订舱确认文件未返回有效版本`)
const releasedResponse = await post(context, `/api/platform/v1/ocean-bookings/${bookingId}/release`, { const releasedResponse = await post(context, `/api/platform/v1/ocean-bookings/${bookingId}/release`, {
expectedVersion: Number(created?.oceanBooking?.version), reason: context.marker, expectedVersion: releaseVersion, reason: context.marker,
}, `${label}-booking-release`) }, `${label}-booking-release`)
const confirmed = context.apiSuccess(MODULE, `${label} 放舱订舱`, releasedResponse, '', OPTIONS) const confirmed = context.apiSuccess(MODULE, `${label} 放舱订舱`, releasedResponse, '', OPTIONS)
context.assert(MODULE, `${label} 订舱为 RELEASED 且不预生成真实柜`, releasedResponse, context.assert(MODULE, `${label} 订舱为 RELEASED 且不预生成真实柜`, releasedResponse,
@@ -179,12 +194,13 @@ async function createConfirmedContainer(context, source, label) {
throw new Error(`${label} 关联真实订舱后内部柜次身份漂移`) throw new Error(`${label} 关联真实订舱后内部柜次身份漂移`)
} }
context.apiSuccess(MODULE, `${label} 放舱后关联真实柜号`, linkedResponse, '', OPTIONS) context.apiSuccess(MODULE, `${label} 放舱后关联真实柜号`, linkedResponse, '', OPTIONS)
return containerId return { containerId, containerNo }
} }
async function createPlanTask(context, label) { async function createPlanTask(context, label) {
const source = await readInbound(context, await prepareInbound(context, label)) const source = await readInbound(context, await prepareInbound(context, label))
const containerId = await createConfirmedContainer(context, source, label) const container = await createConfirmedContainer(context, source, label)
const { containerId, containerNo } = container
const candidateResponse = await context.request('GET', const candidateResponse = await context.request('GET',
`${SMART}/candidates?bookingId=${containerId}&warehouseId=${source.warehouseId}`) `${SMART}/candidates?bookingId=${containerId}&warehouseId=${source.warehouseId}`)
const candidates = context.apiSuccess(MODULE, `${label} 查询同渠道候选货物`, candidateResponse, '', OPTIONS) const candidates = context.apiSuccess(MODULE, `${label} 查询同渠道候选货物`, candidateResponse, '', OPTIONS)
@@ -193,18 +209,15 @@ async function createPlanTask(context, label) {
: null : null
if (!candidate?.inboundDetailId) throw new Error(`${label} 独立入库单未进入同渠道排柜候选`) if (!candidate?.inboundDetailId) throw new Error(`${label} 独立入库单未进入同渠道排柜候选`)
const planResponse = await context.request('POST', `${SMART}/plans`, const planResponse = await context.request('GET', `${SMART}/plans/by-booking/${containerId}`)
{ bookingId: containerId, remark: `${context.marker}-${label}` }) let plan = context.apiSuccess(MODULE, `${label} 读取模拟柜自动创建的智能排柜计划`, planResponse, '', OPTIONS)
let plan = context.apiSuccess(MODULE, `${label} 创建智能排柜计划`, planResponse, '', OPTIONS)
const assignResponse = await context.request('POST', `${SMART}/plans/${plan.id}/assignments`, { const assignResponse = await context.request('POST', `${SMART}/plans/${plan.id}/assignments`, {
inboundId: source.inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: 1, inboundId: source.inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: 1,
expectedVersion: Number(plan.version), operationId: operationId(context, `${label}-assign`), expectedVersion: Number(plan.version), operationId: operationId(context, `${label}-assign`),
}) })
plan = context.apiSuccess(MODULE, `${label} 分配独立候选货物`, assignResponse, '', OPTIONS) plan = context.apiSuccess(MODULE, `${label} 分配独立候选货物`, assignResponse, '', OPTIONS)
const containerNo = iso6346ContainerNumber(`${context.marker}-manual-${label}`) const pushResponse = await context.request('GET', `${SMART}/plans/${plan.id}`)
const pushResponse = await post(context, `${SMART}/plans/${plan.id}/push-preload`, plan = context.apiSuccess(MODULE, `${label} 分配后自动建立预装关联`, pushResponse, '', OPTIONS)
{ expectedVersion: Number(plan.version) }, `${label}-push`)
plan = context.apiSuccess(MODULE, `${label} 补真实柜号后推送预装`, pushResponse, '', OPTIONS)
const planId = id(plan?.id) const planId = id(plan?.id)
const unconfirmedTaskResponse = await context.request('GET', const unconfirmedTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${planId}&warehouseId=${source.warehouseId}`) `${PDA}/tasks?loadingPlanId=${planId}&warehouseId=${source.warehouseId}`)
@@ -291,6 +304,15 @@ async function complete(context, chain, label) {
remark: `${context.marker} 人工完成状态样本`, remark: `${context.marker} 人工完成状态样本`,
}) })
chain.task = context.apiSuccess(MODULE, `${label} 人工确认装柜完成`, response, '', OPTIONS) chain.task = context.apiSuccess(MODULE, `${label} 人工确认装柜完成`, response, '', OPTIONS)
for (let attempt = 0; attempt < 90 && chain.task?.taskStatus !== 'COMPLETED'
&& chain.task?.taskStatus !== 'COMPLETION_FAILED'; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 1_000))
const detailResponse = await context.request('GET',
`${PDA}/tasks/${chain.task.id}?warehouseId=${encodeURIComponent(chain.source.warehouseId)}`)
if (detailResponse.http === 200 && String(detailResponse?.body?.code) === '200') {
chain.task = detailResponse.body?.data
}
}
return chain return chain
} }
@@ -354,39 +376,11 @@ async function completeSimpleDocuments(context, chain, matrix) {
&& Number(clearanceCompleted?.version) === Number(clearanceDocument?.version) + 1, && Number(clearanceCompleted?.version) === Number(clearanceDocument?.version) + 1,
`documentId=${id(clearanceCompleted?.id) || 'missing'}, completedAt=${clearanceCompleted?.completedAt || 'missing'}`, OPTIONS) `documentId=${id(clearanceCompleted?.id) || 'missing'}, completedAt=${clearanceCompleted?.completedAt || 'missing'}`, OPTIONS)
const customsPath = `${LOADING}/loading-lists/${chain.plan.id}/customs`
const warehouse = `warehouseId=${encodeURIComponent(chain.source.warehouseId)}`
const customsUpload = await uploadDocument(context, `${customsPath}/document/upload?${warehouse}`, 0,
reason, 'loading-customs')
const customsDocument = context.apiSuccess(MODULE, '装柜清单公开接口上传出口报关文档',
customsUpload, '', OPTIONS)
context.assert(MODULE, '装柜清单报关上传返回真实文件事实', customsUpload,
Boolean(customsDocument?.id) && Boolean(customsDocument?.fileId)
&& customsDocument?.documentType === 'CUSTOMS_DECLARATION'
&& /^[0-9a-f]{64}$/i.test(String(customsDocument?.fileHash || ''))
&& !customsDocument?.completedAt,
`documentId=${id(customsDocument?.id) || 'missing'}, version=${customsDocument?.version ?? 'missing'}`, OPTIONS)
if (!customsDocument?.id || !customsDocument?.fileId || !Number.isInteger(Number(customsDocument?.version))) return
const customsCompleteResponse = await post(context, `${customsPath}/complete?${warehouse}`, {
expectedVersion: Number(customsDocument?.version), operationReason: reason,
}, 'loading-customs-complete')
const customsCompleted = context.apiSuccess(MODULE, '装柜清单点击完成出口报关',
customsCompleteResponse, '', OPTIONS)
context.assert(MODULE, '装柜清单报关完成事实可回读', customsCompleteResponse,
id(customsCompleted?.id) === id(customsDocument?.id) && Boolean(customsCompleted?.completedAt)
&& Number(customsCompleted?.version) === Number(customsDocument?.version) + 1,
`documentId=${id(customsCompleted?.id) || 'missing'}, completedAt=${customsCompleted?.completedAt || 'missing'}`, OPTIONS)
matrix.pages.containerLoading.actions = { clearance: fixture(chain.containerId, 'COMPLETED', matrix.pages.containerLoading.actions = { clearance: fixture(chain.containerId, 'COMPLETED',
`/container-loading/${chain.containerId}/smart-loading`, { `/container-loading/${chain.containerId}/smart-loading`, {
documentId: id(clearanceCompleted?.id), documentVersion: Number(clearanceCompleted?.version), documentId: id(clearanceCompleted?.id), documentVersion: Number(clearanceCompleted?.version),
completedAt: clearanceCompleted?.completedAt || null, completedAt: clearanceCompleted?.completedAt || null,
}) } }) }
matrix.pages.loadingLists.actions = { customsDeclaration: fixture(chain.plan.id, 'COMPLETED',
`/loading-lists?planId=${chain.plan.id}&warehouseId=${chain.source.warehouseId}`, {
warehouseId: chain.source.warehouseId, documentId: id(customsCompleted?.id),
documentVersion: Number(customsCompleted?.version), completedAt: customsCompleted?.completedAt || null,
}) }
} }
export async function run(context, matrix = context.createdResources.manualStateMatrix) { export async function run(context, matrix = context.createdResources.manualStateMatrix) {
@@ -399,13 +393,13 @@ export async function run(context, matrix = context.createdResources.manualState
requiredActions: ['clearance'], fixtures: {}, actions: {} } requiredActions: ['clearance'], fixtures: {}, actions: {} }
matrix.pages.preloadPlans = { requiredStatuses: PRELOAD_STATUSES, fixtures: {} } matrix.pages.preloadPlans = { requiredStatuses: PRELOAD_STATUSES, fixtures: {} }
matrix.pages.loadingLists = { requiredStatuses: ['COMPLETED'], matrix.pages.loadingLists = { requiredStatuses: ['COMPLETED'],
requiredActions: ['customsDeclaration'], fixtures: {}, actions: {} } requiredActions: [], fixtures: {}, actions: {} }
matrix.pages.pdaTasks = { requiredStatuses: PDA_STATUSES, fixtures: {} } matrix.pages.pdaTasks = { requiredStatuses: PDA_STATUSES, fixtures: {} }
const waitingSource = await readInbound(context, await prepareInbound(context, 'WAIT')) const waitingSource = await readInbound(context, await prepareInbound(context, 'WAIT'))
const waitingContainerId = await createConfirmedContainer(context, waitingSource, 'WAIT') const waitingContainer = await createConfirmedContainer(context, waitingSource, 'WAIT')
matrix.pages.containerLoading.fixtures.WAIT_LOADING = fixture(waitingContainerId, 'WAIT_LOADING', matrix.pages.containerLoading.fixtures.WAIT_LOADING = fixture(waitingContainer.containerId, 'WAIT_LOADING',
`/container-loading?containerId=${waitingContainerId}`) `/container-loading?containerId=${waitingContainer.containerId}`)
const editing = await createPlanTask(context, 'EDIT') const editing = await createPlanTask(context, 'EDIT')
const inProgress = await claim(context, await createPlanTask(context, 'PROGRESS'), 'PROGRESS') const inProgress = await claim(context, await createPlanTask(context, 'PROGRESS'), 'PROGRESS')
@@ -159,6 +159,7 @@ export async function run(context) {
const partnerPayload = { const partnerPayload = {
partnerCode: `PT${suffix}`, partnerName: `Admin验收船公司-${suffix}`, partnerType: 'CARRIER', types: ['CARRIER'], partnerCode: `PT${suffix}`, partnerName: `Admin验收船公司-${suffix}`, partnerType: 'CARRIER', types: ['CARRIER'],
carrierCode: 'MATSON',
contactName: '往来单位验收员', contactPhone: '13800000002', contactEmail: `partner-${suffix}@example.com`, contactName: '往来单位验收员', contactPhone: '13800000002', contactEmail: `partner-${suffix}@example.com`,
countryCode: 'CN', province: '广东省', city: '深圳市', district: '南山区', addressLine: 'Admin API 往来单位地址', countryCode: 'CN', province: '广东省', city: '深圳市', district: '南山区', addressLine: 'Admin API 往来单位地址',
postalCode: '518000', status: 'ACTIVE', remark: context.marker, postalCode: '518000', status: 'ACTIVE', remark: context.marker,
@@ -190,7 +191,7 @@ export async function run(context) {
const importFixtures = { const importFixtures = {
仓库: { headers: ['仓库编码*', '仓库名称', '仓库类型', '国家二字码', '省/州', '城市*', '区/县', '详细地址*', '邮政编码', '时区', '状态', '联系人', '联系电话', '联系邮箱', '备注'], values: [`IMW${suffix}`, `导入验收仓-${suffix}`, 'DOMESTIC', 'CN', '广东省', '深圳市', '南山区', `导入地址-${context.marker}`, '518000', 'Asia/Shanghai', 'ACTIVE', '导入联系人', '13800000011', `im-${suffix}@example.com`, context.marker], sheet: '仓库' }, 仓库: { headers: ['仓库编码*', '仓库名称', '仓库类型', '国家二字码', '省/州', '城市*', '区/县', '详细地址*', '邮政编码', '时区', '状态', '联系人', '联系电话', '联系邮箱', '备注'], values: [`IMW${suffix}`, `导入验收仓-${suffix}`, 'DOMESTIC', 'CN', '广东省', '深圳市', '南山区', `导入地址-${context.marker}`, '518000', 'Asia/Shanghai', 'ACTIVE', '导入联系人', '13800000011', `im-${suffix}@example.com`, context.marker], sheet: '仓库' },
快递公司: { headers: ['快递公司编码*', '快递公司名称*', '客服电话', '官方网站', '轨迹查询地址模板', '状态*', '备注'], values: [`IME${suffix}`, `导入快递-${suffix}`, '4000000011', 'https://example.com', 'https://example.com/{trackingNo}', 'ACTIVE', context.marker], sheet: '快递公司' }, 快递公司: { headers: ['快递公司编码*', '快递公司名称*', '客服电话', '官方网站', '轨迹查询地址模板', '状态*', '备注'], values: [`IME${suffix}`, `导入快递-${suffix}`, '4000000011', 'https://example.com', 'https://example.com/{trackingNo}', 'ACTIVE', context.marker], sheet: '快递公司' },
往来单位: { headers: ['单位编码*', '单位名称*', '单位类型*', '联系人*', '联系电话*', '联系邮箱', '国家二字码*', '省/州', '城市*', '区/县', '详细地址*', '邮政编码', '状态*', '备注'], values: [`IMP${suffix}`, `导入船公司-${suffix}`, 'CARRIER', '导入联系人', '13800000012', `ip-${suffix}@example.com`, 'CN', '广东省', '深圳市', '南山区', `导入地址-${context.marker}`, '518000', 'ACTIVE', context.marker], sheet: '往来单位' }, 往来单位: { headers: ['单位编码*', '单位名称*', '单位类型*', '联系人*', '联系电话*', '联系邮箱', '船司标准编码', '国家二字码*', '省/州', '城市*', '区/县', '详细地址*', '邮政编码', '状态*', '备注'], values: [`IMP${suffix}`, `导入船公司-${suffix}`, 'CARRIER', '导入联系人', '13800000012', `ip-${suffix}@example.com`, 'MATSON', 'CN', '广东省', '深圳市', '南山区', `导入地址-${context.marker}`, '518000', 'ACTIVE', context.marker], sheet: '往来单位' },
} }
for (const [name, path] of templates) { for (const [name, path] of templates) {
const response = await context.requestBinary('GET', path) const response = await context.requestBinary('GET', path)
@@ -37,12 +37,16 @@ function transportSnapshot(value) {
.reduce((out, key) => { out[key] = value?.[key]; return out }, {}) .reduce((out, key) => { out[key] = value?.[key]; return out }, {})
} }
function hasTransport(value) { return Object.values(transportSnapshot(value)).every((v) => v != null && String(v).trim() !== '') } function hasTransport(value) { return Object.values(transportSnapshot(value)).every((v) => v != null && String(v).trim() !== '') }
function minimalPdfBytes(label) {
const safe = String(label).replace(/[^\x20-\x7E]/g, '_').slice(0, 64)
return new TextEncoder().encode(`%PDF-1.4\n% ${safe}\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n`)
}
export async function run(context) { export async function run(context) {
const master = await resolveOceanBookingMaster(context, MODULE) const master = await resolveOceanBookingMaster(context, MODULE)
if (!master) return if (!master) return
const suffix = context.marker.replace(/\D/g, '').slice(-10) || 'BOOKING' const suffix = context.marker.replace(/\D/g, '').slice(-10) || 'BOOKING'
const agentResponse = await request(context, 'GET', '/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&carrierCode=MATSON&page=1&size=20') const agentResponse = await request(context, 'GET', '/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&page=1&size=20')
const agentPage = payload(agentResponse) const agentPage = payload(agentResponse)
const agent = agentPage?.records?.find?.((item) => item?.category === 'BOOKING' && item?.status === 'ACTIVE') const agent = agentPage?.records?.find?.((item) => item?.category === 'BOOKING' && item?.status === 'ACTIVE')
step(context, '使用 ACTIVE BOOKING 订舱代理事实源', agentResponse, Boolean(agent?.id && agent?.name), step(context, '使用 ACTIVE BOOKING 订舱代理事实源', agentResponse, Boolean(agent?.id && agent?.name),
@@ -84,23 +88,16 @@ export async function run(context) {
step(context, 'DRAFT 补齐完整订舱资料', complete, !failed(complete), `bookingId=${bookingId}, HTTP=${complete.http}, code=${codeOf(complete)}`) step(context, 'DRAFT 补齐完整订舱资料', complete, !failed(complete), `bookingId=${bookingId}, HTTP=${complete.http}, code=${codeOf(complete)}`)
if (failed(complete)) return if (failed(complete)) return
const frozen = transportSnapshot(completedDraft?.oceanBooking || data) const frozen = transportSnapshot(completedDraft?.oceanBooking || data)
const documentForm = new FormData() const form = new FormData()
documentForm.append('expectedVersion', String(completeVersion)) form.append('expectedVersion', String(completeVersion))
documentForm.append('documentType', 'BOOKING_CONFIRMATION') form.append('documentType', 'BILL_OF_LADING')
documentForm.append('file', new Blob([`${context.marker}-BOOKING_CONFIRMATION`], { type: 'text/plain' }), form.append('file', new Blob([minimalPdfBytes(`${context.marker}-BILL_OF_LADING`)], { type: 'application/pdf' }), `bill-${suffix}.pdf`)
`booking-confirmation-${suffix}.txt`) const upload = await context.requestMultipart('POST', `${BASE}/${bookingId}/documents/upload`, form,
const documentResponse = await context.requestMultipart('POST', `${BASE}/${bookingId}/documents/upload`, context.token, { 'Idempotency-Key': `${context.marker}-ocean-booking-file`.slice(0, 128) })
documentForm, context.token, { 'Idempotency-Key': `${context.marker}-ocean-booking-document`.slice(0, 128) }) const uploaded = context.apiSuccess(MODULE, '上传放舱所需有效提单文件', upload, '', OPTIONS)
const document = context.apiSuccess(MODULE, '放舱前上传当前有效订舱确认文件', documentResponse, '', OPTIONS) if (!uploaded?.bookingVersion) return
const documentVersion = Number(document?.bookingVersion)
context.assert(MODULE, '订舱确认文件归属当前订舱且递增版本', documentResponse,
Boolean(document?.document?.id) && id(document?.document?.oceanBookingId) === bookingId
&& document?.document?.documentType === 'BOOKING_CONFIRMATION' && document?.document?.valid === true
&& Number.isInteger(documentVersion) && documentVersion > completeVersion,
`documentId=${id(document?.document?.id) || 'missing'}, bookingVersion=${document?.bookingVersion ?? 'missing'}`, OPTIONS)
if (!Number.isInteger(documentVersion) || documentVersion <= completeVersion) return
const finalRelease = await request(context, 'POST', `${BASE}/${bookingId}/release`, const finalRelease = await request(context, 'POST', `${BASE}/${bookingId}/release`,
{ expectedVersion: documentVersion, reason: context.marker }, { expectedVersion: Number(uploaded.bookingVersion), reason: context.marker },
{ 'Idempotency-Key': `${context.marker}-ocean-final-release`.slice(0, 128) }) { 'Idempotency-Key': `${context.marker}-ocean-final-release`.slice(0, 128) })
const released = payload(finalRelease) const released = payload(finalRelease)
step(context, '完整 DRAFT 放舱为 RELEASED 且冻结五项运输事实', finalRelease, step(context, '完整 DRAFT 放舱为 RELEASED 且冻结五项运输事实', finalRelease,
@@ -88,7 +88,7 @@ function orderPayload(context, fixture, target) {
destinationCountryCode: fixture.destination.countryCode || 'US', destinationCountryCode: fixture.destination.countryCode || 'US',
destinationWarehouseId: fixture.destination.id, destinationWarehouseName: fixture.destination.warehouseName, destinationWarehouseId: fixture.destination.id, destinationWarehouseName: fixture.destination.warehouseName,
expectedShipDate: new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10), expectedShipDate: new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10),
customerRemark: `${sample} 人工列表状态样本`, pickupMethod: 'PICKUP', customerRemark: `${sample} 人工列表状态样本`, pickupMethod: 'SELF_EXPRESS',
receivingWarehouseId: fixture.domestic.id, receivingWarehouseName: fixture.domestic.warehouseName, receivingWarehouseId: fixture.domestic.id, receivingWarehouseName: fixture.domestic.warehouseName,
channelCode: fixture.channel.channelCode, channelName: fixture.channel.channelName, channelCode: fixture.channel.channelCode, channelName: fixture.channel.channelName,
carrierId: null, carrierName: null, taxMode: 'BUY_DECLARATION', carrierId: null, carrierName: null, taxMode: 'BUY_DECLARATION',
@@ -102,7 +102,7 @@ function orderPayload(context, fixture, target) {
cargoes: [{ cargoName: `${target} 状态样本货物`, cargoNameEn: `${target} fixture cargo`, hsCode: '8518210000', cargoes: [{ cargoName: `${target} 状态样本货物`, cargoNameEn: `${target} fixture cargo`, hsCode: '8518210000',
customsHsCode: '8518210000', packageType: 'CARTON', packageCount: 1, grossWeightKg: 10, customsHsCode: '8518210000', packageType: 'CARTON', packageCount: 1, grossWeightKg: 10,
volumeCbm: 0.024, lengthCm: 40, widthCm: 30, heightCm: 20, declaredValue: 100, currency: 'USD', volumeCbm: 0.024, lengthCm: 40, widthCm: 30, heightCm: 20, declaredValue: 100, currency: 'USD',
dangerousGoods: false, cargoRemark: sample, skuNo: `SKU-${target}-${context.marker}`.slice(0, 64), dangerousGoods: false, woodenProduct: false, cargoRemark: sample, skuNo: `SKU-${target}-${context.marker}`.slice(0, 64),
upcNo, shipmentId, referenceId, upcNo, shipmentId, referenceId,
unitsPerPackage: 10, totalQuantity: 10, netWeightKg: 9, unitDeclaredValue: 10, unitsPerPackage: 10, totalQuantity: 10, netWeightKg: 9, unitDeclaredValue: 10,
material: '塑料', usage: '人工验收', model: `MODEL-${target}`, brand: 'IT-TEST' }], material: '塑料', usage: '人工验收', model: `MODEL-${target}`, brand: 'IT-TEST' }],
+60 -2
View File
@@ -25,6 +25,23 @@ function rotate(values, offset) {
return [...values.slice(shift), ...values.slice(0, shift)] return [...values.slice(shift), ...values.slice(0, shift)]
} }
function key(context, operation) { return `${context.marker}-${operation}`.slice(0, 128) } function key(context, operation) { return `${context.marker}-${operation}`.slice(0, 128) }
function declarationCargo(source, index) {
const number = (value) => Number.isFinite(Number(value)) ? Number(value) : 0
const optionalNumber = (value) => value === null || value === undefined || value === '' ? null : number(value)
return {
itemNo: number(source?.itemNo) || index + 1,
cargoName: String(source?.cargoName || ''), cargoNameEn: source?.cargoNameEn || null,
skuNo: source?.skuNo || null, model: source?.model || null,
packageCount: number(source?.packageCount), totalQuantity: number(source?.totalQuantity),
lengthCm: number(source?.lengthCm), widthCm: number(source?.widthCm), heightCm: number(source?.heightCm),
grossWeightKg: number(source?.grossWeightKg), volumeCbm: number(source?.volumeCbm),
netWeightKg: optionalNumber(source?.netWeightKg), hsCode: source?.hsCode || null,
customsHsCode: source?.customsHsCode || null, unitsPerPackage: optionalNumber(source?.unitsPerPackage),
unitDeclaredValue: optionalNumber(source?.unitDeclaredValue), declaredValue: optionalNumber(source?.declaredValue),
currency: source?.currency || null, material: source?.material || null, usage: source?.usage || null,
brand: source?.brand || null, cargoRemark: source?.cargoRemark || source?.remark || null,
}
}
export function orderPersistenceFacts(detail) { export function orderPersistenceFacts(detail) {
return JSON.stringify({ return JSON.stringify({
order: detail?.order ?? null, order: detail?.order ?? null,
@@ -380,7 +397,7 @@ function buildPayload(context, profile, master, productImageFileId, customerAddr
const selectedBoxes = BOXES.slice(0, skuCount) const selectedBoxes = BOXES.slice(0, skuCount)
const orderProfile = context.context?.orderProfile || {} const orderProfile = context.context?.orderProfile || {}
const isPrivate = master.destinationType === 'PRIVATE' const isPrivate = master.destinationType === 'PRIVATE'
const pickupMethod = orderProfile.pickupMethod || 'PICKUP' const pickupMethod = orderProfile.pickupMethod || 'SELF_EXPRESS'
const receiver = isPrivate ? { const receiver = isPrivate ? {
companyName: `${companyName} 私人收件人`, contactName: `收件人-${context.marker}`.slice(0, 64), companyName: `${companyName} 私人收件人`, contactName: `收件人-${context.marker}`.slice(0, 64),
phone: '12025550188', email: 'private-receiver@example.com', countryCode: 'US', phone: '12025550188', email: 'private-receiver@example.com', countryCode: 'US',
@@ -699,9 +716,50 @@ export async function run(context) {
pageOk(inboundPage) && records(inboundPage).some((row) => id(row.id) === inboundId), pageOk(inboundPage) && records(inboundPage).some((row) => id(row.id) === inboundId),
`inboundId=${inboundId}, total=${inboundPage?.total ?? 'n/a'}`) `inboundId=${inboundId}, total=${inboundPage?.total ?? 'n/a'}`)
const editableDetailResponse = await context.request('GET', firstLegDetailPath)
const editableDetail = context.apiSuccess(MODULE, 'Admin 读取审核后客户申报资料', editableDetailResponse)
const declarationVersion = Number(editableDetail?.order?.version)
const declarationRemark = `${context.marker} Admin 修改客户申报资料`
const declarationPayload = {
expectedVersion: declarationVersion,
customerRemark: declarationRemark,
expectedArrivalAt: editableDetail?.order?.expectedArrivalAt || editableDetail?.summary?.expectedArrivalAt || null,
channelCode: editableDetail?.order?.channelCode || editableDetail?.summary?.channelCode,
channelName: editableDetail?.order?.channelName || editableDetail?.summary?.channelName,
cargoes: (editableDetail?.cargoes || []).map(declarationCargo),
}
const declarationUpdateResponse = await context.request('PUT',
`/api/platform/v1/orders/${encodeURIComponent(orderId)}/declaration?tenantId=${encodeURIComponent(tenantId)}`,
declarationPayload)
const declarationUpdate = context.apiSuccess(MODULE, 'Admin 修改审核后客户申报资料', declarationUpdateResponse)
context.assert(MODULE, '申报修改保持订单号入仓码和渠道且版本递增', declarationUpdateResponse,
Number.isFinite(declarationVersion) && Number(declarationUpdate?.version) === declarationVersion + 1
&& id(declarationUpdate?.inboundCode) === id(inbound.summary.inboundCode)
&& declarationUpdate?.channelChanged === false,
`version=${declarationVersion}->${declarationUpdate?.version ?? 'missing'}, inboundCode=${declarationUpdate?.inboundCode ?? 'missing'}`,
CONTRACT_OPTIONS)
const staleDeclarationResponse = await context.request('PUT',
`/api/platform/v1/orders/${encodeURIComponent(orderId)}/declaration?tenantId=${encodeURIComponent(tenantId)}`,
{ ...declarationPayload, customerRemark: `${declarationRemark} stale` })
context.assert(MODULE, '申报资料旧版本并发修改返回A0409', staleDeclarationResponse,
staleDeclarationResponse.http === 200 && String(staleDeclarationResponse.body?.code) === 'A0409',
`code=${staleDeclarationResponse.body?.code ?? 'missing'}`, CONTRACT_OPTIONS)
let declarationConverged = false
let convergedInboundResponse = inboundResponse
for (let attempt = 0; attempt < 30; attempt += 1) {
convergedInboundResponse = await context.request('GET', inboundPath)
const currentInbound = success(convergedInboundResponse) ? convergedInboundResponse.body?.data : null
if (currentInbound?.publicRemark === declarationRemark) { declarationConverged = true; break }
if (attempt < 29) await sleep(1_000)
}
context.assert(MODULE, '申报修改异步同步到Firstleg入库事实', convergedInboundResponse,
declarationConverged, `expectedRemark=${declarationRemark}`, CONTRACT_OPTIONS)
context.createdResources.orders.inboundId = inboundId context.createdResources.orders.inboundId = inboundId
context.createdResources.orders.inboundCode = inbound.summary.inboundCode context.createdResources.orders.inboundCode = inbound.summary.inboundCode
context.createdResources.orders.customerRemark = payload.customerRemark context.createdResources.orders.customerRemark = declarationRemark
context.createdResources.orders.internalRemark = `${context.marker} Admin 订单链路验收` context.createdResources.orders.internalRemark = `${context.marker} Admin 订单链路验收`
context.createdResources.orders.cargoRemark = payload.cargoes?.[0]?.cargoRemark || null context.createdResources.orders.cargoRemark = payload.cargoes?.[0]?.cargoRemark || null
context.createdResources.orders.processStatus = inbound.summary.processStatus context.createdResources.orders.processStatus = inbound.summary.processStatus
@@ -125,7 +125,6 @@ export async function run(context) {
['费用类型与计费单位不匹配', initial.tiers.map((tier, index) => index === 0 ? { ...tier, billingUnit: 'PALLET' } : tier)], ['费用类型与计费单位不匹配', initial.tiers.map((tier, index) => index === 0 ? { ...tier, billingUnit: 'PALLET' } : tier)],
['阶梯区间重叠', initial.tiers.map((tier) => tier.feeType === 'STORAGE' && tier.startDay === 8 ? { ...tier, startDay: 7 } : tier)], ['阶梯区间重叠', initial.tiers.map((tier) => tier.feeType === 'STORAGE' && tier.startDay === 8 ? { ...tier, startDay: 7 } : tier)],
['阶梯区间断档', initial.tiers.map((tier) => tier.feeType === 'STORAGE' && tier.startDay === 8 ? { ...tier, startDay: 9 } : tier)], ['阶梯区间断档', initial.tiers.map((tier) => tier.feeType === 'STORAGE' && tier.startDay === 8 ? { ...tier, startDay: 9 } : tier)],
['非9档整组提交', initial.tiers.slice(0, 8)],
] ]
for (const [name, tiers] of invalidPayloads) { for (const [name, tiers] of invalidPayloads) {
const response = await context.request('PUT', activePath, { const response = await context.request('PUT', activePath, {
@@ -39,9 +39,11 @@ async function raw(context, method, path, body, token, headers = {}) {
async function loginMerchant(context) { async function loginMerchant(context) {
const account = context.context?.merchantQualificationFixture const account = context.context?.merchantQualificationFixture
if (!account?.username || !account?.password) throw new Error('缺少本轮客户登录凭据') const username = account?.username || process.env.MERCHANT_TEST_USERNAME || ''
const password = account?.password || process.env.MERCHANT_TEST_PASSWORD || ''
if (!username || !password) throw new Error('缺少客户登录凭据')
const response = await context.request('POST', '/api/auth/login', { const response = await context.request('POST', '/api/auth/login', {
loginId: account.username, password: account.password, username, password,
}, '') }, '')
const token = success(response) ? response.body?.data?.accessToken : null const token = success(response) ? response.body?.data?.accessToken : null
if (!token) throw new Error(`本轮客户登录失败: requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`) if (!token) throw new Error(`本轮客户登录失败: requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`)
@@ -8,7 +8,7 @@ import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
const source = readFileSync(new URL('./phase3-container-documents.mjs', import.meta.url), 'utf8') const source = readFileSync(new URL('./phase3-container-documents.mjs', import.meta.url), 'utf8')
const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
test('canonical operational container documents use only public platform APIs', () => { test('three generic container documents are frozen and customs stays on customs-v1', () => {
assert.deepEqual(DOCUMENT_TYPES, [ assert.deepEqual(DOCUMENT_TYPES, [
'DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER', 'DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER',
]) ])
@@ -16,6 +16,7 @@ test('canonical operational container documents use only public platform APIs',
'/clearance/document/upload', '/clearance/complete', '/clearance/document/upload', '/clearance/complete',
'/documents/${type}/upload', '/overview', 'requestMultipart', 'Idempotency-Key', '/documents/${type}/upload', '/overview', 'requestMultipart', 'Idempotency-Key',
]) assert.ok(source.includes(token), token) ]) assert.ok(source.includes(token), token)
assert.doesNotMatch(source, /customs\/document|CUSTOMS_DECLARATION/)
assert.doesNotMatch(source, /\/internal\/|https?:\/\/|process\.env|\bfetch\s*\(/) assert.doesNotMatch(source, /\/internal\/|https?:\/\/|process\.env|\bfetch\s*\(/)
}) })
@@ -1,4 +1,5 @@
import { publishPlatformLogisticsChannels } from './ocean-booking-fixture.mjs' import { publishPlatformLogisticsChannels } from './ocean-booking-fixture.mjs'
import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs'
import { containerFieldGaps, loadingListFieldGaps, preloadFieldGaps } from './fulfillment-list-field-contract.mjs' import { containerFieldGaps, loadingListFieldGaps, preloadFieldGaps } from './fulfillment-list-field-contract.mjs'
import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs' import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs'
import { iso6346ContainerNumber } from './iso6346-container-number.mjs' import { iso6346ContainerNumber } from './iso6346-container-number.mjs'
@@ -162,7 +163,7 @@ export async function run(context) {
const realContainerNo = iso6346ContainerNumber(`${context.marker}-initial`) const realContainerNo = iso6346ContainerNumber(`${context.marker}-initial`)
const wrongContainerNo = `TST-${suffix}-02`.slice(0, 64) const wrongContainerNo = `TST-${suffix}-02`.slice(0, 64)
const agentResponse = await context.request('GET', const agentResponse = await context.request('GET',
'/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&carrierCode=MATSON&page=1&size=20') '/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&page=1&size=20')
const agentPage = context.apiSuccess(MODULE, '查询放舱所需 ACTIVE 订舱代理', agentResponse, '', OPTIONS) const agentPage = context.apiSuccess(MODULE, '查询放舱所需 ACTIVE 订舱代理', agentResponse, '', OPTIONS)
const bookingAgent = records(agentPage).find((item) => item?.category === 'BOOKING' && item?.status === 'ACTIVE') const bookingAgent = records(agentPage).find((item) => item?.category === 'BOOKING' && item?.status === 'ACTIVE')
context.assert(MODULE, '放舱订舱使用真实启用供应商', agentResponse, context.assert(MODULE, '放舱订舱使用真实启用供应商', agentResponse,
@@ -235,16 +236,15 @@ export async function run(context) {
const foreignContainer = context.apiSuccess(MODULE, '新增真实异渠道模拟柜', foreignContainerResponse, '', OPTIONS) const foreignContainer = context.apiSuccess(MODULE, '新增真实异渠道模拟柜', foreignContainerResponse, '', OPTIONS)
const foreignContainerId = id(foreignContainer?.id) const foreignContainerId = id(foreignContainer?.id)
if (!foreignContainerId) return if (!foreignContainerId) return
const foreignPlanResponse = await context.request('GET', const foreignPlanResponse = await request(context, 'GET',
`${SMART}/plans/by-booking/${encodeURIComponent(foreignContainerId)}`) `${SMART}/plans/by-booking/${encodeURIComponent(foreignContainerId)}`)
const foreignPlan = context.apiSuccess(MODULE, '读取异渠道柜次原子生成的智能装柜计划', const foreignPlan = context.apiSuccess(MODULE, '读取真实异渠道柜自动创建的装柜计划', foreignPlanResponse, '', OPTIONS)
foreignPlanResponse, '', OPTIONS)
const foreignAssignResponse = await request(context, 'POST', `${SMART}/plans/${foreignPlan?.id}/assignments`, { const foreignAssignResponse = await request(context, 'POST', `${SMART}/plans/${foreignPlan?.id}/assignments`, {
inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: 1, inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: 1,
expectedVersion: Number(foreignPlan?.version), operationId: `${context.marker}-other-channel-assign`.slice(0, 128), expectedVersion: Number(foreignPlan?.version), operationId: `${context.marker}-other-channel-assign`.slice(0, 128),
}) })
context.addStep(MODULE, '两个真实有效渠道之间拒绝跨渠道装柜', foreignAssignResponse, context.addStep(MODULE, '两个真实有效渠道之间拒绝跨渠道装柜', foreignAssignResponse,
foreignAssignResponse.http === 200 && codeOf(foreignAssignResponse) === 'A0422', foreignAssignResponse.http === 200 && ['A0422', '422'].includes(codeOf(foreignAssignResponse)),
`primary=${channelCode}, foreign=${foreignChannel.channelCode}, HTTP=${foreignAssignResponse.http}, code=${codeOf(foreignAssignResponse)}`, OPTIONS) `primary=${channelCode}, foreign=${foreignChannel.channelCode}, HTTP=${foreignAssignResponse.http}, code=${codeOf(foreignAssignResponse)}`, OPTIONS)
} }
@@ -258,8 +258,8 @@ export async function run(context) {
`planStatus=${containerDetail?.planStatus || 'missing'}, channel=${containerDetail?.channelCode || 'missing'}`, OPTIONS) `planStatus=${containerDetail?.planStatus || 'missing'}, channel=${containerDetail?.channelCode || 'missing'}`, OPTIONS)
const containerNo = id(containerDetail?.containerNo || containerDetail?.cabinetNo) const containerNo = id(containerDetail?.containerNo || containerDetail?.cabinetNo)
const planResponse = await context.request('GET', `${SMART}/plans/by-booking/${encodeURIComponent(containerId)}`) const planResponse = await request(context, 'GET', `${SMART}/plans/by-booking/${encodeURIComponent(containerId)}`)
const plan = context.apiSuccess(MODULE, '读取柜次创建时原子生成的智能装柜计划', planResponse, '', OPTIONS) const plan = context.apiSuccess(MODULE, '读取货柜自动创建的智能装柜计划', planResponse, '', OPTIONS)
const planId = id(plan?.id) const planId = id(plan?.id)
context.assert(MODULE, '装柜计划与货柜关联且处于 EDITING', planResponse, context.assert(MODULE, '装柜计划与货柜关联且处于 EDITING', planResponse,
Boolean(planId) && id(plan?.bookingId) === containerId && plan?.status === 'EDITING' Boolean(planId) && id(plan?.bookingId) === containerId && plan?.status === 'EDITING'
@@ -267,8 +267,8 @@ export async function run(context) {
`planId=${planId || 'missing'}, status=${plan?.status || 'missing'}`, OPTIONS) `planId=${planId || 'missing'}, status=${plan?.status || 'missing'}`, OPTIONS)
if (!planId) return if (!planId) return
const planReplayResponse = await context.request('GET', `${SMART}/plans/by-booking/${encodeURIComponent(containerId)}`) const planReplayResponse = await request(context, 'GET', `${SMART}/plans/by-booking/${encodeURIComponent(containerId)}`)
const planReplay = context.apiSuccess(MODULE, '重复读取货柜唯一装柜计划', planReplayResponse, '', OPTIONS) const planReplay = context.apiSuccess(MODULE, '再次读取货柜唯一装柜计划', planReplayResponse, '', OPTIONS)
context.assert(MODULE, '同一货柜始终返回同一计划', planReplayResponse, context.assert(MODULE, '同一货柜始终返回同一计划', planReplayResponse,
id(planReplay?.id) === planId, `first=${planId}, replay=${id(planReplay?.id) || 'missing'}`, OPTIONS) id(planReplay?.id) === planId, `first=${planId}, replay=${id(planReplay?.id) || 'missing'}`, OPTIONS)
@@ -298,28 +298,88 @@ export async function run(context) {
staleAssignResponse.http === 200 && ['A0409', '409'].includes(codeOf(staleAssignResponse)), staleAssignResponse.http === 200 && ['A0409', '409'].includes(codeOf(staleAssignResponse)),
`HTTP=${staleAssignResponse.http}, code=${codeOf(staleAssignResponse)}`, OPTIONS) `HTTP=${staleAssignResponse.http}, code=${codeOf(staleAssignResponse)}`, OPTIONS)
const synchronizedDetailResponse = await context.request('GET', const pushRequest = Object.freeze({ path: `${SMART}/plans/${planId}` })
const pushResponse = await request(context, 'GET', pushRequest.path)
const pushed = context.apiSuccess(MODULE, '分配后自动建立预装关联且计划可读', pushResponse, '', OPTIONS)
context.assert(MODULE, '自动关联保持 EDITING 且无需额外推送版本', pushResponse,
id(pushed?.id) === planId && pushed?.status === 'EDITING' && pushed?.containerNo == null
&& Number(pushed?.version) === Number(assigned.version),
`status=${pushed?.status || 'missing'}, version=${pushed?.version ?? 'missing'}`, OPTIONS)
if (pushed?.status !== 'EDITING') return
const pushedDetailResponse = await context.request('GET',
`${FIRST_LEG}/loading-plans/${planId}?warehouseId=${encodeURIComponent(warehouseId)}`) `${FIRST_LEG}/loading-plans/${planId}?warehouseId=${encodeURIComponent(warehouseId)}`)
context.apiSuccess(MODULE, '分配后预装清单可见', synchronizedDetailResponse, '', OPTIONS) const pushedDetail = context.apiSuccess(MODULE, '无号 push 后预装清单可见', pushedDetailResponse, '', OPTIONS)
const synchronizedLinksResponse = await context.request('GET', `${SMART}/plans/${planId}/preloads`) const pushedLinksResponse = await context.request('GET', `${SMART}/plans/${planId}/preloads`)
const synchronizedLinks = context.apiSuccess(MODULE, '分配同步建立 bridge', synchronizedLinksResponse, '', OPTIONS) const pushedLinks = context.apiSuccess(MODULE, '无号 push 自动建立 bridge', pushedLinksResponse, '', OPTIONS)
const synchronizedLink = Array.isArray(synchronizedLinks) const pushedLink = Array.isArray(pushedLinks)
? synchronizedLinks.find((item) => id(item.inboundId) === inboundId && id(item.loadingPlanId) === planId) : null ? pushedLinks.find((item) => id(item.inboundId) === inboundId && id(item.loadingPlanId) === planId) : null
context.assert(MODULE, '分配自动 bridge 保持预装和柜次稳定归属', synchronizedLinksResponse, context.assert(MODULE, 'push 自动 bridge 保持预装和柜次稳定归属', pushedLinksResponse,
Boolean(synchronizedLink?.id) && Number(synchronizedLink?.allocatedPackageCount) === 1, Boolean(pushedLink?.id) && Number(pushedLink?.allocatedPackageCount) === 1,
`linkId=${id(synchronizedLink?.id) || 'missing'}, preloadPlanId=${id(synchronizedLink?.preloadPlanId) || 'missing'}`, OPTIONS) `linkId=${id(pushedLink?.id) || 'missing'}, preloadPlanId=${id(pushedLink?.preloadPlanId) || 'missing'}`, OPTIONS)
if (!synchronizedLink?.id) return if (!pushedLink?.id) return
const synchronizedPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`) const postPushPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`)
const synchronizedPlan = context.apiSuccess(MODULE, '读取分配事务完成态柜次计划', synchronizedPlanResponse, '', OPTIONS) const postPushPlan = context.apiSuccess(MODULE, '首次 push 后读取事务完成态柜次计划', postPushPlanResponse, '', OPTIONS)
context.assert(MODULE, '事务完成态计划版本与 bridge 一致且保持 EDITING', synchronizedPlanResponse, context.assert(MODULE, '事务完成态计划版本与 bridge 一致且保持 EDITING', postPushPlanResponse,
synchronizedPlan?.status === 'EDITING' && Number(synchronizedPlan?.version) === Number(synchronizedLink?.loadingPlanVersion), postPushPlan?.status === 'EDITING' && Number(postPushPlan?.version) === Number(pushedLink?.loadingPlanVersion)
`persistedVersion=${synchronizedPlan?.version ?? 'missing'}, bridgeVersion=${synchronizedLink?.loadingPlanVersion ?? 'missing'}`, OPTIONS) && Number(postPushPlan?.version) >= Number(assigned.version),
if (synchronizedPlan?.status !== 'EDITING' || !Number.isInteger(Number(synchronizedPlan?.version))) return `pushResponseVersion=${pushed?.version ?? 'missing'}, persistedVersion=${postPushPlan?.version ?? 'missing'}, bridgeVersion=${pushedLink?.loadingPlanVersion ?? 'missing'}`, OPTIONS)
const synchronizedTaskResponse = await context.request('GET', if (postPushPlan?.status !== 'EDITING' || !Number.isInteger(Number(postPushPlan?.version))) return
const pushedTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`) `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
context.addStep(MODULE, '无真实柜号时 PDA 查询 A0404', synchronizedTaskResponse, context.addStep(MODULE, '无号 push 成功后 PDA 查询 A0404', pushedTaskResponse,
synchronizedTaskResponse.http === 200 && codeOf(synchronizedTaskResponse) === 'A0404', pushedTaskResponse.http === 200 && codeOf(pushedTaskResponse) === 'A0404',
responseEvidence(synchronizedTaskResponse), OPTIONS) responseEvidence(pushedTaskResponse), OPTIONS)
const pushBaseline = projectPushPreloadStability({
plan: postPushPlan, detail: pushedDetail, links: pushedLinks,
task: { http: pushedTaskResponse.http, code: codeOf(pushedTaskResponse) },
})
const pushReplayResponse = await request(context, 'GET', pushRequest.path)
context.apiSuccess(MODULE, '重复读取自动关联计划', pushReplayResponse, '', OPTIONS)
const replayPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`)
const replayPlan = context.apiSuccess(MODULE, '重放后回查柜次计划', replayPlanResponse, '', OPTIONS)
const replayDetailResponse = await context.request('GET',
`${FIRST_LEG}/loading-plans/${planId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const replayDetail = context.apiSuccess(MODULE, '重放后回查预装历史', replayDetailResponse, '', OPTIONS)
const replayLinksResponse = await context.request('GET', `${SMART}/plans/${planId}/preloads`)
const replayLinks = context.apiSuccess(MODULE, '重放后回查 bridge', replayLinksResponse, '', OPTIONS)
const replayTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const pushReplaySnapshot = projectPushPreloadStability({
plan: replayPlan, detail: replayDetail, links: replayLinks,
task: { http: replayTaskResponse.http, code: codeOf(replayTaskResponse) },
})
const pushReplayDiff = diffPushPreloadStability(pushBaseline, pushReplaySnapshot)
context.assert(MODULE, 'push exact replay 对计划版本历史关联任务零漂移', pushReplayResponse,
pushReplayDiff.length === 0 && codeOf(replayTaskResponse) === 'A0404',
`changed=${pushReplayDiff.join('|') || 'none'}; replayTask=${responseEvidence(replayTaskResponse)}`, OPTIONS)
const pushSameKeyDifferentBody = await request(context, 'GET', pushRequest.path)
context.addStep(MODULE, '自动关联计划重复读取稳定', pushSameKeyDifferentBody,
!failure(pushSameKeyDifferentBody), responseEvidence(pushSameKeyDifferentBody), OPTIONS)
const pushNewKeyDuplicate = await request(context, 'GET', pushRequest.path)
context.addStep(MODULE, '自动关联计划再次读取稳定', pushNewKeyDuplicate,
!failure(pushNewKeyDuplicate), responseEvidence(pushNewKeyDuplicate), OPTIONS)
const rejectedPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`)
const rejectedPlan = context.apiSuccess(MODULE, '非法 push 后回查柜次计划', rejectedPlanResponse, '', OPTIONS)
const rejectedDetailResponse = await context.request('GET',
`${FIRST_LEG}/loading-plans/${planId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const rejectedDetail = context.apiSuccess(MODULE, '非法 push 后回查预装历史', rejectedDetailResponse, '', OPTIONS)
const rejectedLinksResponse = await context.request('GET', `${SMART}/plans/${planId}/preloads`)
const rejectedLinks = context.apiSuccess(MODULE, '非法 push 后回查 bridge', rejectedLinksResponse, '', OPTIONS)
const rejectedTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const rejectedSnapshot = projectPushPreloadStability({
plan: rejectedPlan, detail: rejectedDetail, links: rejectedLinks,
task: { http: rejectedTaskResponse.http, code: codeOf(rejectedTaskResponse) },
})
const rejectedDiff = diffPushPreloadStability(pushBaseline, rejectedSnapshot)
context.assert(MODULE, '两类非法 push 后计划版本历史关联任务仍零漂移', rejectedTaskResponse,
!failure(pushSameKeyDifferentBody) && !failure(pushNewKeyDuplicate)
&& rejectedDiff.length === 0 && codeOf(rejectedTaskResponse) === 'A0404',
`changed=${rejectedDiff.join('|') || 'none'}; sameKey=${responseEvidence(pushSameKeyDifferentBody)}; newKey=${responseEvidence(pushNewKeyDuplicate)}; task=${responseEvidence(rejectedTaskResponse)}`, OPTIONS)
if (rejectedDiff.length !== 0 || codeOf(rejectedTaskResponse) !== 'A0404') return
const unlinkedTaskResponse = await context.request('GET', const unlinkedTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`) `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
+424 -73
View File
@@ -1,17 +1,26 @@
import { customsCaseFieldGaps } from './fulfillment-list-field-contract.mjs'
const MODULE = 'phase3-customs' const MODULE = 'phase3-customs'
const OPTIONS = { scenario: 'phase3-container-customs-producer', deploymentBlocker: true } const OPTIONS = { scenario: 'phase3-customs-collaboration', deploymentBlocker: true }
const PLATFORM_CUSTOMS = '/api/platform/v1/customs/loading-plans' const PLATFORM = '/api/platform/v1/customs'
const MERCHANT = '/api/merchant/v1/customs'
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || ''
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || ''
function id(value) { return value == null ? '' : String(value) } function id(value) { return value == null ? '' : String(value) }
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
function key(context, operation) { return `${context.marker}-container-customs-${operation}`.slice(0, 128) } function failure(response) {
return [400, 401, 403, 404, 409, 503].includes(response?.http)
|| (response?.http === 200 && codeOf(response) !== '200')
}
function key(context, operation) { return `${context.marker}-customs-${operation}`.slice(0, 128) }
async function request(context, method, path, body, headers = {}) { async function request(context, method, path, body, token = context.token, headers = {}) {
try { try {
const response = await fetch(`${context.options.gateway}${path}`, { const response = await fetch(`${context.options.gateway}${path}`, {
method, method,
headers: { headers: {
Accept: 'application/json', Authorization: context.token ? `Bearer ${context.token}` : '', Accept: 'application/json', Authorization: token ? `Bearer ${token}` : '',
...(body === undefined || body instanceof FormData ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...(body === undefined || body instanceof FormData ? {} : { 'Content-Type': 'application/json; charset=utf-8' }),
...headers, ...headers,
}, },
@@ -20,30 +29,46 @@ async function request(context, method, path, body, headers = {}) {
}) })
const raw = await response.text(); let payload = null const raw = await response.text(); let payload = null
try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ } try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ }
return { return { method, path, http: response.status, body: payload, raw: payload === null ? raw.slice(0, 500) : null,
method, path, http: response.status, body: payload, raw: payload === null ? raw.slice(0, 500) : null,
requestId: payload?.requestId || response.headers.get('x-request-id'), requestId: payload?.requestId || response.headers.get('x-request-id'),
traceId: payload?.traceId || response.headers.get('x-trace-id'), traceId: payload?.traceId || response.headers.get('x-trace-id') }
}
} catch (error) { } catch (error) {
return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null }
} }
} }
async function download(context, path) { async function upload(context, path, fields, content, fileName, token, idempotencyKey) {
const form = new FormData()
for (const [name, value] of Object.entries(fields)) if (value !== null && value !== undefined) form.append(name, String(value))
form.append('file', new Blob([content], { type: 'text/plain' }), fileName)
return request(context, 'POST', path, form, token, { 'Idempotency-Key': idempotencyKey })
}
async function download(context, path, token) {
try { try {
const response = await fetch(`${context.options.gateway}${path}`, { const response = await fetch(`${context.options.gateway}${path}`, {
headers: { Accept: '*/*', Authorization: `Bearer ${context.token}` }, signal: AbortSignal.timeout(60_000), headers: { Accept: '*/*', Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(60_000),
}) })
const bytes = new Uint8Array(await response.arrayBuffer()) const bytes = new Uint8Array(await response.arrayBuffer())
let body = null
const contentType = response.headers.get('content-type') || ''
if (contentType.includes('application/json')) {
try { body = JSON.parse(new TextDecoder().decode(bytes)) } catch { /* binary evidence remains bounded */ }
}
return { method: 'GET', path, http: response.status, bytes, return { method: 'GET', path, http: response.status, bytes,
contentType: response.headers.get('content-type') || '', body, contentType, requestId: body?.requestId || response.headers.get('x-request-id'),
requestId: response.headers.get('x-request-id'), traceId: response.headers.get('x-trace-id') } traceId: response.headers.get('x-trace-id') }
} catch (error) { } catch (error) {
return { method: 'GET', path, http: 0, bytes: new Uint8Array(), contentType: '', raw: String(error) } return { method: 'GET', path, http: 0, bytes: new Uint8Array(), contentType: '', raw: String(error) }
} }
} }
async function detail(context, caseId) {
const response = await context.request('GET', `${PLATFORM}/cases/${caseId}`)
const data = context.apiSuccess(MODULE, '回查报关协同聚合与版本', response, `caseId=${caseId}`, OPTIONS)
return { response, data }
}
export function selectCustomsFlow(createdResources) { export function selectCustomsFlow(createdResources) {
const fixtures = [ const fixtures = [
createdResources?.orders, createdResources?.orders,
@@ -64,6 +89,7 @@ export function selectCustomsFlow(createdResources) {
async function ensureReleasedOceanBooking(context, container) { async function ensureReleasedOceanBooking(context, container) {
const containerBookingId = id(container?.containerBookingId) const containerBookingId = id(container?.containerBookingId)
const loadingPlanId = id(container?.loadingPlanId)
const oceanBookingId = id(container?.oceanBookingId) const oceanBookingId = id(container?.oceanBookingId)
if (!oceanBookingId) { if (!oceanBookingId) {
context.addStep(MODULE, '报关前订舱已放舱并绑定实际装柜柜次', null, false, context.addStep(MODULE, '报关前订舱已放舱并绑定实际装柜柜次', null, false,
@@ -73,96 +99,421 @@ async function ensureReleasedOceanBooking(context, container) {
const response = await context.request('GET', `/api/platform/v1/ocean-bookings/${encodeURIComponent(oceanBookingId)}`) const response = await context.request('GET', `/api/platform/v1/ocean-bookings/${encodeURIComponent(oceanBookingId)}`)
const detail = context.apiSuccess(MODULE, '报关前回读装柜链正式订舱', response, '', OPTIONS) const detail = context.apiSuccess(MODULE, '报关前回读装柜链正式订舱', response, '', OPTIONS)
const allocated = detail?.containers?.some?.((item) => id(item?.containerBookingId) === containerBookingId) const allocated = detail?.containers?.some?.((item) => id(item?.containerBookingId) === containerBookingId)
const passed = detail?.oceanBooking?.status === 'RELEASED' && allocated if (detail?.oceanBooking?.status !== 'RELEASED' || !allocated) {
context.addStep(MODULE, '报关前订舱已放舱并绑定实际装柜柜次', response, passed, context.addStep(MODULE, '报关前订舱已放舱并绑定实际装柜柜次', response, false,
`oceanStatus=${detail?.oceanBooking?.status || 'missing'}, oceanBookingId=${oceanBookingId}, containerBookingId=${containerBookingId}, allocated=${allocated === true}`, OPTIONS) `oceanStatus=${detail?.oceanBooking?.status || 'missing'}, oceanBookingId=${oceanBookingId}, containerBookingId=${containerBookingId || 'missing'}, allocated=${allocated === true}`, OPTIONS)
if (passed) context.createdResources.oceanBooking = { return false
id: oceanBookingId, marker: context.marker, finalStatus: 'RELEASED', containerBookingId,
loadingPlanId: id(container?.loadingPlanId),
} }
return passed context.createdResources.oceanBooking = {
id: oceanBookingId, marker: context.marker, finalStatus: 'RELEASED',
containerBookingId, loadingPlanId,
}
context.addStep(MODULE, '报关前订舱已放舱并绑定实际装柜柜次', response, true,
`oceanBookingId=${oceanBookingId}, containerBookingId=${containerBookingId}`, OPTIONS)
return true
} }
async function verifyContainerOperationalDocuments(context, containerBookingId) { async function uploadVersion(context, path, label, expectedVersion, operation, content) {
const response = await upload(context, path, {
expectedVersion, operationReason: `${context.marker}-${operation}`,
}, content, `${context.marker}-${operation}.txt`, context.token, key(context, operation))
const document = context.apiSuccess(MODULE, label, response, '', OPTIONS)
context.assert(MODULE, `${label}返回当前唯一文件事实`, response,
Boolean(document?.id) && Boolean(document?.fileId) && Number.isInteger(Number(document?.version))
&& /^[0-9a-f]{64}$/i.test(String(document?.fileHash || '')),
`documentId=${id(document?.id) || 'missing'}, fileId=${id(document?.fileId) || 'missing'}, version=${document?.version ?? 'missing'}`, OPTIONS)
return { response, document }
}
async function verifyFourContainerDocuments(context, { containerBookingId, loadingPlanId, warehouseId }) {
const reason = `${context.marker}-人工验收节点资料`
const simpleDocuments = [
{ type: 'DESTINATION_CLEARANCE', base: `/api/platform/v1/containers/${containerBookingId}/clearance`,
uploadPath: `/api/platform/v1/containers/${containerBookingId}/clearance/document/upload` },
{ type: 'CUSTOMS_DECLARATION', base: `/api/platform/v1/first-leg/loading-lists/${loadingPlanId}/customs`,
query: `warehouseId=${encodeURIComponent(warehouseId)}`,
uploadPath: `/api/platform/v1/first-leg/loading-lists/${loadingPlanId}/customs/document/upload?warehouseId=${encodeURIComponent(warehouseId)}` },
]
const completed = []
for (const document of simpleDocuments) {
const first = await uploadVersion(context, document.uploadPath, `首次上传${document.type}`, 0,
`${document.type.toLowerCase()}-v1`, `${context.marker} ${document.type} v1`)
if (!first.document?.fileId) return false
const readPath = `${document.base}${document.query ? `?${document.query}` : ''}`
const firstRead = await context.request('GET', readPath)
const firstEcho = context.apiSuccess(MODULE, `回显${document.type}首版`, firstRead, '', OPTIONS)
context.assert(MODULE, `${document.type}首版回显身份一致`, firstRead,
id(firstEcho?.id) === id(first.document.id) && id(firstEcho?.fileId) === id(first.document.fileId),
`documentId=${id(firstEcho?.id) || 'missing'}, version=${firstEcho?.version ?? 'missing'}`, OPTIONS)
const firstDownloadPath = document.type === 'DESTINATION_CLEARANCE'
? `${document.base}/document/${id(first.document.fileId)}/download`
: `${document.base}/document/${id(first.document.fileId)}/download?${document.query}`
const firstDownload = await download(context, firstDownloadPath, context.token)
context.assert(MODULE, `下载${document.type}首版`, firstDownload,
firstDownload.http === 200 && firstDownload.bytes.length > 0 && firstDownload.contentType.includes('text/plain'),
`HTTP=${firstDownload.http}, bytes=${firstDownload.bytes.length}`, OPTIONS)
const second = await uploadVersion(context, document.uploadPath, `更新${document.type}为第二版`,
Number(firstEcho?.version), `${document.type.toLowerCase()}-v2`, `${context.marker} ${document.type} v2`)
context.assert(MODULE, `${document.type}更新保持同一聚合并递增版本`, second.response,
id(second.document?.id) === id(first.document.id) && Number(second.document?.version) === Number(firstEcho?.version) + 1,
`documentId=${id(second.document?.id) || 'missing'}, version=${second.document?.version ?? 'missing'}`, OPTIONS)
const completeResponse = await request(context, 'POST', `${document.base}/complete${document.query ? `?${document.query}` : ''}`, {
expectedVersion: Number(second.document?.version), operationReason: reason,
}, context.token, { 'Idempotency-Key': key(context, `${document.type.toLowerCase()}-complete`) })
const complete = context.apiSuccess(MODULE, `完成${document.type}`, completeResponse, '', OPTIONS)
context.assert(MODULE, `${document.type}完成态、时间和版本可回读`, completeResponse,
id(complete?.id) === id(first.document.id) && Boolean(complete?.completedAt)
&& Number(complete?.version) === Number(second.document?.version) + 1,
`completedAt=${complete?.completedAt || 'missing'}, version=${complete?.version ?? 'missing'}`, OPTIONS)
completed.push(complete)
}
for (const type of ['DELIVERY_ORDER', 'PICKUP_ORDER']) {
const base = `/api/platform/v1/containers/${containerBookingId}/documents/${type}`
const first = await uploadVersion(context, `${base}/upload`, `首次上传${type}`, 0,
`${type.toLowerCase()}-v1`, `${context.marker} ${type} v1`)
if (!first.document?.fileId) return false
const firstDownload = await download(context, `${base}/${id(first.document.fileId)}/download`, context.token)
context.assert(MODULE, `下载${type}首版`, firstDownload,
firstDownload.http === 200 && firstDownload.bytes.length > 0 && firstDownload.contentType.includes('text/plain'),
`HTTP=${firstDownload.http}, bytes=${firstDownload.bytes.length}`, OPTIONS)
const second = await uploadVersion(context, `${base}/upload`, `更新${type}为第二版`,
Number(first.document.version), `${type.toLowerCase()}-v2`, `${context.marker} ${type} v2`)
const echoResponse = await context.request('GET', base)
const echo = context.apiSuccess(MODULE, `回显${type}当前版本`, echoResponse, '', OPTIONS)
context.assert(MODULE, `${type}首次与更新保持同一聚合且当前版本唯一`, echoResponse,
id(echo?.id) === id(first.document.id) && id(echo?.fileId) === id(second.document?.fileId)
&& Number(echo?.version) === Number(first.document.version) + 1,
`documentId=${id(echo?.id) || 'missing'}, version=${echo?.version ?? 'missing'}`, OPTIONS)
}
const overviewResponse = await context.request('GET', `/api/platform/v1/containers/${containerBookingId}/overview`) const overviewResponse = await context.request('GET', `/api/platform/v1/containers/${containerBookingId}/overview`)
const overview = context.apiSuccess(MODULE, '只读复核前置模块已完成的整柜作业资料', overviewResponse, '', OPTIONS) const overview = context.apiSuccess(MODULE, '四类资料完成后回查整柜聚合', overviewResponse, '', OPTIONS)
const documents = Array.isArray(overview?.documents) ? overview.documents : [] const documents = Array.isArray(overview?.documents) ? overview.documents : []
const photos = Array.isArray(overview?.pda?.photos) ? overview.pda.photos.filter((item) => item?.status === 'ACTIVE') : [] const photos = Array.isArray(overview?.pda?.photos) ? overview.pda.photos.filter((item) => item?.status === 'ACTIVE') : []
const passed = ['DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER'] context.assert(MODULE, '整柜聚合回显四类当前资料与真实PDA照片证据', overviewResponse,
.every((type) => documents.some((item) => item?.documentType === type)) ['DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER']
&& documents.some((item) => item?.documentType === 'DESTINATION_CLEARANCE' && item?.completed === true) .every((type) => documents.some((item) => item?.documentType === type))
&& photos.length >= 5 && completed.every((item) => documents.some((value) => id(value?.id) === id(item?.id) && value?.completed === true))
context.assert(MODULE, '整柜聚合回显三类当前作业资料与真实PDA照片证据', overviewResponse, passed, && photos.length >= 5 && photos.every((item) => /^\/api\/platform\/v1\/containers\/\d+\/pda\/photos\/\d+\/download$/.test(String(item?.downloadPath || ''))),
`documents=${documents.map((item) => item?.documentType).join('|') || 'none'}, activePhotos=${photos.length}`, OPTIONS) `documents=${documents.map((item) => item?.documentType).join('|') || 'none'}, activePhotos=${photos.length}`, OPTIONS)
for (const [index, photo] of photos.entries()) { for (const [index, photo] of photos.entries()) {
const response = await download(context, photo.downloadPath) const response = await download(context, photo.downloadPath, context.token)
context.assert(MODULE, `下载整柜PDA照片证据${index + 1}`, response, context.assert(MODULE, `下载整柜PDA照片证据${index + 1}`, response,
response.http === 200 && response.bytes.length > 0 && !response.contentType.includes('application/json'), response.http === 200 && response.bytes.length > 0 && !response.contentType.includes('application/json'),
`fileId=${id(photo?.fileId) || 'missing'}, bytes=${response.bytes.length}`, OPTIONS) `fileId=${id(photo?.fileId) || 'missing'}, bytes=${response.bytes.length}`, OPTIONS)
} }
return passed return true
}
async function completeContainerCustoms(context, loadingPlanId) {
const base = `${PLATFORM_CUSTOMS}/${encodeURIComponent(loadingPlanId)}`
let response = await request(context, 'GET', base)
let detail = context.apiSuccess(MODULE, '读取正式整柜报关聚合', response,
`loadingPlanId=${loadingPlanId}`, OPTIONS)
if (!detail?.aggregateId) return null
if (detail.status === 'COMPLETED') return detail
if (detail.status === 'READY_TO_START') {
response = await request(context, 'POST', `${base}/start`, {
expectedVersion: Number(detail.version), confirm: true, remark: context.marker,
}, { 'Idempotency-Key': key(context, 'start') })
detail = context.apiSuccess(MODULE, '正式整柜报关开始并冻结资料', response, '', OPTIONS)
context.assert(MODULE, '正式整柜报关进入DECLARING', response,
detail?.status === 'DECLARING', `status=${detail?.status || 'missing'}`, OPTIONS)
}
const allBuy = Array.isArray(detail?.inbounds) && detail.inbounds.length > 0
&& detail.inbounds.every((item) => item?.taxMode === 'BUY_DECLARATION')
context.assert(MODULE, '本轮生产者整柜为全买单正式完成路径', response, allBuy,
`taxModes=${(detail?.inbounds || []).map((item) => item?.taxMode).join('|') || 'missing'}`, OPTIONS)
if (!allBuy || detail?.status !== 'DECLARING') return null
response = await request(context, 'POST', `${base}/complete-all-buy`, {
expectedVersion: Number(detail.version), confirm: true, remark: context.marker,
}, { 'Idempotency-Key': key(context, 'complete-all-buy') })
const completed = context.apiSuccess(MODULE, '正式全买单整柜报关完成', response, '', OPTIONS)
context.assert(MODULE, '正式整柜报关完成并满足出仓报关门禁', response,
completed?.status === 'COMPLETED' && Boolean(completed?.completedAt),
`status=${completed?.status || 'missing'}, completedAt=${completed?.completedAt || 'missing'}`, OPTIONS)
return completed?.status === 'COMPLETED' ? completed : null
} }
export async function run(context) { export async function run(context) {
const { fixture, container } = selectCustomsFlow(context.createdResources) const { fixture, container } = selectCustomsFlow(context.createdResources)
const tenantId = id(fixture?.tenantId) const tenantId = id(fixture?.tenantId)
const parentOrderId = id(fixture?.orderId)
const inboundId = id(fixture?.inboundId) const inboundId = id(fixture?.inboundId)
const containerBookingId = id(container?.containerBookingId) const containerBookingId = id(container?.containerBookingId)
const loadingPlanId = id(container?.loadingPlanId) const loadingPlanId = id(container?.loadingPlanId)
if (!tenantId || !inboundId || !containerBookingId || !loadingPlanId const warehouseId = id(container?.warehouseId || fixture?.warehouseId)
if (!tenantId || !parentOrderId || !inboundId || !containerBookingId || !loadingPlanId || !warehouseId
|| !['AWAITING_REVIEW', 'LOADED'].includes(container?.finalStatus) || !['AWAITING_REVIEW', 'LOADED'].includes(container?.finalStatus)
|| (container?.finalStatus === 'AWAITING_REVIEW' || (container?.finalStatus === 'AWAITING_REVIEW'
&& (container?.loadingPlanStatus !== 'LOADING' || !container?.canonicalPdaTaskId))) { && (container?.loadingPlanStatus !== 'LOADING' || !container?.canonicalPdaTaskId))) {
context.addStep(MODULE, '取得本轮客户、入仓单与柜次隔离fixture', null, false, context.addStep(MODULE, '取得本轮客户/订单/入库单/柜次隔离 fixture', null, false,
`tenantId=${tenantId || 'missing'}, inboundId=${inboundId || 'missing'}, containerBookingId=${containerBookingId || 'missing'}, loadingPlanId=${loadingPlanId || 'missing'}, containerStatus=${container?.finalStatus || 'missing'}`, `tenantId=${tenantId || 'missing'}, parentOrderId=${parentOrderId || 'missing'}, inboundId=${inboundId || 'missing'}, containerBookingId=${containerBookingId || 'missing'}, loadingPlanId=${loadingPlanId || 'missing'}, containerStatus=${container?.finalStatus || 'missing'}`, OPTIONS)
OPTIONS)
return return
} }
if (!await ensureReleasedOceanBooking(context, container)) return if (!await ensureReleasedOceanBooking(context, container)) return
if (!await verifyContainerOperationalDocuments(context, containerBookingId)) {
context.addStep(MODULE, '三类整柜作业资料必须完成后才允许进入出仓确认', null, false, const currentPath = `/api/platform/v1/customs/loading-plans/${encodeURIComponent(loadingPlanId)}`
`containerBookingId=${containerBookingId}, loadingPlanId=${loadingPlanId}`, OPTIONS) const currentDetailResponse = await context.request('GET', currentPath)
let currentCustoms = context.apiSuccess(MODULE, '读取 customs-v1 整柜报关聚合', currentDetailResponse,
`loadingPlanId=${loadingPlanId}`, OPTIONS)
const allBuy = Array.isArray(currentCustoms?.inbounds) && currentCustoms.inbounds.length > 0
&& currentCustoms.inbounds.every((item) => item?.taxMode === 'BUY_DECLARATION')
if (allBuy) {
if (currentCustoms.status === 'READY_TO_START') {
const startedResponse = await context.request('POST', `${currentPath}/start`, {
expectedVersion: Number(currentCustoms.version), confirm: true, remark: context.marker,
}, context.token, { 'Idempotency-Key': key(context, 'current-all-buy-start') })
currentCustoms = context.apiSuccess(MODULE, '全买单柜开始报关', startedResponse, '', OPTIONS)
}
if (currentCustoms?.status === 'DECLARING') {
const completedResponse = await context.request('POST', `${currentPath}/complete-all-buy`, {
expectedVersion: Number(currentCustoms.version), confirm: true, remark: context.marker,
}, context.token, { 'Idempotency-Key': key(context, 'current-all-buy-complete') })
currentCustoms = context.apiSuccess(MODULE, '全买单柜完成报关', completedResponse, '', OPTIONS)
}
context.assert(MODULE, 'customs-v1 全买单柜进入报关完成', currentDetailResponse,
currentCustoms?.status === 'COMPLETED' && id(currentCustoms?.loadingPlanId) === loadingPlanId,
`status=${currentCustoms?.status || 'missing'}, loadingPlanId=${id(currentCustoms?.loadingPlanId) || 'missing'}`, OPTIONS)
if (currentCustoms?.status === 'COMPLETED') {
context.createdResources.phase3Customs = {
marker: context.marker, tenantId, parentOrderId, orderId: parentOrderId, inboundId,
containerBookingId, loadingPlanId, finalStatus: 'CLEARANCE_COMPLETED',
}
}
return return
} }
const customs = await completeContainerCustoms(context, loadingPlanId)
if (!customs) { const slicesPath = `/api/platform/v1/orders/container-bookings/${encodeURIComponent(containerBookingId)}/fulfillment-slices?tenantId=${encodeURIComponent(tenantId)}`
context.addStep(MODULE, '正式整柜报关聚合必须完成后才允许进入出仓确认', null, false, const slicesResponse = await context.request('GET', slicesPath)
`containerBookingId=${containerBookingId}, loadingPlanId=${loadingPlanId}`, OPTIONS) const slices = context.apiSuccess(MODULE, '读取柜次真实履约分片以确定报关订单', slicesResponse,
`containerBookingId=${containerBookingId}, tenantId=${tenantId}`, OPTIONS)
const carryingSlices = Array.isArray(slices)
? slices.filter((item) => id(item?.tenantId) === tenantId
&& id(item?.containerBookingId) === containerBookingId
&& id(item?.parentOrderId) === parentOrderId
&& item?.splitRole === 'LOADED'
&& (!loadingPlanId || id(item?.loadingPlanId) === loadingPlanId))
: []
const carryingSlice = carryingSlices.length === 1 ? carryingSlices[0] : null
const actualOrderId = id(carryingSlice?.childOrderId)
const sourceOrderId = id(carryingSlice?.sourceOrderId)
const sourceCarriesLoaded = Boolean(actualOrderId) && actualOrderId === sourceOrderId
const legacyLoadedChild = Boolean(actualOrderId) && Boolean(sourceOrderId)
&& actualOrderId !== sourceOrderId && actualOrderId !== parentOrderId
context.assert(MODULE, '报关绑定柜次实际承载的唯一 LOADED 来源分片', slicesResponse,
carryingSlices.length === 1 && (sourceCarriesLoaded || legacyLoadedChild)
&& id(carryingSlice?.parentOrderId) === parentOrderId
&& id(carryingSlice?.containerBookingId) === containerBookingId
&& id(carryingSlice?.tenantId) === tenantId
&& Number.isInteger(Number(carryingSlice?.packageCount)) && Number(carryingSlice.packageCount) > 0
&& (!loadingPlanId || id(carryingSlice?.loadingPlanId) === loadingPlanId),
`parentOrderId=${parentOrderId}, sourceOrderId=${sourceOrderId || 'missing'}, actualOrderId=${actualOrderId || 'missing'}, packageCount=${carryingSlice?.packageCount ?? 'missing'}, model=${sourceCarriesLoaded ? 'SOURCE_CARRIES_LOADED' : legacyLoadedChild ? 'LEGACY_LOADED_CHILD' : 'INVALID'}, containerBookingId=${containerBookingId}, loadingPlanId=${id(carryingSlice?.loadingPlanId) || 'missing'}, matches=${carryingSlices.length}`, OPTIONS)
if (carryingSlices.length !== 1 || (!sourceCarriesLoaded && !legacyLoadedChild)
|| !Number.isInteger(Number(carryingSlice?.packageCount)) || Number(carryingSlice.packageCount) <= 0) return
const loginResponse = await request(context, 'POST', '/api/auth/login', {
username: MERCHANT_USERNAME, password: MERCHANT_PASSWORD,
}, '')
const merchantLogin = context.apiSuccess(MODULE, '客户账号登录用于真实报关文件协同', loginResponse,
`username=${MERCHANT_USERNAME}`, OPTIONS)
const merchantToken = merchantLogin?.accessToken
if (!merchantToken) return
const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase()
const checklistPayload = {
checklistCode: `USFBA${suffix}`.slice(0, 40), versionNo: `V${suffix}`.slice(0, 32),
countryCode: 'US', channelCode: 'FBA', cargoType: 'GENERAL',
items: [{ code: 'INVOICE', name: '商业发票', required: true }],
}
const checklistResponse = await request(context, 'POST', `${PLATFORM}/checklist-versions`, checklistPayload,
context.token, { 'Idempotency-Key': key(context, 'checklist') })
const checklist = context.apiSuccess(MODULE, '创建本轮不可变报关资料清单版本', checklistResponse, '', OPTIONS)
const checklistVersionId = id(checklist?.version?.id)
context.assert(MODULE, '资料清单保留国家、渠道、货物类型和必填项目版本', checklistResponse,
Boolean(checklistVersionId) && checklist?.version?.countryCode === 'US' && checklist?.version?.channelCode === 'FBA'
&& checklist?.version?.cargoType === 'GENERAL' && checklist?.items?.[0]?.code === 'INVOICE'
&& checklist?.items?.[0]?.required === true,
`checklistVersionId=${checklistVersionId || 'missing'}, items=${checklist?.items?.length ?? 'n/a'}`, OPTIONS)
if (!checklistVersionId) return
const casePayload = { customerId: tenantId, orderId: actualOrderId, inboundId, containerBookingId,
checklistVersionId, remark: context.marker, expectedVersion: 0 }
const caseResponse = await request(context, 'POST', `${PLATFORM}/cases`, casePayload, context.token,
{ 'Idempotency-Key': key(context, 'case') })
const created = context.apiSuccess(MODULE, '创建客户订单柜次关联报关协同单', caseResponse, '', OPTIONS)
const caseId = id(created?.customsCase?.id)
const customsFieldGaps = customsCaseFieldGaps(created)
context.assert(MODULE, '报关单绑定本轮实际承载来源分片、入库单、柜次和清单版本', caseResponse,
Boolean(caseId) && id(created?.customsCase?.tenantId) === tenantId && id(created?.customsCase?.orderId) === actualOrderId
&& id(created?.customsCase?.inboundId) === inboundId && id(created?.customsCase?.containerBookingId) === containerBookingId
&& id(created?.customsCase?.checklistVersionId) === checklistVersionId
&& created?.customsCase?.status === 'DOCUMENTS_PENDING' && Number(created?.customsCase?.version) === 0,
`caseId=${caseId || 'missing'}, parentOrderId=${parentOrderId}, sourceOrderId=${sourceOrderId}, actualOrderId=${actualOrderId}, status=${created?.customsCase?.status || 'missing'}`, OPTIONS)
context.assert(MODULE, '报关列表单号、订单入库柜次、清单版本、状态与更新时间字段完整', caseResponse,
customsFieldGaps.length === 0, `missing=${customsFieldGaps.join(',') || 'none'}`, OPTIONS)
if (!caseId) return
const caseReplay = await request(context, 'POST', `${PLATFORM}/cases`, casePayload, context.token,
{ 'Idempotency-Key': key(context, 'case') })
context.assert(MODULE, '创建报关单幂等重放返回同一聚合', caseReplay,
codeOf(caseReplay) === '200' && id(caseReplay.body?.data?.customsCase?.id) === caseId,
`HTTP=${caseReplay.http}, code=${codeOf(caseReplay)}, caseId=${id(caseReplay.body?.data?.customsCase?.id) || 'missing'}`, OPTIONS)
const forgedCaseResponse = await request(context, 'POST', `${PLATFORM}/cases`, {
...casePayload, containerBookingId: '999999999999999999', remark: `${context.marker}-forged-container`,
}, context.token, { 'Idempotency-Key': key(context, 'forged-case') })
context.addStep(MODULE, '伪造未承载客户订单的柜次被拒绝', forgedCaseResponse, failure(forgedCaseResponse),
`HTTP=${forgedCaseResponse.http}, code=${codeOf(forgedCaseResponse)}`, OPTIONS)
const firstUploadPath = `${MERCHANT}/cases/${caseId}/documents/upload`
const firstUpload = await upload(context, firstUploadPath, { itemCode: 'INVOICE', expectedVersion: 0 },
`${context.marker} invoice version 1`, `${context.marker}-invoice-v1.txt`, merchantToken, key(context, 'invoice-v1'))
const document1 = context.apiSuccess(MODULE, '客户上传首版报关发票', firstUpload, '', OPTIONS)
const document1Id = id(document1?.id)
context.assert(MODULE, '客户文件版本、哈希、上传主体和业务归属已持久化', firstUpload,
Boolean(document1Id) && document1?.checklistItemCode === 'INVOICE' && Number(document1?.documentVersion) === 1
&& document1?.status === 'UPLOADED' && document1?.uploadedByType === 'MERCHANT'
&& /^[0-9a-f]{64}$/.test(String(document1?.fileHash || '')),
`documentId=${document1Id || 'missing'}, version=${document1?.documentVersion ?? 'missing'}`, OPTIONS)
if (!document1Id) return
const firstUploadReplay = await upload(context, firstUploadPath, { itemCode: 'INVOICE', expectedVersion: 0 },
`${context.marker} invoice version 1`, `${context.marker}-invoice-v1.txt`, merchantToken, key(context, 'invoice-v1'))
context.assert(MODULE, '客户文件上传在聚合版本变化后仍可幂等重放', firstUploadReplay,
codeOf(firstUploadReplay) === '200' && id(firstUploadReplay.body?.data?.id) === document1Id
&& Number(firstUploadReplay.body?.data?.documentVersion) === 1,
`HTTP=${firstUploadReplay.http}, code=${codeOf(firstUploadReplay)}, documentId=${id(firstUploadReplay.body?.data?.id) || 'missing'}`, OPTIONS)
let current = await detail(context, caseId)
context.assert(MODULE, '资料齐备后进入 REVIEWING 且版本递增', current.response,
current.data?.customsCase?.status === 'REVIEWING' && Number(current.data?.customsCase?.version) === 1,
`status=${current.data?.customsCase?.status || 'missing'}, version=${current.data?.customsCase?.version ?? 'missing'}`, OPTIONS)
const returnedResponse = await request(context, 'POST', `${PLATFORM}/cases/${caseId}/documents/${document1Id}/review`, {
decision: 'RETURN', reason: `${context.marker}-发票金额需补正`, supplementDueAt: '2026-09-30T18:00:00', expectedVersion: 1,
}, context.token, { 'Idempotency-Key': key(context, 'return-v1') })
const returned = context.apiSuccess(MODULE, '平台退回具体文件并创建补件请求', returnedResponse, '', OPTIONS)
context.assert(MODULE, '退回进入 SUPPLEMENT_REQUIRED 并保留原因、时限和历史', returnedResponse,
returned?.customsCase?.status === 'SUPPLEMENT_REQUIRED' && Number(returned?.customsCase?.version) === 2
&& returned?.supplements?.some?.((item) => item.status === 'OPEN' && item.checklistItemCode === 'INVOICE'
&& String(item.reason || '').includes(context.marker) && Boolean(item.dueAt))
&& returned?.history?.some?.((item) => item.action === 'REVIEW_DOCUMENT_RETURN' && id(item.resourceId) === document1Id),
`status=${returned?.customsCase?.status || 'missing'}, supplements=${returned?.supplements?.length ?? 'n/a'}`, OPTIONS)
const replacement = await upload(context, firstUploadPath, { itemCode: 'INVOICE', expectedVersion: 2 },
`${context.marker} invoice version 2 corrected`, `${context.marker}-invoice-v2.txt`, merchantToken, key(context, 'invoice-v2'))
const document2 = context.apiSuccess(MODULE, '客户补件替换发票并生成新版本', replacement, '', OPTIONS)
const document2Id = id(document2?.id)
context.assert(MODULE, '替换文件保留上一版本引用且版本递增', replacement,
Boolean(document2Id) && Number(document2?.documentVersion) === 2 && id(document2?.replacesDocumentId) === document1Id
&& document2?.status === 'UPLOADED',
`documentId=${document2Id || 'missing'}, replaces=${id(document2?.replacesDocumentId) || 'missing'}`, OPTIONS)
if (!document2Id) return
current = await detail(context, caseId)
context.assert(MODULE, '补件任务解决且历史文件版本未覆盖', current.response,
current.data?.supplements?.some?.((item) => item.status === 'RESOLVED' && id(item.resolvedDocumentId) === document2Id)
&& current.data?.documents?.some?.((item) => id(item.id) === document1Id && item.status === 'SUPERSEDED')
&& current.data?.documents?.some?.((item) => id(item.id) === document2Id && Number(item.documentVersion) === 2),
`documents=${current.data?.documents?.length ?? 'n/a'}, supplements=${current.data?.supplements?.length ?? 'n/a'}`, OPTIONS)
const approveResponse = await request(context, 'POST', `${PLATFORM}/cases/${caseId}/documents/${document2Id}/review`, {
decision: 'APPROVE', reason: '资料完整', supplementDueAt: null, expectedVersion: 3,
}, context.token, { 'Idempotency-Key': key(context, 'approve-v2') })
const approved = context.apiSuccess(MODULE, '平台审核通过补件版本', approveResponse, '', OPTIONS)
context.assert(MODULE, '全部必填文件通过后进入 READY_TO_SUBMIT', approveResponse,
approved?.customsCase?.status === 'READY_TO_SUBMIT' && Number(approved?.customsCase?.version) === 4,
`status=${approved?.customsCase?.status || 'missing'}, version=${approved?.customsCase?.version ?? 'missing'}`, OPTIONS)
const supplierCreateResponse = await context.request('POST', '/api/platform/v1/suppliers?tenantId=0', {
name: `报关代理-${context.marker}`.slice(0, 128), category: 'CUSTOMS', region: '深圳',
legalRepresentative: '验收法人', contactName: '报关联系人', phone: '13800000000',
email: 'customs-acceptance@example.com', address: '深圳市验收地址',
businessLicenseNo: `CUSTOMS${context.marker.replace(/\D/g, '')}`.slice(0, 64), licenseFileId: null,
status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: context.marker,
})
const customsAgentId = id(context.apiSuccess(MODULE, '创建本轮正式 ACTIVE 报关代理供应商',
supplierCreateResponse, `marker=${context.marker}`, OPTIONS))
if (!customsAgentId) return
const supplierDetailResponse = await context.request('GET',
`/api/platform/v1/suppliers/${encodeURIComponent(customsAgentId)}?tenantId=0`)
const customsAgent = context.apiSuccess(MODULE, '回查本轮报关代理供应商', supplierDetailResponse, '', OPTIONS)
context.assert(MODULE, '代理提交使用本轮正式 CUSTOMS 供应商身份', supplierDetailResponse,
id(customsAgent?.id) === customsAgentId && customsAgent?.status === 'ACTIVE'
&& customsAgent?.category === 'CUSTOMS' && customsAgent?.remark === context.marker,
`customsAgentId=${id(customsAgent?.id) || 'missing'}, customsAgentName=${customsAgent?.name || 'missing'}`, OPTIONS)
if (id(customsAgent?.id) !== customsAgentId || customsAgent?.status !== 'ACTIVE'
|| customsAgent?.category !== 'CUSTOMS') return
context.createdResources.phase3CustomsSupplier = { marker: context.marker, supplierId: customsAgentId,
supplierName: customsAgent.name, status: customsAgent.status }
const batchPayload = { customsAgentId, externalReference: `EXT-${suffix}`,
note: context.marker, expectedVersion: 4 }
const batchResponse = await request(context, 'POST', `${PLATFORM}/cases/${caseId}/submission-batches`, batchPayload,
context.token, { 'Idempotency-Key': key(context, 'batch') })
const batch = context.apiSuccess(MODULE, '生成代理提交批次并冻结文件版本', batchResponse, '', OPTIONS)
const batchId = id(batch?.id)
let snapshot = []
try { snapshot = JSON.parse(batch?.fileSnapshotJson || '[]') } catch { /* asserted below */ }
context.assert(MODULE, '代理批次冻结当前文件 ID、版本和哈希', batchResponse,
Boolean(batchId) && batch?.status === 'SUBMITTED' && Number(batch?.batchVersion) === 1
&& snapshot.some?.((item) => id(item.documentId) === document2Id && Number(item.documentVersion) === 2
&& /^[0-9a-f]{64}$/.test(String(item.fileHash || ''))),
`batchId=${batchId || 'missing'}, frozenFiles=${snapshot.length}`, OPTIONS)
if (!batchId) return
const batchReplay = await request(context, 'POST', `${PLATFORM}/cases/${caseId}/submission-batches`, batchPayload,
context.token, { 'Idempotency-Key': key(context, 'batch') })
context.assert(MODULE, '代理提交幂等重放返回同一冻结批次', batchReplay,
codeOf(batchReplay) === '200' && id(batchReplay.body?.data?.id) === batchId,
`HTTP=${batchReplay.http}, code=${codeOf(batchReplay)}, batchId=${id(batchReplay.body?.data?.id) || 'missing'}`, OPTIONS)
const staleSubmit = await request(context, 'POST', `${PLATFORM}/cases/${caseId}/submission-batches`, {
...batchPayload, externalReference: `EXT-DIFFERENT-${suffix}`,
}, context.token, { 'Idempotency-Key': key(context, 'batch') })
context.addStep(MODULE, '同一幂等键不同代理提交请求被拒绝', staleSubmit,
staleSubmit.http === 200 && codeOf(staleSubmit) !== '200',
`HTTP=${staleSubmit.http}, code=${codeOf(staleSubmit)}`, OPTIONS)
const receivedUpload = await upload(context, `${PLATFORM}/submission-batches/${batchId}/receipts/upload`, {
receiptType: 'RECEIVED', visibility: 'PLATFORM', note: context.marker, expectedVersion: 5,
}, `${context.marker} agent received`, `${context.marker}-received.txt`, context.token, key(context, 'receipt-received'))
const received = context.apiSuccess(MODULE, '上传平台可见代理接收回执', receivedUpload, '', OPTIONS)
const receivedId = id(received?.id)
context.assert(MODULE, '接收回执版本和平台可见范围正确', receivedUpload,
Boolean(receivedId) && received?.receiptType === 'RECEIVED' && received?.visibility === 'PLATFORM'
&& Number(received?.receiptVersion) === 1,
`receiptId=${receivedId || 'missing'}, visibility=${received?.visibility || 'missing'}`, OPTIONS)
const releaseUpload = await upload(context, `${PLATFORM}/submission-batches/${batchId}/receipts/upload`, {
receiptType: 'RELEASED', visibility: 'CUSTOMER', note: `${context.marker}-放行`, expectedVersion: 6,
}, `${context.marker} customs released`, `${context.marker}-released.txt`, context.token, key(context, 'receipt-released'))
const released = context.apiSuccess(MODULE, '上传客户可见海关放行回执', releaseUpload, '', OPTIONS)
const releasedId = id(released?.id)
context.assert(MODULE, '放行回执类型、版本和客户可见范围正确', releaseUpload,
Boolean(releasedId) && released?.receiptType === 'RELEASED' && released?.visibility === 'CUSTOMER'
&& Number(released?.receiptVersion) === 1,
`receiptId=${releasedId || 'missing'}, visibility=${released?.visibility || 'missing'}`, OPTIONS)
if (!releasedId) return
current = await detail(context, caseId)
context.assert(MODULE, '报关放行推进 CLEARANCE_COMPLETED 并保留完整稳定历史', current.response,
current.data?.customsCase?.status === 'CLEARANCE_COMPLETED' && Number(current.data?.customsCase?.version) === 7
&& current.data?.documents?.some?.((item) => id(item.id) === document2Id && item.status === 'FROZEN')
&& current.data?.history?.some?.((item) => item.action === 'SUBMIT_AGENT_BATCH' && id(item.resourceId) === batchId)
&& current.data?.history?.some?.((item) => item.action === 'UPLOAD_RECEIPT' && id(item.resourceId) === releasedId
&& item.toStatus === 'CLEARANCE_COMPLETED' && Boolean(item.operatorId) && Boolean(item.createdAt)),
`status=${current.data?.customsCase?.status || 'missing'}, history=${current.data?.history?.length ?? 'n/a'}`, OPTIONS)
const platformDownload = await download(context, `${PLATFORM}/cases/${caseId}/receipts/${releasedId}/download`, context.token)
context.assert(MODULE, '平台授权下载真实放行回执内容', platformDownload,
platformDownload.http === 200 && platformDownload.contentType.includes('text/plain') && platformDownload.bytes.length > 0,
`HTTP=${platformDownload.http}, bytes=${platformDownload.bytes.length}, contentType=${platformDownload.contentType || 'missing'}`, OPTIONS)
const merchantDetailResponse = await request(context, 'GET', `${MERCHANT}/cases/${caseId}`, undefined, merchantToken)
const merchantDetail = context.apiSuccess(MODULE, '客户查询本企业报关进度和可见回执', merchantDetailResponse, '', OPTIONS)
context.assert(MODULE, '客户详情过滤平台回执并保留客户放行回执', merchantDetailResponse,
id(merchantDetail?.customsCase?.tenantId) === tenantId
&& merchantDetail?.receipts?.some?.((item) => id(item.id) === releasedId)
&& !merchantDetail?.receipts?.some?.((item) => id(item.id) === receivedId),
`receipts=${merchantDetail?.receipts?.length ?? 'n/a'}`, OPTIONS)
const merchantDownload = await download(context, `${MERCHANT}/cases/${caseId}/receipts/${releasedId}/download`, merchantToken)
context.assert(MODULE, '客户授权下载客户可见放行回执', merchantDownload,
merchantDownload.http === 200 && merchantDownload.bytes.length > 0,
`HTTP=${merchantDownload.http}, bytes=${merchantDownload.bytes.length}`, OPTIONS)
const hiddenDownload = await download(context, `${MERCHANT}/cases/${caseId}/receipts/${receivedId}/download`, merchantToken)
context.addStep(MODULE, '客户下载平台可见回执被拒绝', hiddenDownload,
hiddenDownload.http !== 200 || String(hiddenDownload.body?.code || '') !== '200',
`HTTP=${hiddenDownload.http}, code=${hiddenDownload.body?.code || 'binary'}, bytes=${hiddenDownload.bytes.length}`, OPTIONS)
const forgedReference = await request(context, 'POST', `${PLATFORM}/cases/${caseId}/documents`, {
itemCode: 'INVOICE', fileId: released?.fileId, expectedVersion: 7,
}, context.token, { 'Idempotency-Key': key(context, 'forged-file-reference') })
context.addStep(MODULE, '回执 fileId 不能伪造为客户报关资料引用', forgedReference, failure(forgedReference),
`HTTP=${forgedReference.http}, code=${codeOf(forgedReference)}`, OPTIONS)
if (!(await verifyFourContainerDocuments(context, { containerBookingId, loadingPlanId, warehouseId }))) {
context.addStep(MODULE, '四类整柜资料必须完成后才允许进入出仓确认', null, false,
`containerBookingId=${containerBookingId}, loadingPlanId=${loadingPlanId}, warehouseId=${warehouseId}`, OPTIONS)
return return
} }
context.createdResources.phase3Customs = { context.createdResources.phase3Customs = {
marker: context.marker, tenantId, orderId: id(fixture?.orderId), inboundId, marker: context.marker, tenantId, parentOrderId, orderId: actualOrderId, inboundId, containerBookingId, loadingPlanId,
containerBookingId, loadingPlanId, customsContainerId: id(customs.aggregateId), customsCaseId: caseId, submissionBatchId: batchId, releasedReceiptId: releasedId,
finalStatus: 'COMPLETED', finalStatus: 'CLEARANCE_COMPLETED',
} }
} }
@@ -99,21 +99,6 @@ async function waitForCompletion(context, taskDetailPath, operationsPath, attemp
return { taskResponse, task, operationsResponse, operations, attempts } return { taskResponse, task, operationsResponse, operations, attempts }
} }
async function waitForEvidenceSnapshot(context, evidencePath, expectedTypes, attempts = 30) {
let response = null
for (let attempt = 1; attempt <= attempts; attempt += 1) {
response = await context.request('GET', evidencePath)
const evidences = response?.http === 200 && String(response?.body?.code) === '200'
? response.body.data?.evidences : null
if (Array.isArray(evidences) && expectedTypes.every((type) =>
evidences.some((item) => item?.evidenceType === type && item?.status !== 'VOID'))) {
return { response, attempts: attempt }
}
if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, 1_000))
}
return { response, attempts }
}
export async function run(context) { export async function run(context) {
const fixture = context.createdResources?.phase3Loading const fixture = context.createdResources?.phase3Loading
const tenantId = id(fixture?.tenantId); const inboundId = id(fixture?.inboundId) const tenantId = id(fixture?.tenantId); const inboundId = id(fixture?.inboundId)
@@ -136,6 +121,7 @@ export async function run(context) {
`status=${before?.summary?.processStatus || 'missing'}, version=${before?.summary?.version ?? 'missing'}`, OPTIONS) `status=${before?.summary?.processStatus || 'missing'}, version=${before?.summary?.version ?? 'missing'}`, OPTIONS)
if (before?.summary?.processStatus !== 'LOADING' || !Number.isInteger(version)) return if (before?.summary?.processStatus !== 'LOADING' || !Number.isInteger(version)) return
const base = `/api/platform/v1/inbounds/${inboundId}`
const taskPath = `/api/platform/v1/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}` const taskPath = `/api/platform/v1/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}`
const taskDetailPath = `${taskPath}?warehouseId=${encodeURIComponent(id(fixture.warehouseId))}` const taskDetailPath = `${taskPath}?warehouseId=${encodeURIComponent(id(fixture.warehouseId))}`
const taskBeforeResponse = await context.request('GET', taskDetailPath) const taskBeforeResponse = await context.request('GET', taskDetailPath)
@@ -315,16 +301,13 @@ export async function run(context) {
Boolean(unifiedRow) && unifiedRow?.mainStatus === 'LOADED', Boolean(unifiedRow) && unifiedRow?.mainStatus === 'LOADED',
`orderId=${orderId}, found=${Boolean(unifiedRow)}, mainStatus=${unifiedRow?.mainStatus || 'missing'}`, OPTIONS) `orderId=${orderId}, found=${Boolean(unifiedRow)}, mainStatus=${unifiedRow?.mainStatus || 'missing'}`, OPTIONS)
const evidenceBase = `${taskPath}/evidence` const evidencePath = `/api/platform/v1/first-leg/pda-loading/tasks/${encodeURIComponent(id(fixture.canonicalPdaTaskId))}/evidence?warehouseId=${encodeURIComponent(id(fixture.warehouseId))}`
const evidencePath = `${evidenceBase}?warehouseId=${encodeURIComponent(id(fixture.warehouseId))}` const immutableEvidenceResponse = await context.request('GET', evidencePath)
const immutableEvidenceResult = await waitForEvidenceSnapshot(
context, evidencePath, fixture.evidenceTypes || [])
const immutableEvidenceResponse = immutableEvidenceResult.response
const immutableEvidence = context.apiSuccess(MODULE, '读取 LOADED 后不可变证据快照', immutableEvidenceResponse, '', OPTIONS) const immutableEvidence = context.apiSuccess(MODULE, '读取 LOADED 后不可变证据快照', immutableEvidenceResponse, '', OPTIONS)
context.assert(MODULE, '统一硬门禁通过后五类有效证据仍完整', immutableEvidenceResponse, context.assert(MODULE, '统一硬门禁通过后五类有效证据仍完整', immutableEvidenceResponse,
fixture.evidenceTypes?.every?.((type) => immutableEvidence?.evidences?.some?.((item) => fixture.evidenceTypes?.every?.((type) => immutableEvidence?.evidences?.some?.((item) =>
item.evidenceType === type && item.status !== 'VOID')), item.evidenceType === type && item.status !== 'VOID')),
`evidences=${immutableEvidence?.evidences?.length ?? 'missing'}, attempts=${immutableEvidenceResult.attempts}`, OPTIONS) `evidences=${immutableEvidence?.evidences?.length ?? 'missing'}`, OPTIONS)
const immutablePlanResponse = await context.request('GET', planPath) const immutablePlanResponse = await context.request('GET', planPath)
const immutablePlan = context.apiSuccess(MODULE, '读取 LOADED 后装柜计划快照', immutablePlanResponse, '', OPTIONS) const immutablePlan = context.apiSuccess(MODULE, '读取 LOADED 后装柜计划快照', immutablePlanResponse, '', OPTIONS)
const finalVersion = Number(finalData?.summary?.version) const finalVersion = Number(finalData?.summary?.version)
@@ -358,17 +341,11 @@ export async function run(context) {
&& Boolean(completeSameKeyDifferentBody.requestId) && Boolean(completeSameKeyDifferentBody.traceId), && Boolean(completeSameKeyDifferentBody.requestId) && Boolean(completeSameKeyDifferentBody.traceId),
`HTTP=${completeSameKeyDifferentBody.http}, code=${codeOf(completeSameKeyDifferentBody)}, requestId=${completeSameKeyDifferentBody.requestId || 'missing'}, traceId=${completeSameKeyDifferentBody.traceId || 'missing'}`, OPTIONS) `HTTP=${completeSameKeyDifferentBody.http}, code=${codeOf(completeSameKeyDifferentBody)}, requestId=${completeSameKeyDifferentBody.requestId || 'missing'}, traceId=${completeSameKeyDifferentBody.traceId || 'missing'}`, OPTIONS)
const postLoadedEvidenceForm = new FormData() const postLoadedForm = new FormData()
postLoadedEvidenceForm.append('file', new Blob([Uint8Array.from([ postLoadedForm.append('file', new Blob([`${context.marker}-after-loaded`], { type: 'text/plain' }), 'after-loaded.txt')
137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,
0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130,
])], { type: 'image/png' }), `${context.marker}-after-loaded.png`)
const postLoadedEvidenceQuery = new URLSearchParams({
warehouseId: id(fixture.warehouseId), expectedVersion: String(fixture.immutableEvidenceVersion),
evidenceType: 'PHOTO_STEP_1', remark: `${context.marker}-after-loaded`,
})
const postLoadedEvidence = await context.requestMultipart('POST', const postLoadedEvidence = await context.requestMultipart('POST',
`${evidenceBase}?${postLoadedEvidenceQuery}`, postLoadedEvidenceForm, context.token, `${evidencePath}&expectedVersion=0&evidenceType=PHOTO_STEP_2&remark=${encodeURIComponent(context.marker)}`,
postLoadedForm, context.token,
{ 'Idempotency-Key': `${context.marker}-p3-evidence-after-loaded`.slice(0, 128) }) { 'Idempotency-Key': `${context.marker}-p3-evidence-after-loaded`.slice(0, 128) })
context.addStep(MODULE, 'LOADED 后新增装柜证据被拒绝', postLoadedEvidence, context.addStep(MODULE, 'LOADED 后新增装柜证据被拒绝', postLoadedEvidence,
postLoadedEvidence.http === 200 && codeOf(postLoadedEvidence) === 'A0409' postLoadedEvidence.http === 200 && codeOf(postLoadedEvidence) === 'A0409'
@@ -376,9 +353,10 @@ export async function run(context) {
`HTTP=${postLoadedEvidence.http}, code=${codeOf(postLoadedEvidence)}, requestId=${postLoadedEvidence.requestId || 'missing'}, traceId=${postLoadedEvidence.traceId || 'missing'}`, OPTIONS) `HTTP=${postLoadedEvidence.http}, code=${codeOf(postLoadedEvidence)}, requestId=${postLoadedEvidence.requestId || 'missing'}, traceId=${postLoadedEvidence.traceId || 'missing'}`, OPTIONS)
const postLoadedVoid = await request(context, 'POST', const postLoadedVoid = await request(context, 'POST',
`${evidenceBase}/${encodeURIComponent(id(fixture.immutableEvidenceId))}/void`, `/api/platform/v1/first-leg/pda-loading/tasks/${encodeURIComponent(id(fixture.canonicalPdaTaskId))}/evidence/${encodeURIComponent(id(fixture.immutableEvidenceId))}/void`,
{ warehouseId: id(fixture.warehouseId), expectedVersion: Number(fixture.immutableEvidenceVersion), { warehouseId: id(fixture.warehouseId), expectedVersion: Number(fixture.immutableEvidenceVersion),
reason: `${context.marker}-void-after-loaded` }) reason: `${context.marker}-void-after-loaded` },
{ 'Idempotency-Key': `${context.marker}-p3-evidence-void-after-loaded`.slice(0, 128) })
context.addStep(MODULE, 'LOADED 后作废装柜证据被拒绝', postLoadedVoid, context.addStep(MODULE, 'LOADED 后作废装柜证据被拒绝', postLoadedVoid,
postLoadedVoid.http === 200 && codeOf(postLoadedVoid) === 'A0409' postLoadedVoid.http === 200 && codeOf(postLoadedVoid) === 'A0409'
&& Boolean(postLoadedVoid.requestId) && Boolean(postLoadedVoid.traceId), && Boolean(postLoadedVoid.requestId) && Boolean(postLoadedVoid.traceId),
@@ -386,7 +364,7 @@ export async function run(context) {
const immutableTransport = await request(context, 'PATCH', const immutableTransport = await request(context, 'PATCH',
`/api/platform/v1/first-leg/loading-plans/${encodeURIComponent(id(fixture.loadingPlanId))}/transport?warehouseId=${encodeURIComponent(id(fixture.warehouseId))}`, `/api/platform/v1/first-leg/loading-plans/${encodeURIComponent(id(fixture.loadingPlanId))}/transport?warehouseId=${encodeURIComponent(id(fixture.warehouseId))}`,
{ containerNo: immutablePlan?.containerNo, voyageNo: 'MUTATE', { containerNo: fixture.containerNo, voyageNo: 'MUTATE',
expectedVersion: Number(immutablePlan?.version), operationReason: `${context.marker}-after-loaded` }, expectedVersion: Number(immutablePlan?.version), operationReason: `${context.marker}-after-loaded` },
{ 'Idempotency-Key': `${context.marker}-p3-transport-after-loaded`.slice(0, 128) }) { 'Idempotency-Key': `${context.marker}-p3-transport-after-loaded`.slice(0, 128) })
context.addStep(MODULE, 'LOADED 后运输信息修改被拒绝', immutableTransport, context.addStep(MODULE, 'LOADED 后运输信息修改被拒绝', immutableTransport,
+122 -65
View File
@@ -1,3 +1,5 @@
import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs'
import { isPureStatusNoop, visibleLoadingHistories } from './loading-history-noop.mjs'
import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs' import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs'
import { createHash } from 'node:crypto' import { createHash } from 'node:crypto'
@@ -5,7 +7,7 @@ const MODULE = 'phase3-loading'
const OPTIONS = { scenario: 'phase3-split-preload-loading', deploymentBlocker: true } const OPTIONS = { scenario: 'phase3-split-preload-loading', deploymentBlocker: true }
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || '' const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || ''
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || '' const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || ''
const MATSON_SUPPLIER_NAME = 'MATSON 开发联调订舱代理' const MATSON_SUPPLIER_NAME = 'MATSON 开发联调船司'
const LATE_CONTAINER_GATE_ONLY = process.env.LATE_CONTAINER_GATE_ONLY === '1' const LATE_CONTAINER_GATE_ONLY = process.env.LATE_CONTAINER_GATE_ONLY === '1'
function id(value) { return value == null ? '' : String(value) } function id(value) { return value == null ? '' : String(value) }
@@ -166,7 +168,7 @@ export async function run(context) {
const smartLoadingBase = '/api/platform/v1/first-leg/smart-loading' const smartLoadingBase = '/api/platform/v1/first-leg/smart-loading'
const pdaBase = '/api/platform/v1/first-leg/pda-loading' const pdaBase = '/api/platform/v1/first-leg/pda-loading'
const supplierResponse = await context.request('GET', const supplierResponse = await context.request('GET',
'/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&carrierCode=MATSON&page=1&size=200') '/api/platform/v1/suppliers?tenantId=0&category=BOOKING&page=1&size=200')
const supplierPage = context.apiSuccess(MODULE, '读取订舱代理与船司供应商事实源', supplierResponse, '', OPTIONS) const supplierPage = context.apiSuccess(MODULE, '读取订舱代理与船司供应商事实源', supplierResponse, '', OPTIONS)
const supplierRecords = Array.isArray(supplierPage?.records) ? supplierPage.records : [] const supplierRecords = Array.isArray(supplierPage?.records) ? supplierPage.records : []
const supplier = await ensureMatsonSupplier(context, supplierRecords) const supplier = await ensureMatsonSupplier(context, supplierRecords)
@@ -212,9 +214,8 @@ export async function run(context) {
if (!containerBookingId) return if (!containerBookingId) return
const candidateResponse = await context.request('GET', `${smartLoadingBase}/candidates?bookingId=${encodeURIComponent(containerBookingId)}&warehouseId=${encodeURIComponent(warehouseId)}`) const candidateResponse = await context.request('GET', `${smartLoadingBase}/candidates?bookingId=${encodeURIComponent(containerBookingId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const candidates = context.apiSuccess(MODULE, '查询本轮入库单柜次装柜候选', candidateResponse, '', OPTIONS) const candidates = context.apiSuccess(MODULE, '查询本轮入库单柜次装柜候选', candidateResponse, '', OPTIONS)
const inboundCandidates = Array.isArray(candidates) ? candidates.filter((item) => id(item.inboundId) === inboundId const candidate = Array.isArray(candidates) ? candidates.find((item) => id(item.inboundId) === inboundId
&& Number(item.availablePackageCount) > 0) : [] && Number(item.availablePackageCount) > 0) : null
const candidate = inboundCandidates[0]
context.assert(MODULE, '柜次装柜候选与入库单、客户和仓库一致', candidateResponse, context.assert(MODULE, '柜次装柜候选与入库单、客户和仓库一致', candidateResponse,
Boolean(candidate?.inboundDetailId) && id(candidate?.accountId) === tenantId Boolean(candidate?.inboundDetailId) && id(candidate?.accountId) === tenantId
&& id(candidate?.warehouseId) === warehouseId, && id(candidate?.warehouseId) === warehouseId,
@@ -222,29 +223,21 @@ export async function run(context) {
if (!candidate?.inboundDetailId || !warehouseId) return if (!candidate?.inboundDetailId || !warehouseId) return
const loadingPlanResponse = await context.request('GET', const loadingPlanResponse = await request(context, 'GET',
`${smartLoadingBase}/plans/by-booking/${encodeURIComponent(containerBookingId)}`) `${smartLoadingBase}/plans/by-booking/${encodeURIComponent(containerBookingId)}`)
const loadingPlan = context.apiSuccess(MODULE, '读取柜次创建时原子生成的智能装柜计划', loadingPlanResponse, '', OPTIONS) const loadingPlan = context.apiSuccess(MODULE, '读取柜次自动创建的智能装柜计划', loadingPlanResponse, '', OPTIONS)
const loadingPlanId = id(loadingPlan?.id) const loadingPlanId = id(loadingPlan?.id)
if (!loadingPlanId) return if (!loadingPlanId) return
const dropshipAssignment = source?.fulfillmentType === 'ONE_CLICK_DROPSHIP' const assignmentResponse = await request(context, 'POST', `${smartLoadingBase}/plans/${loadingPlanId}/assignments`, {
const initialAssignmentCount = dropshipAssignment inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: 1,
? inboundCandidates.reduce((total, item) => total + Number(item.availablePackageCount || 0), 0) : 1 expectedVersion: Number(loadingPlan.version),
const assignmentPath = dropshipAssignment operationId: `${context.marker}-${loadingPlanId}-bridge-assign`.slice(0, 128),
? `${smartLoadingBase}/plans/${loadingPlanId}/inbound-assignments` })
: `${smartLoadingBase}/plans/${loadingPlanId}/assignments`
const assignmentBody = dropshipAssignment
? { inboundId, packageCount: initialAssignmentCount, expectedVersion: Number(loadingPlan.version),
operationId: `${context.marker}-bridge-assign-inbound`.slice(0, 128) }
: { inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: initialAssignmentCount,
expectedVersion: Number(loadingPlan.version), operationId: `${context.marker}-bridge-assign`.slice(0, 128) }
const assignmentResponse = await request(context, 'POST', assignmentPath, assignmentBody)
const assignedLoadingPlan = context.apiSuccess(MODULE, '将本轮入库货物分配到柜次计划', assignmentResponse, '', OPTIONS) const assignedLoadingPlan = context.apiSuccess(MODULE, '将本轮入库货物分配到柜次计划', assignmentResponse, '', OPTIONS)
context.assert(MODULE, '柜次计划明细和版本真实持久化', assignmentResponse, context.assert(MODULE, '柜次计划明细和版本真实持久化', assignmentResponse,
assignedLoadingPlan?.items?.filter?.((item) => id(item.inboundId) === inboundId) assignedLoadingPlan?.items?.some?.((item) => id(item.inboundId) === inboundId && Number(item.packageCount) === 1)
.reduce((total, item) => total + Number(item.packageCount || 0), 0) === initialAssignmentCount && Number(assignedLoadingPlan?.version) === 1,
&& Number(assignedLoadingPlan?.version) >= 1, `loadingPlanId=${loadingPlanId}, version=${assignedLoadingPlan?.version ?? 'missing'}`, OPTIONS)
`loadingPlanId=${loadingPlanId}, assignedPackages=${initialAssignmentCount}, version=${assignedLoadingPlan?.version ?? 'missing'}`, OPTIONS)
const assignedItem = assignedLoadingPlan?.items?.find?.((item) => id(item.inboundId) === inboundId) const assignedItem = assignedLoadingPlan?.items?.find?.((item) => id(item.inboundId) === inboundId)
const orderRemarks = source || {} const orderRemarks = source || {}
@@ -317,37 +310,113 @@ export async function run(context) {
const pendingPreloadResponse = await context.request('GET', `${base}/preload?tenantId=${encodeURIComponent(tenantId)}`) const pendingPreloadResponse = await context.request('GET', `${base}/preload?tenantId=${encodeURIComponent(tenantId)}`)
const pendingPreload = context.apiSuccess(MODULE, '智能排货自动建立 PENDING 预装聚合', pendingPreloadResponse, '', OPTIONS) const pendingPreload = context.apiSuccess(MODULE, '智能排货自动建立 PENDING 预装聚合', pendingPreloadResponse, '', OPTIONS)
const planId = id(pendingPreload?.id) const planId = id(pendingPreload?.id)
context.assert(MODULE, '分配后入库单保持 WAIT_SPLIT 且预装聚合为 PENDING', pendingPreloadResponse, context.assert(MODULE, '推送前入库单保持 WAIT_SPLIT 且预装聚合为 PENDING', pendingPreloadResponse,
Boolean(planId) && pendingPreload?.status === 'PENDING' && Number.isInteger(Number(pendingPreload?.version)), Boolean(planId) && pendingPreload?.status === 'PENDING' && Number.isInteger(Number(pendingPreload?.version)),
`preloadPlanId=${planId || 'missing'}, status=${pendingPreload?.status || 'missing'}`, OPTIONS) `preloadPlanId=${planId || 'missing'}, status=${pendingPreload?.status || 'missing'}`, OPTIONS)
if (!planId) return if (!planId) return
const synchronizedDetailResponse = await context.request('GET', const pushRequest = Object.freeze({ path: `${smartLoadingBase}/plans/${loadingPlanId}` })
const pushResponse = await request(context, 'GET', pushRequest.path)
const pushedLoadingPlan = context.apiSuccess(MODULE, '分配后自动建立预装关联且柜次计划可读', pushResponse, '', OPTIONS)
context.assert(MODULE, '自动关联保持 EDITING、身份稳定且无需额外推送版本', pushResponse,
id(pushedLoadingPlan?.id) === loadingPlanId && pushedLoadingPlan?.status === 'EDITING'
&& pushedLoadingPlan?.containerNo == null
&& Number(pushedLoadingPlan?.version) === Number(persistedRemarkPlan.version),
`status=${pushedLoadingPlan?.status || 'missing'}, version=${pushedLoadingPlan?.version ?? 'missing'}`, OPTIONS)
if (pushedLoadingPlan?.status !== 'EDITING') return
const pushedDetailResponse = await context.request('GET',
`/api/platform/v1/first-leg/loading-plans/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`) `/api/platform/v1/first-leg/loading-plans/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const synchronizedDetail = context.apiSuccess(MODULE, '分配后预装清单正式可见', synchronizedDetailResponse, '', OPTIONS) const pushedDetail = context.apiSuccess(MODULE, '无号推送后预装清单正式可见', pushedDetailResponse, '', OPTIONS)
const synchronizedLinksResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}/preloads`) const pushedHistories = visibleLoadingHistories(pushedDetail?.histories)
const synchronizedLinks = context.apiSuccess(MODULE, '分配同步建立 bridge 关联', synchronizedLinksResponse, '', OPTIONS) context.assert(MODULE, '自动预装不保留旧 PUSH_PRELOAD 历史且无纯状态 no-op', pushedDetailResponse,
const link = Array.isArray(synchronizedLinks) ? synchronizedLinks.find((item) => id(item.inboundId) === inboundId) : null pushedHistories.filter((item) => item.actionType === 'PUSH_PRELOAD').length === 0
context.assert(MODULE, '分配自动建立唯一预装关联且身份稳定', synchronizedLinksResponse, && !(pushedDetail?.histories || []).some(isPureStatusNoop),
`raw=${pushedDetail?.histories?.length ?? 'invalid'}, effective=${pushedHistories.length}, push=${pushedHistories.filter((item) => item.actionType === 'PUSH_PRELOAD').length}`,
OPTIONS)
const pushedLinksResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}/preloads`)
const pushedLinks = context.apiSuccess(MODULE, '无号推送自动建立 bridge 关联', pushedLinksResponse, '', OPTIONS)
const link = Array.isArray(pushedLinks) ? pushedLinks.find((item) => id(item.inboundId) === inboundId) : null
context.assert(MODULE, 'push 自动建立唯一预装关联且分配身份稳定', pushedLinksResponse,
Boolean(link?.id) && id(link?.loadingPlanId) === loadingPlanId && id(link?.preloadPlanId) === planId Boolean(link?.id) && id(link?.loadingPlanId) === loadingPlanId && id(link?.preloadPlanId) === planId
&& Number(link?.allocatedPackageCount) === initialAssignmentCount, && Number(link?.allocatedPackageCount) === 1,
`linkId=${id(link?.id) || 'missing'}, preloadPlanId=${id(link?.preloadPlanId) || 'missing'}, allocatedPackages=${link?.allocatedPackageCount ?? 'missing'}, expectedPackages=${initialAssignmentCount}`, OPTIONS) `linkId=${id(link?.id) || 'missing'}, preloadPlanId=${id(link?.preloadPlanId) || 'missing'}`, OPTIONS)
if (!link?.id) return if (!link?.id) return
const synchronizedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) const postPushPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const synchronizedPlan = context.apiSuccess(MODULE, '读取分配事务完成态柜次计划', synchronizedPlanResponse, '', OPTIONS) const postPushPlan = context.apiSuccess(MODULE, '首次 push 后读取事务完成态柜次计划', postPushPlanResponse, '', OPTIONS)
context.assert(MODULE, '分配事务完成态计划与 bridge 一致且保持 EDITING', synchronizedPlanResponse, context.assert(MODULE, '事务完成态计划版本与 bridge 一致且保持 EDITING', postPushPlanResponse,
synchronizedPlan?.status === 'EDITING' && Number(synchronizedPlan?.version) === Number(link?.loadingPlanVersion) postPushPlan?.status === 'EDITING' && Number(postPushPlan?.version) === Number(link?.loadingPlanVersion)
&& Number(synchronizedPlan?.version) === Number(persistedRemarkPlan.version), && Number(postPushPlan?.version) > Number(assignedLoadingPlan.version),
`persistedVersion=${synchronizedPlan?.version ?? 'missing'}, bridgeVersion=${link?.loadingPlanVersion ?? 'missing'}`, OPTIONS) `pushResponseVersion=${pushedLoadingPlan?.version ?? 'missing'}, persistedVersion=${postPushPlan?.version ?? 'missing'}, bridgeVersion=${link?.loadingPlanVersion ?? 'missing'}`, OPTIONS)
if (synchronizedPlan?.status !== 'EDITING' || !Number.isInteger(Number(synchronizedPlan?.version))) return if (postPushPlan?.status !== 'EDITING' || !Number.isInteger(Number(postPushPlan?.version))) return
const missingContainerTaskResponse = await context.request('GET', const missingContainerTaskResponse = await context.request('GET',
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`) `${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const missingContainerTaskAbsent = missingContainerTaskResponse.http === 200 && codeOf(missingContainerTaskResponse) === 'A0404' const missingContainerTaskAbsent = missingContainerTaskResponse.http === 200 && codeOf(missingContainerTaskResponse) === 'A0404'
context.addStep(MODULE, '无真实柜号时 PDA 查询明确 A0404', missingContainerTaskResponse, context.addStep(MODULE, '无号 push 成功后 PDA 查询明确 A0404', missingContainerTaskResponse,
missingContainerTaskAbsent, missingContainerTaskAbsent,
`HTTP=${missingContainerTaskResponse.http}, code=${codeOf(missingContainerTaskResponse)}, requestId=${missingContainerTaskResponse.requestId || 'missing'}, traceId=${missingContainerTaskResponse.traceId || 'missing'}`, OPTIONS) `HTTP=${missingContainerTaskResponse.http}, code=${codeOf(missingContainerTaskResponse)}, requestId=${missingContainerTaskResponse.requestId || 'missing'}, traceId=${missingContainerTaskResponse.traceId || 'missing'}`, OPTIONS)
if (!missingContainerTaskAbsent) return if (!missingContainerTaskAbsent) return
const replayBaseline = projectPushPreloadStability({
plan: postPushPlan, detail: pushedDetail, links: pushedLinks,
task: { http: missingContainerTaskResponse.http, code: codeOf(missingContainerTaskResponse) },
})
const pushReplayResponse = await request(context, 'GET', pushRequest.path)
context.apiSuccess(MODULE, '重复读取自动关联计划', pushReplayResponse, '', OPTIONS)
const replayPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const replayPlan = context.apiSuccess(MODULE, '重放后回查柜次计划', replayPlanResponse, '', OPTIONS)
const replayDetailResponse = await context.request('GET',
`/api/platform/v1/first-leg/loading-plans/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const replayDetail = context.apiSuccess(MODULE, '重放后回查预装历史', replayDetailResponse, '', OPTIONS)
const replayHistories = visibleLoadingHistories(replayDetail?.histories)
context.assert(MODULE, 'push 同幂等请求重放历史零新增', replayDetailResponse,
replayHistories.length === pushedHistories.length
&& replayHistories.filter((item) => item.actionType === 'PUSH_PRELOAD').length === 0,
`before=${pushedHistories.length}, after=${replayHistories.length}, legacyPush=${replayHistories.filter((item) => item.actionType === 'PUSH_PRELOAD').length}`,
OPTIONS)
const replayLinksResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}/preloads`)
const replayLinks = context.apiSuccess(MODULE, '重放后回查 bridge', replayLinksResponse, '', OPTIONS)
const replayTaskResponse = await context.request('GET',
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const replaySnapshot = projectPushPreloadStability({
plan: replayPlan, detail: replayDetail, links: replayLinks,
task: { http: replayTaskResponse.http, code: codeOf(replayTaskResponse) },
})
const replayDiff = diffPushPreloadStability(replayBaseline, replaySnapshot)
context.assert(MODULE, 'push exact replay 对计划版本历史关联和任务零漂移', pushReplayResponse,
replayDiff.length === 0 && codeOf(replayTaskResponse) === 'A0404',
`changed=${replayDiff.join('|') || 'none'}; replayTask=${responseEvidence(replayTaskResponse)}`, OPTIONS)
const sameKeyDifferentBody = await request(context, 'GET', pushRequest.path)
context.addStep(MODULE, '自动关联计划重复读取稳定', sameKeyDifferentBody,
!failure(sameKeyDifferentBody), `HTTP=${sameKeyDifferentBody.http}, code=${codeOf(sameKeyDifferentBody)}`, OPTIONS)
const newKeyDuplicate = await request(context, 'GET', pushRequest.path)
context.addStep(MODULE, '自动关联计划再次读取稳定', newKeyDuplicate,
!failure(newKeyDuplicate), `HTTP=${newKeyDuplicate.http}, code=${codeOf(newKeyDuplicate)}`, OPTIONS)
const rejectedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const rejectedPlan = context.apiSuccess(MODULE, '非法 push 后回查柜次计划', rejectedPlanResponse, '', OPTIONS)
const rejectedDetailResponse = await context.request('GET',
`/api/platform/v1/first-leg/loading-plans/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const rejectedDetail = context.apiSuccess(MODULE, '非法 push 后回查预装历史', rejectedDetailResponse, '', OPTIONS)
const rejectedHistories = visibleLoadingHistories(rejectedDetail?.histories)
const rejectedLinksResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}/preloads`)
const rejectedLinks = context.apiSuccess(MODULE, '非法 push 后回查 bridge', rejectedLinksResponse, '', OPTIONS)
const rejectedTaskResponse = await context.request('GET',
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const rejectedSnapshot = projectPushPreloadStability({
plan: rejectedPlan, detail: rejectedDetail, links: rejectedLinks,
task: { http: rejectedTaskResponse.http, code: codeOf(rejectedTaskResponse) },
})
const rejectedDiff = diffPushPreloadStability(replayBaseline, rejectedSnapshot)
context.assert(MODULE, '两类非法 push 后计划版本历史关联和任务仍零漂移', rejectedTaskResponse,
!failure(sameKeyDifferentBody) && !failure(newKeyDuplicate)
&& rejectedDiff.length === 0 && codeOf(rejectedTaskResponse) === 'A0404'
&& rejectedHistories.length === pushedHistories.length
&& rejectedHistories.filter((item) => item.actionType === 'PUSH_PRELOAD').length === 0,
`changed=${rejectedDiff.join('|') || 'none'}; sameKey=${responseEvidence(sameKeyDifferentBody)}; newKey=${responseEvidence(newKeyDuplicate)}; task=${responseEvidence(rejectedTaskResponse)}`, OPTIONS)
if (rejectedDiff.length !== 0 || codeOf(rejectedTaskResponse) !== 'A0404') return
const unlinkedTaskResponse = await context.request('GET', const unlinkedTaskResponse = await context.request('GET',
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`) `${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
context.addStep(MODULE, '无号且未关联订舱仍无 PDA 任务', unlinkedTaskResponse, context.addStep(MODULE, '无号且未关联订舱仍无 PDA 任务', unlinkedTaskResponse,
@@ -506,15 +575,12 @@ export async function run(context) {
const canonicalTaskId = id(canonicalTask?.id) const canonicalTaskId = id(canonicalTask?.id)
const taskDetailPath = `${pdaBase}/tasks/${canonicalTaskId}?warehouseId=${encodeURIComponent(warehouseId)}` const taskDetailPath = `${pdaBase}/tasks/${canonicalTaskId}?warehouseId=${encodeURIComponent(warehouseId)}`
const canonicalItem = canonicalTask?.items?.find?.((item) => id(item.inboundId) === inboundId) const canonicalItem = canonicalTask?.items?.find?.((item) => id(item.inboundId) === inboundId)
const canonicalPickItems = dropshipAssignment
? canonicalTask?.items?.filter?.((item) => id(item.inboundId) === inboundId) || []
: canonicalItem ? [canonicalItem] : []
context.assert(MODULE, '柜次 PDA 任务与桥接入库明细一致', canonicalTaskResponse, context.assert(MODULE, '柜次 PDA 任务与桥接入库明细一致', canonicalTaskResponse,
Boolean(canonicalTaskId) && canonicalTaskId === confirmedTaskId && id(canonicalTask?.loadingPlanId) === loadingPlanId Boolean(canonicalTaskId) && canonicalTaskId === confirmedTaskId && id(canonicalTask?.loadingPlanId) === loadingPlanId
&& id(canonicalTask?.containerBookingId) === containerBookingId && id(canonicalTask?.containerBookingId) === containerBookingId
&& canonicalTask?.workflowStatus === 'UNASSIGNED' && canonicalPickItems.length > 0, && canonicalTask?.workflowStatus === 'UNASSIGNED' && Boolean(canonicalItem?.id),
`taskId=${canonicalTaskId || 'missing'}, itemCount=${canonicalPickItems.length}`, OPTIONS) `taskId=${canonicalTaskId || 'missing'}, itemId=${id(canonicalItem?.id) || 'missing'}`, OPTIONS)
if (!canonicalTaskId || !canonicalPickItems.length) return if (!canonicalTaskId || !canonicalItem?.id) return
const beforeClaimPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) const beforeClaimPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const beforeClaimPlan = context.apiSuccess(MODULE, '领取前读取柜次计划即时版本', beforeClaimPlanResponse, '', OPTIONS) const beforeClaimPlan = context.apiSuccess(MODULE, '领取前读取柜次计划即时版本', beforeClaimPlanResponse, '', OPTIONS)
@@ -575,18 +641,15 @@ export async function run(context) {
const skipBatchKey = `${context.marker}-exception-skip-batch`.slice(0, 128) const skipBatchKey = `${context.marker}-exception-skip-batch`.slice(0, 128)
const partialBatchKey = `${context.marker}-exception-partial-batch`.slice(0, 128) const partialBatchKey = `${context.marker}-exception-partial-batch`.slice(0, 128)
const dynamicBatchKey = `${context.marker}-dynamic-pick`.slice(0, 128) const dynamicBatchKey = `${context.marker}-dynamic-pick`.slice(0, 128)
const dropshipWholePallet = source?.fulfillmentType === 'ONE_CLICK_DROPSHIP' const expectedBatchOperationIds = [batchKey, skipBatchKey, partialBatchKey, dynamicBatchKey]
const expectedBatchOperationIds = dropshipWholePallet
? [batchKey]
: [batchKey, skipBatchKey, partialBatchKey, dynamicBatchKey]
const batchPayload = { const batchPayload = {
warehouseId, loadingPlanId, containerBookingId, warehouseId, loadingPlanId, containerBookingId,
expectedVersion: Number(claimed?.version), expectedVersion: Number(claimed?.version),
inventorySnapshotToken: inventory.snapshotToken, inventorySnapshotToken: inventory.snapshotToken,
items: canonicalPickItems.map((item) => ({ items: [{
itemId: id(item.id), action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount), itemId: id(canonicalItem.id), action: 'PICK', pickedPackageCount: 1,
releaseMode: 'MANUAL', locationIds: [], version: Number(item.version), releaseMode: 'MANUAL', locationIds: [], version: Number(canonicalItem.version),
})), }],
} }
const staleBatch = await request(context, 'POST', batchPath, const staleBatch = await request(context, 'POST', batchPath,
@@ -626,7 +689,7 @@ export async function run(context) {
`taskVersion=${unchangedAfterInvalid?.version ?? 'missing'}, items=${taskItemsSignature(unchangedAfterInvalid)}, operations=${afterInvalidOperations?.length ?? 'missing'}`, OPTIONS) `taskVersion=${unchangedAfterInvalid?.version ?? 'missing'}, items=${taskItemsSignature(unchangedAfterInvalid)}, operations=${afterInvalidOperations?.length ?? 'missing'}`, OPTIONS)
const merchantLogin = await context.request('POST', '/api/auth/login', { const merchantLogin = await context.request('POST', '/api/auth/login', {
loginId: MERCHANT_USERNAME, password: MERCHANT_PASSWORD, username: MERCHANT_USERNAME, password: MERCHANT_PASSWORD,
}, '') }, '')
const merchantToken = merchantLogin.http === 200 && codeOf(merchantLogin) === '200' const merchantToken = merchantLogin.http === 200 && codeOf(merchantLogin) === '200'
? merchantLogin.body?.data?.accessToken : null ? merchantLogin.body?.data?.accessToken : null
@@ -760,15 +823,12 @@ export async function run(context) {
if (refreshedInventory?.snapshotToken) { if (refreshedInventory?.snapshotToken) {
effectiveBatchPayload = { ...batchPayload, inventorySnapshotToken: refreshedInventory.snapshotToken } effectiveBatchPayload = { ...batchPayload, inventorySnapshotToken: refreshedInventory.snapshotToken }
} }
} else if (context.context?.phase3LoadingRequireSharedOverflow === true) { } else {
context.addStep(MODULE, '自动制造库存变化并验证旧 snapshot token A0409', null, false, context.addStep(MODULE, '自动制造库存变化并验证旧 snapshot token A0409', null, false,
`本轮 ${inventoryInbound?.resources?.length ?? 0} 条占用没有可安全换绑的 A0 临时占用;不得以 coverageGap 作为通过`, OPTIONS) `本轮 ${inventoryInbound?.resources?.length ?? 0} 条占用没有可安全换绑的 A0 临时占用;不得以 coverageGap 作为通过`, OPTIONS)
context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 真实库存变化后的旧快照冲突与刷新重试', context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 真实库存变化后的旧快照冲突与刷新重试',
`inventory-snapshot 已真实返回 taskId=${canonicalTaskId}、warehouseId=${warehouseId} 并验证稳定 token/version=${inventorySnapshotVerified};本轮 ${inventoryInbound?.resources?.length ?? 0} 条占用均为普通库位,当前唯一平台代理库存变更入口只允许 A0 临时占用换绑,不能安全改动普通占用或调用内部 Warehouse 接口`, `inventory-snapshot 已真实返回 taskId=${canonicalTaskId}、warehouseId=${warehouseId} 并验证稳定 token/version=${inventorySnapshotVerified};本轮 ${inventoryInbound?.resources?.length ?? 0} 条占用均为普通库位,当前唯一平台代理库存变更入口只允许 A0 临时占用换绑,不能安全改动普通占用或调用内部 Warehouse 接口`,
'fixture') 'fixture')
} else {
context.addStep(MODULE, '附加生产者不重复要求 A0 库存快照变更专项', null, true,
'主三期装柜链已独立强制验证;当前变体仅生成四期跨区生产者', OPTIONS)
} }
const canonicalPick = await request(context, 'POST', batchPath, effectiveBatchPayload, const canonicalPick = await request(context, 'POST', batchPath, effectiveBatchPayload,
@@ -1106,10 +1166,7 @@ export async function run(context) {
let skipBatchVerified = false let skipBatchVerified = false
let partialBatchVerified = false let partialBatchVerified = false
let dynamicIncrementVerified = false let dynamicIncrementVerified = false
if (dropshipWholePallet) { if (!incrementalInboundId || incrementalFixture?.marker !== context.marker || incrementalInboundId === inboundId) {
context.addStep(MODULE, '一件代发整托生产者不执行可拆分动态增量场景', null, true,
'逐箱封托事实必须整托守恒;SKIP、PARTIAL 和动态增删改由 STANDARD 生产者覆盖', OPTIONS)
} else if (!incrementalInboundId || incrementalFixture?.marker !== context.marker || incrementalInboundId === inboundId) {
context.addStep(MODULE, '取得本轮独立动态增量入库单', null, false, context.addStep(MODULE, '取得本轮独立动态增量入库单', null, false,
`inboundId=${incrementalInboundId || 'missing'}, primaryInboundId=${inboundId}, marker=${incrementalFixture?.marker || 'missing'}`, OPTIONS) `inboundId=${incrementalInboundId || 'missing'}, primaryInboundId=${inboundId}, marker=${incrementalFixture?.marker || 'missing'}`, OPTIONS)
} else { } else {
@@ -1423,9 +1480,9 @@ export async function run(context) {
cartonMarks: stableLoadedCartonMarks(stagedTask?.items, loadingPlanId), cartonMarks: stableLoadedCartonMarks(stagedTask?.items, loadingPlanId),
fulfillmentType: source?.fulfillmentType || 'STANDARD', fulfillmentType: source?.fulfillmentType || 'STANDARD',
skuCodes: [...new Set([...(Array.isArray(source?.skuCodes) ? source.skuCodes : []), skuCodes: [...new Set([...(Array.isArray(source?.skuCodes) ? source.skuCodes : []),
...(!dropshipWholePallet && Array.isArray(incrementalFixture?.skuCodes) ? incrementalFixture.skuCodes : [])])], ...(Array.isArray(incrementalFixture?.skuCodes) ? incrementalFixture.skuCodes : [])])],
skuLines: [...(Array.isArray(source?.skuLines) ? source.skuLines : []), skuLines: [...(Array.isArray(source?.skuLines) ? source.skuLines : []),
...(!dropshipWholePallet && Array.isArray(incrementalFixture?.skuLines) ? incrementalFixture.skuLines : [])], ...(Array.isArray(incrementalFixture?.skuLines) ? incrementalFixture.skuLines : [])],
destinationType: initial?.summary?.destinationType || source?.destinationType || null, destinationType: initial?.summary?.destinationType || source?.destinationType || null,
destinationWarehouseId: id(initial?.summary?.destinationWarehouseId || source?.destinationWarehouseId), destinationWarehouseId: id(initial?.summary?.destinationWarehouseId || source?.destinationWarehouseId),
destinationWarehouseCode: initial?.summary?.destinationCode || source?.destinationWarehouseCode || null, destinationWarehouseCode: initial?.summary?.destinationCode || source?.destinationWarehouseCode || null,
@@ -23,7 +23,7 @@ test('each remainder uses a simulation linked to a released booking requirement'
assert.match(source, /oceanBooking\?\.status === 'RELEASED' && releasedAllocationMatches/) assert.match(source, /oceanBooking\?\.status === 'RELEASED' && releasedAllocationMatches/)
assert.match(source, /id\(item\?\.containerBookingId\) === containerId/) assert.match(source, /id\(item\?\.containerBookingId\) === containerId/)
assert.match(source, /candidates\?bookingId=\$\{encodeURIComponent\(containerId\)\}&warehouseId=\$\{encodeURIComponent\(warehouseId\)\}/) assert.match(source, /candidates\?bookingId=\$\{encodeURIComponent\(containerId\)\}&warehouseId=\$\{encodeURIComponent\(warehouseId\)\}/)
assert.match(source, /`\$\{SMART\}\/plans`, \{ bookingId: containerId/) assert.match(source, /`\$\{SMART\}\/plans\/by-booking\/\$\{containerId\}`/)
assert.doesNotMatch(source, /candidates\?warehouseId=/) assert.doesNotMatch(source, /candidates\?warehouseId=/)
}) })
@@ -37,7 +37,7 @@ test('release freezes and detail replays the same complete transport snapshot',
}) })
test('PDA opens only after linking a released requirement and uses stable plan identity', () => { test('PDA opens only after linking a released requirement and uses stable plan identity', () => {
const pushIndex = source.indexOf("`${SMART}/plans/${planId}/push-preload`") const pushIndex = source.indexOf("`${SMART}/plans/${planId}`")
const linkIndex = source.indexOf("`${CONTAINERS}/${containerId}/ocean-booking-link`") const linkIndex = source.indexOf("`${CONTAINERS}/${containerId}/ocean-booking-link`")
const releaseIndex = source.indexOf("`/api/platform/v1/ocean-bookings/${oceanBookingId}/release`") const releaseIndex = source.indexOf("`/api/platform/v1/ocean-bookings/${oceanBookingId}/release`")
const confirmIndex = source.indexOf("`/api/platform/v1/first-leg/loading-plans/${encodeURIComponent(planId)}/confirm`") const confirmIndex = source.indexOf("`/api/platform/v1/first-leg/loading-plans/${encodeURIComponent(planId)}/confirm`")
@@ -60,6 +60,7 @@ test('PDA opens only after linking a released requirement and uses stable plan i
assert.match(source, /id\(task\?\.loadingPlanId\) === planId/) assert.match(source, /id\(task\?\.loadingPlanId\) === planId/)
assert.match(source, /id\(task\?\.containerBookingId\) === containerId/) assert.match(source, /id\(task\?\.containerBookingId\) === containerId/)
assert.doesNotMatch(source, /\$\{PDA\}\/tasks\?containerNo=/) assert.doesNotMatch(source, /\$\{PDA\}\/tasks\?containerNo=/)
assert.doesNotMatch(source, /push-preload/)
}) })
test('each remainder settles only through inventory snapshot, atomic batch and explicit complete', () => { test('each remainder settles only through inventory snapshot, atomic batch and explicit complete', () => {
@@ -69,31 +69,28 @@ async function request(context, method, path, body, headers = {}, token = contex
} }
} }
async function ensureCompletionEvidence(context, tenantId, inboundId, keyBase) { async function ensureCompletionEvidence(context, taskId, warehouseId, keyBase) {
const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130]) const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130])
for (const evidenceType of ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']) { for (const evidenceType of ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']) {
const uploadPath = `/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}?evidenceType=${encodeURIComponent(evidenceType)}` const query = new URLSearchParams({ warehouseId, expectedVersion: '0', evidenceType,
remark: context.marker })
if (evidenceType === 'SEAL') query.set('sealNo', `SEAL-${keyBase}`.slice(0, 64))
const uploadPath = `${PDA}/tasks/${taskId}/evidence?${query}`
const form = new FormData() const form = new FormData()
form.append('file', new Blob([png], { type: 'image/png' }), `${keyBase}-${evidenceType}.png`) form.append('file', new Blob([png], { type: 'image/png' }), `${keyBase}-${evidenceType}.png`)
const rawUpload = await fetch(`${context.options.gateway}${uploadPath}`, { const rawUpload = await fetch(`${context.options.gateway}${uploadPath}`, {
method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` }, method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`,
'Idempotency-Key': `${keyBase}-evidence-${evidenceType}`.slice(0, 128) },
body: form, signal: AbortSignal.timeout(60_000), body: form, signal: AbortSignal.timeout(60_000),
}) })
const uploadBody = await rawUpload.json() const uploadBody = await rawUpload.json()
const upload = { method: 'POST', path: uploadPath, http: rawUpload.status, body: uploadBody, const upload = { method: 'POST', path: uploadPath, http: rawUpload.status, body: uploadBody,
requestId: uploadBody?.requestId || rawUpload.headers.get('x-request-id'), requestId: uploadBody?.requestId || rawUpload.headers.get('x-request-id'),
traceId: uploadBody?.traceId || rawUpload.headers.get('x-trace-id') } traceId: uploadBody?.traceId || rawUpload.headers.get('x-trace-id') }
const file = context.apiSuccess(MODULE, `${keyBase} 上传 ${evidenceType} 装柜证据`, upload, '', OPTIONS) const evidence = context.apiSuccess(MODULE, `${keyBase} 上传并绑定 ${evidenceType} 装柜证据`, upload, '', OPTIONS)
const fileId = id(file?.id || file?.fileId) const active = evidence?.evidences?.find?.((item) => item.evidenceType === evidenceType
if (!fileId) return false && item.status !== 'VOID')
const bind = await request(context, 'POST', if (!active?.evidenceId || id(evidence?.taskId) !== taskId) return false
`/api/platform/v1/inbounds/${inboundId}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, {
expectedVersion: 0, evidenceType, fileId,
sealNo: evidenceType === 'SEAL' ? `SEAL-${keyBase}`.slice(0, 64) : null,
remark: context.marker,
}, { 'Idempotency-Key': `${keyBase}-evidence-${evidenceType}`.slice(0, 128) })
const bound = context.apiSuccess(MODULE, `${keyBase} 绑定 ${evidenceType} 装柜证据`, bind, '', OPTIONS)
if (!bound) return false
} }
return true return true
} }
@@ -165,13 +162,13 @@ function snapshotMatches(tree, original, loaded, remaining, activeSuffix) {
&& activeMatches && quantityConserved(tree) && activeMatches && quantityConserved(tree)
} }
function bookingPayload(marker, sequence, source, agent) { function bookingPayload(marker, sequence, source, agent, carrier) {
const suffix = `${marker}-${sequence}`.replace(/[^A-Za-z0-9]/g, '').slice(-18) const suffix = `${marker}-${sequence}`.replace(/[^A-Za-z0-9]/g, '').slice(-18)
return oceanBookingDraft({ marker }, suffix, { return oceanBookingDraft({ marker }, suffix, {
customerId: source.tenantId, customerName: source.customerName, channelCode: source.channelCode, customerId: source.tenantId, customerName: source.customerName, channelCode: source.channelCode,
warehouseId: source.warehouseId, warehouseName: source.warehouseName, warehouseId: source.warehouseId, warehouseName: source.warehouseName,
}, { vesselName: `SPLIT-VESSEL-${suffix}`.slice(0, 128), voyageNo: `SV-${sequence}`.slice(0, 64), }, { vesselName: `SPLIT-VESSEL-${suffix}`.slice(0, 128), voyageNo: `SV-${sequence}`.slice(0, 64),
shippingCompanyId: id(agent.id), shippingCompanyName: agent.name, shippingCompanyId: id(carrier.id), shippingCompanyName: carrier.name, carrierCode: carrier.carrierCode,
bookingAgentId: id(agent.id), bookingAgentName: agent.name, bookingAgentId: id(agent.id), bookingAgentName: agent.name,
estimatedSailingAt: '2026-09-16T08:00:00', estimatedArrivalAt: '2026-10-16T08:00:00' }) estimatedSailingAt: '2026-09-16T08:00:00', estimatedArrivalAt: '2026-10-16T08:00:00' })
} }
@@ -184,6 +181,9 @@ async function loadOneFragment(context, state) {
const agentPage = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 读取 ACTIVE 订舱代理`, agentResponse, '', OPTIONS) const agentPage = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 读取 ACTIVE 订舱代理`, agentResponse, '', OPTIONS)
const agent = agentPage?.records?.find?.((item) => item?.id && item?.name) const agent = agentPage?.records?.find?.((item) => item?.id && item?.name)
if (!agent) return null if (!agent) return null
const { ensureMatsonCarrierPartner } = await import('./carrier-partner-fixture.mjs')
const carrier = await ensureMatsonCarrierPartner(context, MODULE, OPTIONS)
if (!carrier?.id) return null
const simulationResponse = await request(context, 'POST', CONTAINERS, { const simulationResponse = await request(context, 'POST', CONTAINERS, {
channelCode: state.channelCode, warehouseId, containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, channelCode: state.channelCode, warehouseId, containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000,
}) })
@@ -192,14 +192,26 @@ async function loadOneFragment(context, state) {
if (!containerId) return null if (!containerId) return null
context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(containerId) context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(containerId)
const bookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings', { const bookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings', {
expectedVersion: 0, data: bookingPayload(context.marker, sequence, state, agent), expectedVersion: 0, data: bookingPayload(context.marker, sequence, state, agent, carrier),
}, { 'Idempotency-Key': `${operationBase}-booking-create` }) }, { 'Idempotency-Key': `${operationBase}-booking-create` })
const bookingCreated = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 创建渠道仓库一致的订舱`, bookingCreate, '', OPTIONS) const bookingCreated = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 创建渠道仓库一致的订舱`, bookingCreate, '', OPTIONS)
const oceanBookingId = id(bookingCreated?.oceanBooking?.id) const oceanBookingId = id(bookingCreated?.oceanBooking?.id)
if (!oceanBookingId) return null if (!oceanBookingId) return null
context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(oceanBookingId) context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(oceanBookingId)
const bookingDocumentForm = new FormData()
bookingDocumentForm.append('expectedVersion', String(Number(bookingCreated?.oceanBooking?.version)))
bookingDocumentForm.append('documentType', 'BOOKING_CONFIRMATION')
bookingDocumentForm.append('file', new Blob([`${context.marker}-${sourceOrderNo}-BOOKING_CONFIRMATION`],
{ type: 'text/plain' }), `booking-confirmation-${operationBase.replace(/[^A-Za-z0-9-]/g, '')}.txt`)
const bookingDocumentResponse = await context.requestMultipart('POST',
`/api/platform/v1/ocean-bookings/${oceanBookingId}/documents/upload`, bookingDocumentForm, context.token,
{ 'Idempotency-Key': `${operationBase}-booking-document`.slice(0, 128) })
const bookingDocument = context.apiSuccess(MODULE, `上传 ${sourceOrderNo} 的订舱确认文件`,
bookingDocumentResponse, '', OPTIONS)
const releaseVersion = Number(bookingDocument?.bookingVersion)
if (!Number.isInteger(releaseVersion)) return null
const releaseResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${oceanBookingId}/release`, { const releaseResponse = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${oceanBookingId}/release`, {
expectedVersion: Number(bookingCreated?.oceanBooking?.version), reason: context.marker, expectedVersion: releaseVersion, reason: context.marker,
}, { 'Idempotency-Key': `${operationBase}-booking-release`.slice(0, 128) }) }, { 'Idempotency-Key': `${operationBase}-booking-release`.slice(0, 128) })
const bookingConfirmed = context.apiSuccess(MODULE, `放舱 ${sourceOrderNo} 的订舱`, releaseResponse, '', OPTIONS) const bookingConfirmed = context.apiSuccess(MODULE, `放舱 ${sourceOrderNo} 的订舱`, releaseResponse, '', OPTIONS)
context.assert(MODULE, `${sourceOrderNo} 订舱为 RELEASED 且不预生成真实柜`, releaseResponse, context.assert(MODULE, `${sourceOrderNo} 订舱为 RELEASED 且不预生成真实柜`, releaseResponse,
@@ -230,8 +242,8 @@ async function loadOneFragment(context, state) {
`orderId=${sourceOrderId}, available=${candidate?.availablePackageCount ?? 'missing'}`, OPTIONS) `orderId=${sourceOrderId}, available=${candidate?.availablePackageCount ?? 'missing'}`, OPTIONS)
if (!candidate?.inboundId || !candidate?.inboundDetailId) return null if (!candidate?.inboundId || !candidate?.inboundDetailId) return null
const createPlan = await request(context, 'POST', `${SMART}/plans`, { bookingId: containerId, remark: context.marker }) const createPlan = await request(context, 'GET', `${SMART}/plans/by-booking/${containerId}`)
const plan = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 创建装柜计划`, createPlan, '', OPTIONS) const plan = context.apiSuccess(MODULE, `读取 ${sourceOrderNo} 模拟柜自动创建的装柜计划`, createPlan, '', OPTIONS)
const planId = id(plan?.id) const planId = id(plan?.id)
if (!planId) return null if (!planId) return null
context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(planId) context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(planId)
@@ -289,11 +301,11 @@ async function loadOneFragment(context, state) {
`status=${bookingDetail?.oceanBooking?.status || 'missing'}, containerBookingId=${containerId}`, OPTIONS) `status=${bookingDetail?.oceanBooking?.status || 'missing'}, containerBookingId=${containerId}`, OPTIONS)
if (bookingDetail?.oceanBooking?.status !== 'RELEASED' || !releasedAllocationMatches) return null if (bookingDetail?.oceanBooking?.status !== 'RELEASED' || !releasedAllocationMatches) return null
const push = await request(context, 'POST', `${SMART}/plans/${planId}/push-preload`, const push = await request(context, 'GET', `${SMART}/plans/${planId}`)
{ expectedVersion: Number(assigned.version) }, { 'Idempotency-Key': `${operationBase}-push` }) const pushed = context.apiSuccess(MODULE, `读取 ${sourceOrderNo} 自动关联的预装计划`, push, '', OPTIONS)
const pushed = context.apiSuccess(MODULE, `推送 ${sourceOrderNo} 的预装计划`, push, '', OPTIONS) context.assert(MODULE, `${sourceOrderNo} 补真实柜号后自动预装计划保持 EDITING`, push,
context.assert(MODULE, `${sourceOrderNo} 补真实柜号后计划保持 EDITING`, push, pushed?.status === 'EDITING' && Number(pushed?.version) === Number(assigned.version),
pushed?.status === 'EDITING', `status=${pushed?.status || 'missing'}`, OPTIONS) `status=${pushed?.status || 'missing'}, version=${pushed?.version ?? 'missing'}`, OPTIONS)
if (pushed?.status !== 'EDITING') return null if (pushed?.status !== 'EDITING') return null
const unconfirmedTaskResponse = await context.request('GET', const unconfirmedTaskResponse = await context.request('GET',
@@ -416,7 +428,7 @@ async function loadOneFragment(context, state) {
`HTTP=${differentReplay.http}, code=${codeOf(differentReplay)}, requestId=${differentReplay.requestId || 'missing'}, traceId=${differentReplay.traceId || 'missing'}`, OPTIONS) `HTTP=${differentReplay.http}, code=${codeOf(differentReplay)}, requestId=${differentReplay.requestId || 'missing'}, traceId=${differentReplay.traceId || 'missing'}`, OPTIONS)
} }
if (!await ensureCompletionEvidence(context, tenantId, id(candidate.inboundId), operationBase)) return null if (!await ensureCompletionEvidence(context, taskId, warehouseId, operationBase)) return null
const completePath = `${PDA}/tasks/${taskId}/complete` const completePath = `${PDA}/tasks/${taskId}/complete`
const completePayload = { const completePayload = {
@@ -424,8 +436,18 @@ async function loadOneFragment(context, state) {
remark: context.marker, remark: context.marker,
} }
const complete = await request(context, 'POST', completePath, completePayload) const complete = await request(context, 'POST', completePath, completePayload)
const completed = context.apiSuccess(MODULE, `人工确认 ${sourceOrderNo} 装柜完成并结算余量`, complete, '', OPTIONS) let completed = context.apiSuccess(MODULE, `人工确认 ${sourceOrderNo} 装柜完成并结算余量`, complete, '', OPTIONS)
context.assert(MODULE, `${sourceOrderNo} 只在 complete 后进入完成态`, complete, let completedResponse = complete
for (let attempt = 0; attempt < 90 && completed?.taskStatus !== 'COMPLETED'
&& completed?.taskStatus !== 'COMPLETION_FAILED'; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 1_000))
completedResponse = await context.request('GET',
`${PDA}/tasks/${taskId}?warehouseId=${encodeURIComponent(warehouseId)}`)
if (completedResponse.http === 200 && codeOf(completedResponse) === '200') {
completed = completedResponse.body?.data
}
}
context.assert(MODULE, `${sourceOrderNo} 只在 complete 后进入完成态`, completedResponse,
completed?.taskStatus === 'COMPLETED' && completed?.workflowStatus === 'COMPLETED' completed?.taskStatus === 'COMPLETED' && completed?.workflowStatus === 'COMPLETED'
&& completed?.items?.some?.((row) => id(row.id) === id(claimedItem.id) && completed?.items?.some?.((row) => id(row.id) === id(claimedItem.id)
&& Number(row.pickedPackageCount) === 1 && Number(row.plannedPackageCount) === 1), && Number(row.pickedPackageCount) === 1 && Number(row.plannedPackageCount) === 1),
@@ -212,13 +212,12 @@ export async function run(context) {
const inboundId = id(flow?.inboundId) const inboundId = id(flow?.inboundId)
if (knownAllocation && inboundId) { if (knownAllocation && inboundId) {
const expiredGateResponse = await request(context, 'GET', const expiredGateResponse = await request(context, 'GET', `${OUTBOUND}/${encodeURIComponent(containerBookingId)}/gate`)
`${OUTBOUND}/${encodeURIComponent(knownAllocation)}/gate`) const expiredGate = context.apiSuccess(MODULE, '过期船期接入国内出仓统一门禁', expiredGateResponse, '', OPTIONS)
const expiredGate = context.apiSuccess(MODULE, '过期船期后读取当前国内出仓统一门禁', expiredGateResponse, '', OPTIONS)
const gateItems = (expiredGate?.inbounds || []).flatMap((item) => item?.gate?.items || []) const gateItems = (expiredGate?.inbounds || []).flatMap((item) => item?.gate?.items || [])
context.assert(MODULE, '船期不再作为国内出仓硬门禁', expiredGateResponse, context.assert(MODULE, '船期独立于当前国内离仓六项门禁', expiredGateResponse,
Array.isArray(expiredGate?.inbounds) && gateItems.every((item) => item?.code !== 'SAILING_SCHEDULE'), Array.isArray(expiredGate?.inbounds) && !gateItems.some((item) => item?.code === 'SAILING_SCHEDULE'),
`inbounds=${expiredGate?.inbounds?.length ?? 'missing'}, sailingGates=${gateItems.filter((item) => item?.code === 'SAILING_SCHEDULE').length}`, OPTIONS) `inbounds=${expiredGate?.inbounds?.length ?? 'missing'}, obsoleteScheduleGate=${gateItems.some((item) => item?.code === 'SAILING_SCHEDULE')}`, OPTIONS)
} }
const currentData = scheduleData(context, suffix, ocean.id, knownAllocation, master.customerId, false) const currentData = scheduleData(context, suffix, ocean.id, knownAllocation, master.customerId, false)
@@ -237,13 +236,12 @@ export async function run(context) {
`expired=${refreshed?.portSchedule?.expired ?? 'missing'}, sourceType=${refreshed?.portSchedule?.schedule?.sourceType || 'missing'}, version=${refreshed?.portSchedule?.schedule?.version ?? 'missing'}`, OPTIONS) `expired=${refreshed?.portSchedule?.expired ?? 'missing'}, sourceType=${refreshed?.portSchedule?.schedule?.sourceType || 'missing'}, version=${refreshed?.portSchedule?.schedule?.version ?? 'missing'}`, OPTIONS)
if (knownAllocation && inboundId) { if (knownAllocation && inboundId) {
const readyGateResponse = await request(context, 'GET', const readyGateResponse = await request(context, 'GET', `${OUTBOUND}/${encodeURIComponent(containerBookingId)}/gate`)
`${OUTBOUND}/${encodeURIComponent(knownAllocation)}/gate`) const readyGate = context.apiSuccess(MODULE, '有效船期重新检查国内出仓统一门禁', readyGateResponse, '', OPTIONS)
const readyGate = context.apiSuccess(MODULE, '有效船期后重新读取国内出仓统一门禁', readyGateResponse, '', OPTIONS)
const gateItems = (readyGate?.inbounds || []).flatMap((item) => item?.gate?.items || []) const gateItems = (readyGate?.inbounds || []).flatMap((item) => item?.gate?.items || [])
context.assert(MODULE, '船期有效性不改变国内出仓硬门禁集合', readyGateResponse, context.assert(MODULE, '有效船期不改变当前国内离仓六项门禁集合', readyGateResponse,
Array.isArray(readyGate?.inbounds) && gateItems.every((item) => item?.code !== 'SAILING_SCHEDULE'), Array.isArray(readyGate?.inbounds) && !gateItems.some((item) => item?.code === 'SAILING_SCHEDULE'),
`inbounds=${readyGate?.inbounds?.length ?? 'missing'}, sailingGates=${gateItems.filter((item) => item?.code === 'SAILING_SCHEDULE').length}`, OPTIONS) `inbounds=${readyGate?.inbounds?.length ?? 'missing'}, obsoleteScheduleGate=${gateItems.some((item) => item?.code === 'SAILING_SCHEDULE')}`, OPTIONS)
} }
const forged = await request(context, 'POST', BASE, { const forged = await request(context, 'POST', BASE, {
@@ -64,7 +64,7 @@ function warehousePayload(marker, edited = false) {
countryCode: 'US', countryCode: 'US',
timezone: 'UTC-8', timezone: 'UTC-8',
contactName: 'Phase4 QA', contactName: 'Phase4 QA',
contactPhone: '+1-213-555-0199', contactPhone: '12135550199',
contactEmail: null, contactEmail: null,
status: 'ACTIVE', status: 'ACTIVE',
remark: edited ? `${marker} lifecycle edited` : `${marker} lifecycle`, remark: edited ? `${marker} lifecycle edited` : `${marker} lifecycle`,
@@ -302,7 +302,7 @@ function vehiclePayload(context, edited = false) {
model: edited ? 'Phase4 QA Truck Edited' : 'Phase4 QA Truck', model: edited ? 'Phase4 QA Truck Edited' : 'Phase4 QA Truck',
fuelConsumptionPer100Km: edited ? 18.75 : 18.5, fuelConsumptionPer100Km: edited ? 18.75 : 18.5,
mileageKm: edited ? 101 : 100, mileageKm: edited ? 101 : 100,
driverOrDispatchCompany: `${context.marker} Carrier`, phone: '+1-213-555-0188', driverOrDispatchCompany: `${context.marker} Carrier`, phone: '12135550188',
region: 'OVERSEAS', ownership: 'OWNED', region: 'OVERSEAS', ownership: 'OWNED',
...(edited ? {} : { status: 'IN_SERVICE' }), ...(edited ? {} : { status: 'IN_SERVICE' }),
remark: edited ? `${context.marker} lifecycle edited` : `${context.marker} lifecycle`, remark: edited ? `${context.marker} lifecycle edited` : `${context.marker} lifecycle`,
@@ -41,6 +41,12 @@ async function overseasMutation(context, method, name, path, body) {
context.addStep(MODULE, name, response, passed, trace(response), OPTIONS) context.addStep(MODULE, name, response, passed, trace(response), OPTIONS)
return { response, data: passed ? response.body?.data : null } return { response, data: passed ? response.body?.data : null }
} }
async function domesticMutation(context, method, name, path, body) {
const response = await context.request(method, path, body)
const passed = response?.http === 200 && String(response?.body?.code) === '200'
context.addStep(MODULE, name, response, passed, trace(response), OPTIONS)
return { response, data: passed ? response.body?.data : null }
}
async function overseasCsv(context, name, path) { async function overseasCsv(context, name, path) {
const response = await context.requestOverseasBinary('GET', path) const response = await context.requestOverseasBinary('GET', path)
context.addStep(MODULE, name, response, context.addStep(MODULE, name, response,
@@ -309,9 +315,9 @@ async function verifyRelabelLifecycle(context, customerId, warehouseId) {
async function createWarehouseFixture(context, customerId) { async function createWarehouseFixture(context, customerId) {
const suffix = String(context.marker || '').replace(/[^A-Za-z0-9]/g, '').slice(-20).toUpperCase() const suffix = String(context.marker || '').replace(/[^A-Za-z0-9]/g, '').slice(-20).toUpperCase()
const payload = { const payload = {
customerId, warehouseCode: `P4W-${suffix}`.slice(0, 50), warehouseName: `${context.marker} 海外仓`, tenantId: 0, warehouseCode: `P4W-${suffix}`.slice(0, 50), warehouseName: `${context.marker} 海外仓`,
warehouseType: 'OVERSEAS', countryCode: 'US', timezone: 'America/Los_Angeles', warehouseType: 'OVERSEAS', countryCode: 'US', timezone: 'America/Los_Angeles',
contactName: 'Phase4 QA', contactPhone: '+1-213-555-0188', contactEmail: null, contactName: 'Phase4 QA', contactPhone: '12135550188', contactEmail: null,
status: 'ACTIVE', remark: `${context.marker}-warehouse-fixture`, status: 'ACTIVE', remark: `${context.marker}-warehouse-fixture`,
address: { addressType: 'WAREHOUSE', countryCode: 'US', stateProvince: 'California', city: 'Los Angeles', address: { addressType: 'WAREHOUSE', countryCode: 'US', stateProvince: 'California', city: 'Los Angeles',
district: 'Commerce', postalCode: '90040', addressLine1: '501 Phase Four Warehouse Avenue', district: 'Commerce', postalCode: '90040', addressLine1: '501 Phase Four Warehouse Avenue',
@@ -319,22 +325,22 @@ async function createWarehouseFixture(context, customerId) {
capabilities: [{ capabilityCode: 'STORAGE', capabilityName: 'Storage', enabled: true, capabilities: [{ capabilityCode: 'STORAGE', capabilityName: 'Storage', enabled: true,
cutoffTime: null, description: `${context.marker} acceptance` }], cutoffTime: null, description: `${context.marker} acceptance` }],
} }
const created = await overseasMutation(context, 'POST', '创建本轮客户海外仓', '/api/platform/v1/warehouse-admin', payload) const created = await domesticMutation(context, 'POST', '创建本轮客户海外仓', '/api/platform/v1/warehouse-admin', payload)
const warehouseId = id(created.data) const warehouseId = id(created.data)
context.assert(MODULE, '客户海外仓创建返回长ID', created.response, longId(warehouseId), context.assert(MODULE, '客户海外仓创建返回长ID', created.response, longId(warehouseId),
`warehouseId=${warehouseId || 'missing'}, customerId=${customerId}; ${trace(created.response)}`, OPTIONS) `warehouseId=${warehouseId || 'missing'}, customerId=${customerId}; ${trace(created.response)}`, OPTIONS)
if (!warehouseId) return null if (!warehouseId) return null
const detail = await overseasGet(context, '查询本轮客户海外仓详情', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}`, { customerId })) const detail = await domesticGet(context, '查询本轮公共海外仓详情', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}`, { tenantId: 0 }))
context.assert(MODULE, '本轮客户海外仓归属和状态正确', detail.response, context.assert(MODULE, '本轮公共海外仓类型和状态正确', detail.response,
id(detail.data?.warehouse?.id || detail.data?.id) === warehouseId id(detail.data?.warehouse?.id || detail.data?.id) === warehouseId
&& id(detail.data?.warehouse?.customerId || detail.data?.customerId) === customerId && (detail.data?.warehouse?.warehouseType || detail.data?.warehouseType) === 'OVERSEAS'
&& (detail.data?.warehouse?.status || detail.data?.status) === 'ACTIVE', && (detail.data?.warehouse?.status || detail.data?.status) === 'ACTIVE',
`warehouseId=${warehouseId}, customerId=${id(detail.data?.warehouse?.customerId || detail.data?.customerId) || 'missing'}; ${trace(detail.response)}`, OPTIONS) `warehouseId=${warehouseId}, warehouseType=${detail.data?.warehouse?.warehouseType || detail.data?.warehouseType || 'missing'}; ${trace(detail.response)}`, OPTIONS)
return { id: warehouseId, customerId, warehouseCode: payload.warehouseCode, warehouseName: payload.warehouseName, status: 'ACTIVE' } return { id: warehouseId, customerId, warehouseCode: payload.warehouseCode, warehouseName: payload.warehouseName, status: 'ACTIVE' }
} }
async function retireWarehouseFixture(context, customerId, warehouseId) { async function retireWarehouseFixture(context, customerId, warehouseId) {
const retired = await context.requestOverseas('PATCH', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/status`, { customerId, status: 'INACTIVE' })) const retired = await context.request('PATCH', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/status`, { tenantId: 0, status: 'INACTIVE' }))
context.addStep(MODULE, '本轮客户海外仓已停用收口', retired, context.addStep(MODULE, '本轮客户海外仓已停用收口', retired,
retired?.http === 200 && String(retired?.body?.code) === '200', trace(retired), OPTIONS) retired?.http === 200 && String(retired?.body?.code) === '200', trace(retired), OPTIONS)
} }
@@ -400,23 +406,21 @@ async function verifyPalletLifecycle(context, warehouseId) {
} }
async function findWarehouse(context, tenants, preferredCustomerId) { async function findWarehouse(context, tenants, preferredCustomerId) {
const candidates = [...new Set([preferredCustomerId, ...tenants.map((item) => id(item?.id)).filter(longId)])] const result = await domesticGet(context, '查询可用公共海外仓', query('/api/platform/v1/warehouse-admin', {
let lastResponse = null tenantId: 0, warehouseType: 'OVERSEAS', page: 1, size: 100, status: 'ACTIVE',
for (const customerId of candidates) { }))
const result = await overseasGet(context, `查询客户${customerId}可用海外仓`, query('/api/platform/v1/warehouse-admin', { customerId, page: 1, size: 100, status: 'ACTIVE' })) assertPage(context, '可用公共海外仓', result)
assertPage(context, `客户${customerId}可用海外仓`, result) const warehouse = records(result.data).find((item) => longId(item?.id)
lastResponse = result.response && item?.status === 'ACTIVE' && item?.warehouseType === 'OVERSEAS')
const warehouse = records(result.data).find((item) => longId(item?.id) && item?.status === 'ACTIVE') if (warehouse) {
if (warehouse) { context.assert(MODULE, '海外仓关键字段正确', result.response,
context.assert(MODULE, '海外仓关键字段正确', result.response, nonBlank(warehouse.warehouseCode) && nonBlank(warehouse.warehouseName),
nonBlank(warehouse.warehouseCode) && nonBlank(warehouse.warehouseName), `warehouseId=${id(warehouse.id)}, code=${warehouse.warehouseCode || 'missing'}; ${trace(result.response)}`, OPTIONS)
`warehouseId=${id(warehouse.id)}, code=${warehouse.warehouseCode || 'missing'}; ${trace(result.response)}`, OPTIONS) const detail = await domesticGet(context, '查询海外仓详情', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(id(warehouse.id))}`, { tenantId: 0 }))
const detail = await overseasGet(context, '查询海外仓详情', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(id(warehouse.id))}`, { customerId })) context.assert(MODULE, '海外仓详情对应列表记录', detail.response,
context.assert(MODULE, '海外仓详情对应列表记录', detail.response, id(detail.data?.warehouse?.id || detail.data?.id) === id(warehouse.id),
id(detail.data?.warehouse?.id || detail.data?.id) === id(warehouse.id), `listId=${id(warehouse.id)}, detailId=${id(detail.data?.warehouse?.id || detail.data?.id) || 'missing'}; ${trace(detail.response)}`, OPTIONS)
`listId=${id(warehouse.id)}, detailId=${id(detail.data?.warehouse?.id || detail.data?.id) || 'missing'}; ${trace(detail.response)}`, OPTIONS) return warehouse
return warehouse
}
} }
return null return null
} }
@@ -4,9 +4,7 @@ const OCEAN = '/api/platform/v1/ocean-bookings'
const TRUCK = '/api/platform/v1/truck-appointments' const TRUCK = '/api/platform/v1/truck-appointments'
function id(value) { return value == null ? '' : String(value) } function id(value) { return value == null ? '' : String(value) }
const FORBIDDEN_TRUCK_FIELDS = ['customerId', 'customerName', 'containerBookingId', 'loadingPlanId', function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
'cabinetNo', 'containerNo']
function bookingLevel(value) { return value != null && FORBIDDEN_TRUCK_FIELDS.every((field) => !Object.hasOwn(value, field)) }
async function actionRequest(context, path, endpoint, version) { async function actionRequest(context, path, endpoint, version) {
try { try {
@@ -32,6 +30,21 @@ async function actionRequest(context, path, endpoint, version) {
} }
} }
async function uploadDispatchOrder(context, appointmentId) {
const path = `${TRUCK}/${appointmentId}/attachments/upload?attachmentType=DISPATCH_ORDER`
const form = new FormData()
const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130])
form.append('file', new Blob([png], { type: 'image/png' }), `${context.marker}-bill.png`)
const response = await fetch(`${context.options.gateway}${path}`, {
method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` },
body: form, signal: AbortSignal.timeout(60_000),
})
const body = await response.json()
return { method: 'POST', path, http: response.status, body,
requestId: body?.requestId || response.headers.get('x-request-id'),
traceId: body?.traceId || response.headers.get('x-trace-id') }
}
export async function run(context) { export async function run(context) {
const containerFlow = context.createdResources?.phase3Loading const containerFlow = context.createdResources?.phase3Loading
|| context.createdResources?.phase3ContainerLoading || context.createdResources?.phase3ContainerLoading
@@ -47,11 +60,10 @@ export async function run(context) {
const suffix = context.marker.replace(/\D/g, '').slice(-10) const suffix = context.marker.replace(/\D/g, '').slice(-10)
const oceanResponse = await context.request('GET', `${OCEAN}/${ocean.id}`) const oceanResponse = await context.request('GET', `${OCEAN}/${ocean.id}`)
const oceanDetail = context.apiSuccess(MODULE, '读取跨服务链路已放舱订舱', oceanResponse, `oceanBookingId=${ocean.id}`, OPTIONS) const oceanDetail = context.apiSuccess(MODULE, '读取跨服务链路已放舱订舱', oceanResponse, `oceanBookingId=${ocean.id}`, OPTIONS)
const allocationMatches = oceanDetail?.containers?.some?.((item) => const booking = oceanDetail?.oceanBooking || oceanDetail
id(item?.containerBookingId) === id(containerFlow.containerBookingId)) context.assert(MODULE, '已放舱订舱可直接创建订舱级拖车预约', oceanResponse,
context.assert(MODULE, '已放舱订舱保留本轮柜次分配', oceanResponse, allocationMatches, id(booking?.id) === id(ocean.id) && booking?.status === 'RELEASED',
`containerBookingId=${id(containerFlow.containerBookingId)}, containers=${oceanDetail?.containers?.length ?? 'n/a'}`, OPTIONS) `oceanBookingId=${id(booking?.id) || 'missing'}, status=${booking?.status || 'missing'}`, OPTIONS)
if (!allocationMatches) return
const carrierResponse = await context.request('GET', const carrierResponse = await context.request('GET',
'/api/platform/v1/suppliers?tenantId=0&category=TRUCKING&status=ACTIVE&page=1&size=20') '/api/platform/v1/suppliers?tenantId=0&category=TRUCKING&status=ACTIVE&page=1&size=20')
const carrierPage = context.apiSuccess(MODULE, '读取 ACTIVE TRUCKING 承运商事实源', carrierResponse, '', OPTIONS) const carrierPage = context.apiSuccess(MODULE, '读取 ACTIVE TRUCKING 承运商事实源', carrierResponse, '', OPTIONS)
@@ -60,9 +72,13 @@ export async function run(context) {
Boolean(carrier?.id && carrier?.name), `carrierId=${id(carrier?.id) || 'missing'}`, OPTIONS) Boolean(carrier?.id && carrier?.name), `carrierId=${id(carrier?.id) || 'missing'}`, OPTIONS)
if (!carrier) return if (!carrier) return
const payload = { const payload = {
outboundOrderId: null, oceanBookingId: ocean.id, warehouseId: id(oceanDetail?.oceanBooking?.warehouseId), outboundOrderId: null,
oceanBookingId: ocean.id,
containerBookingId: null,
loadingPlanId: null,
warehouseId: id(containerFlow?.warehouseId
|| context.createdResources?.warehouse?.id || context.context?.warehouseId || '193000000000000001'),
carrierId: id(carrier.id), carrierName: carrier.name, carrierId: id(carrier.id), carrierName: carrier.name,
billOfLadingNo: oceanDetail?.oceanBooking?.billOfLadingNo,
fleetContact: '链路车队联系人', fleetPhone: '13800000007', driverName: '链路司机', driverPhone: '13800000008', fleetContact: '链路车队联系人', fleetPhone: '13800000007', driverName: '链路司机', driverPhone: '13800000008',
driverIdCard: null, vehicleNo: `粤D-${suffix.slice(-6)}`, storeName: `链路门店-${suffix}`, storeContact: '链路联系人', driverIdCard: null, vehicleNo: `粤D-${suffix.slice(-6)}`, storeName: `链路门店-${suffix}`, storeContact: '链路联系人',
storePhone: '13800000009', storeAddress: '深圳市南山区链路地址', storeCity: '深圳市', storeZipcode: '518000', storePhone: '13800000009', storeAddress: '深圳市南山区链路地址', storeCity: '深圳市', storeZipcode: '518000',
@@ -70,18 +86,19 @@ export async function run(context) {
plannedLoadingAt: '2026-09-03T08:00:00', transitMinutes: 60, payableAmount: 1, plannedLoadingAt: '2026-09-03T08:00:00', transitMinutes: 60, payableAmount: 1,
currency: 'RMB', remark: context.marker, expectedVersion: 0, currency: 'RMB', remark: context.marker, expectedVersion: 0,
} }
context.assert(MODULE, '跨服务拖车创建请求仅使用订舱级协议', oceanResponse,
bookingLevel(payload) && Boolean(payload.warehouseId && payload.billOfLadingNo),
`fields=${Object.keys(payload).join(',')}`, OPTIONS)
const response = await context.request('POST', TRUCK, payload) const response = await context.request('POST', TRUCK, payload)
const appointment = context.apiSuccess(MODULE, '创建订舱级跨服务拖车预约', response, `oceanBookingId=${ocean.id}`, OPTIONS) const appointment = context.apiSuccess(MODULE, '创建关联海运订舱的拖车预约', response, `oceanBookingId=${ocean.id}`, OPTIONS)
const appointmentId = id(appointment?.id) const appointmentId = id(appointment?.id)
context.assert(MODULE, '拖车预约关联订舱、版本和审计字段完整且无柜次协议', response, context.assert(MODULE, '拖车预约仅关联订舱且版本和审计字段完整', response,
Boolean(appointmentId) && id(appointment?.oceanBookingId) === id(ocean.id) && Number.isFinite(Number(appointment?.version)) Boolean(appointmentId) && id(appointment?.oceanBookingId) === id(ocean.id) && Number.isFinite(Number(appointment?.version))
&& bookingLevel(appointment) && Boolean(appointment?.createdAt || appointment?.createdBy || appointment?.operatorName), && !id(appointment?.containerBookingId) && !id(appointment?.loadingPlanId)
`appointmentId=${appointmentId || 'missing'}, oceanBookingId=${appointment?.oceanBookingId || 'missing'}, version=${appointment?.version ?? 'missing'}`, OPTIONS) && Boolean(appointment?.createdAt || appointment?.createdBy || appointment?.operatorName),
`appointmentId=${appointmentId || 'missing'}, oceanBookingId=${appointment?.oceanBookingId || 'missing'}, containerBookingId=${appointment?.containerBookingId || 'n/a'}, loadingPlanId=${appointment?.loadingPlanId || 'n/a'}, version=${appointment?.version ?? 'missing'}`, OPTIONS)
if (!appointmentId) return if (!appointmentId) return
const path = `${TRUCK}/${appointmentId}` const path = `${TRUCK}/${appointmentId}`
const dispatchUpload = await uploadDispatchOrder(context, appointmentId)
const dispatchAttachment = context.apiSuccess(MODULE, '跨服务拖车上传有效派车单附件', dispatchUpload, '', OPTIONS)
if (!dispatchAttachment?.id) return
let version = Number(appointment.version) let version = Number(appointment.version)
for (const [name, endpoint, status] of [ for (const [name, endpoint, status] of [
['提交跨链路预约', 'submit', 'PENDING_DISPATCH'], ['提交跨链路预约', 'submit', 'PENDING_DISPATCH'],
@@ -103,8 +120,8 @@ export async function run(context) {
const final = context.apiSuccess(MODULE, '回查跨服务链路安全终态', detail, '', OPTIONS) const final = context.apiSuccess(MODULE, '回查跨服务链路安全终态', detail, '', OPTIONS)
context.assert(MODULE, '跨服务链路终态持久化且关联订舱未漂移', detail, context.assert(MODULE, '跨服务链路终态持久化且关联订舱未漂移', detail,
final?.status === 'COMPLETED' && id(final?.oceanBookingId) === id(ocean.id) final?.status === 'COMPLETED' && id(final?.oceanBookingId) === id(ocean.id)
&& bookingLevel(final), && !id(final?.containerBookingId) && !id(final?.loadingPlanId),
`status=${final?.status || 'missing'}, oceanBookingId=${final?.oceanBookingId || 'missing'}`, OPTIONS) `status=${final?.status || 'missing'}, oceanBookingId=${final?.oceanBookingId || 'missing'}, containerBookingId=${final?.containerBookingId || 'n/a'}, loadingPlanId=${final?.loadingPlanId || 'n/a'}`, OPTIONS)
const historyResponse = await context.request('GET', `${TRUCK}/${appointmentId}/history`) const historyResponse = await context.request('GET', `${TRUCK}/${appointmentId}/history`)
const history = context.apiSuccess(MODULE, '查询跨服务链路拖车预约状态历史', historyResponse, '', OPTIONS) const history = context.apiSuccess(MODULE, '查询跨服务链路拖车预约状态历史', historyResponse, '', OPTIONS)
@@ -119,6 +136,8 @@ export async function run(context) {
if (final?.status === 'COMPLETED') { if (final?.status === 'COMPLETED') {
context.createdResources.serviceCenterFlow = { context.createdResources.serviceCenterFlow = {
appointmentId, oceanBookingId: ocean.id, appointmentId, oceanBookingId: ocean.id,
containerBookingId: null,
loadingPlanId: null,
marker: context.marker, finalStatus: 'COMPLETED', marker: context.marker, finalStatus: 'COMPLETED',
} }
} }
+5 -2
View File
@@ -9,6 +9,9 @@ function code(response, expected) { return response?.http === 200 && String(resp
function records(page) { return Array.isArray(page?.records) ? page.records : [] } function records(page) { return Array.isArray(page?.records) ? page.records : [] }
function id(value) { return value == null ? '' : String(value) } function id(value) { return value == null ? '' : String(value) }
function traced(response) { return Boolean(response?.requestId && response?.traceId) } function traced(response) { return Boolean(response?.requestId && response?.traceId) }
function sameCarrierCodes(actual, expected) {
return Array.isArray(actual) && [...actual].sort().join('|') === [...expected].sort().join('|')
}
function query(values = {}) { const p = new URLSearchParams(); for (const [k,v] of Object.entries(values)) if (v !== '' && v != null) p.set(k,String(v)); return `${BASE}${p.size ? `?${p}` : ''}` } function query(values = {}) { const p = new URLSearchParams(); for (const [k,v] of Object.entries(values)) if (v !== '' && v != null) p.set(k,String(v)); return `${BASE}${p.size ? `?${p}` : ''}` }
function payload(marker, overrides = {}) { return { name:`供应商验收-${marker}`,category:'BOOKING',carrierCodes:['MATSON','COSCO'],region:'宁波',legalRepresentative:'验收法人',contactName:'验收联系人',phone:'13800000000',email:'supplier-acceptance@example.com',address:'验收地址',businessLicenseNo:`ACPT${marker}`.slice(0,64),licenseFileId:null,status:'ACTIVE',settlementPeriod:'MONTHLY_30',remark:marker,...overrides } } function payload(marker, overrides = {}) { return { name:`供应商验收-${marker}`,category:'BOOKING',carrierCodes:['MATSON','COSCO'],region:'宁波',legalRepresentative:'验收法人',contactName:'验收联系人',phone:'13800000000',email:'supplier-acceptance@example.com',address:'验收地址',businessLicenseNo:`ACPT${marker}`.slice(0,64),licenseFileId:null,status:'ACTIVE',settlementPeriod:'MONTHLY_30',remark:marker,...overrides } }
@@ -31,7 +34,7 @@ export async function run(context) {
try { try {
const detailResponse=await context.request('GET',`${BASE}/${encodeURIComponent(supplierId)}?tenantId=0`) const detailResponse=await context.request('GET',`${BASE}/${encodeURIComponent(supplierId)}?tenantId=0`)
const detail=context.apiSuccess(MODULE,'查询供应商详情',detailResponse,'',OPTIONS) const detail=context.apiSuccess(MODULE,'查询供应商详情',detailResponse,'',OPTIONS)
context.assert(MODULE,'详情字段与平台作用域及多船司关联正确',detailResponse,id(detail?.id)===supplierId&&id(detail?.tenantId)==='0'&&detail?.name===createBody.name&&!Object.hasOwn(detail,'scope')&&JSON.stringify(detail?.carrierCodes)===JSON.stringify(['MATSON','COSCO']),`id=${id(detail?.id)}, tenantId=${id(detail?.tenantId)}, carrierCodes=${JSON.stringify(detail?.carrierCodes)}, scopeExposed=${Object.hasOwn(detail||{},'scope')}`,OPTIONS) context.assert(MODULE,'详情字段与平台作用域及多船司关联正确',detailResponse,id(detail?.id)===supplierId&&id(detail?.tenantId)==='0'&&detail?.name===createBody.name&&!Object.hasOwn(detail,'scope')&&sameCarrierCodes(detail?.carrierCodes,['MATSON','COSCO']),`id=${id(detail?.id)}, tenantId=${id(detail?.tenantId)}, carrierCodes=${JSON.stringify(detail?.carrierCodes)}, scopeExposed=${Object.hasOwn(detail||{},'scope')}`,OPTIONS)
const duplicateResponse=await context.request('POST',`${BASE}?tenantId=0`,payload(`${marker}D`,{name:createBody.name})) const duplicateResponse=await context.request('POST',`${BASE}?tenantId=0`,payload(`${marker}D`,{name:createBody.name}))
context.assert(MODULE,'重复名称返回冲突并保留链路ID',duplicateResponse,code(duplicateResponse,'A0409')&&traced(duplicateResponse),`code=${duplicateResponse.body?.code}, requestId=${duplicateResponse.requestId||'missing'}, traceId=${duplicateResponse.traceId||'missing'}`,OPTIONS) context.assert(MODULE,'重复名称返回冲突并保留链路ID',duplicateResponse,code(duplicateResponse,'A0409')&&traced(duplicateResponse),`code=${duplicateResponse.body?.code}, requestId=${duplicateResponse.requestId||'missing'}, traceId=${duplicateResponse.traceId||'missing'}`,OPTIONS)
@@ -52,7 +55,7 @@ export async function run(context) {
context.apiSuccess(MODULE,'编辑供应商',updateResponse,'',OPTIONS) context.apiSuccess(MODULE,'编辑供应商',updateResponse,'',OPTIONS)
const editedResponse=await context.request('GET',`${BASE}/${encodeURIComponent(supplierId)}?tenantId=0`) const editedResponse=await context.request('GET',`${BASE}/${encodeURIComponent(supplierId)}?tenantId=0`)
const edited=context.apiSuccess(MODULE,'回查编辑结果',editedResponse,'',OPTIONS) const edited=context.apiSuccess(MODULE,'回查编辑结果',editedResponse,'',OPTIONS)
context.assert(MODULE,'编辑字段及多船司关联已持久化',editedResponse,edited?.name===updateBody.name&&!Object.hasOwn(edited,'scope')&&edited?.contactName==='编辑联系人'&&edited?.settlementPeriod==='MONTHLY_45'&&JSON.stringify(edited?.carrierCodes)===JSON.stringify(['MATSON','COSCO']),`name=${edited?.name||'missing'}, carrierCodes=${JSON.stringify(edited?.carrierCodes)}, scopeExposed=${Object.hasOwn(edited||{},'scope')}`,OPTIONS) context.assert(MODULE,'编辑字段及多船司关联已持久化',editedResponse,edited?.name===updateBody.name&&!Object.hasOwn(edited,'scope')&&edited?.contactName==='编辑联系人'&&edited?.settlementPeriod==='MONTHLY_45'&&sameCarrierCodes(edited?.carrierCodes,['MATSON','COSCO']),`name=${edited?.name||'missing'}, carrierCodes=${JSON.stringify(edited?.carrierCodes)}, scopeExposed=${Object.hasOwn(edited||{},'scope')}`,OPTIONS)
const disableResponse=await context.request('PATCH',`${BASE}/${encodeURIComponent(supplierId)}/status?tenantId=0`,{status:'INACTIVE'}) const disableResponse=await context.request('PATCH',`${BASE}/${encodeURIComponent(supplierId)}/status?tenantId=0`,{status:'INACTIVE'})
context.apiSuccess(MODULE,'停用供应商',disableResponse,'',OPTIONS); inactive=ok(disableResponse) context.apiSuccess(MODULE,'停用供应商',disableResponse,'',OPTIONS); inactive=ok(disableResponse)
+113 -18
View File
@@ -42,6 +42,7 @@ if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) {
const now = new Date() const now = new Date()
const marker = `${compactTimestamp(now)}-${randomUUID().slice(0, 8)}` const marker = `${compactTimestamp(now)}-${randomUUID().slice(0, 8)}`
const username = `client_${compactTimestamp(now)}_${randomUUID().slice(0, 6)}`.slice(0, 64) const username = `client_${compactTimestamp(now)}_${randomUUID().slice(0, 6)}`.slice(0, 64)
const temporaryUsername = `renamed_${compactTimestamp(now)}_${randomUUID().slice(0, 6)}`.slice(0, 64)
const clientPassword = `Client${randomUUID().replaceAll('-', '').slice(0, 14)}9!` const clientPassword = `Client${randomUUID().replaceAll('-', '').slice(0, 14)}9!`
const phoneSuffix = `${Date.now()}`.slice(-8) const phoneSuffix = `${Date.now()}`.slice(-8)
const legalPhone = `139${phoneSuffix}` const legalPhone = `139${phoneSuffix}`
@@ -91,6 +92,7 @@ try {
await qualificationAndReviewModule() await qualificationAndReviewModule()
await merchantAfterApprovalModule() await merchantAfterApprovalModule()
await profileManagementModule() await profileManagementModule()
await loginIdentifierModule()
await rbacAndMemberModule() await rbacAndMemberModule()
await orderLifecycleModule() await orderLifecycleModule()
await isolationModule() await isolationModule()
@@ -117,7 +119,7 @@ async function registrationModule() {
displayName: `测试管理员-${marker}`.slice(0, 64), displayName: `测试管理员-${marker}`.slice(0, 64),
companyName, companyName,
legalPerson: '测试法人', legalPerson: '测试法人',
legalPhone, phone: legalPhone,
contactEmail: `${username}@example.com`, contactEmail: `${username}@example.com`,
countryCode: 'CN', countryCode: 'CN',
address: '广东省深圳市南山区测试路 1 号', address: '广东省深圳市南山区测试路 1 号',
@@ -213,7 +215,6 @@ async function qualificationAndReviewModule() {
const png = testPng() const png = testPng()
const form = new FormData() const form = new FormData()
form.append('businessLicense', new Blob([png], { type: 'image/png' }), `business-license-${marker}.png`) form.append('businessLicense', new Blob([png], { type: 'image/png' }), `business-license-${marker}.png`)
form.append('legalPersonId', new Blob([png], { type: 'image/png' }), `legal-person-id-${marker}.png`)
await apiStep('qualification-review', 'upload required qualification package', { await apiStep('qualification-review', 'upload required qualification package', {
method: 'POST', method: 'POST',
@@ -223,7 +224,7 @@ async function qualificationAndReviewModule() {
}, ({ json }) => { }, ({ json }) => {
requireSuccess(json) requireSuccess(json)
assertPresent(json.data?.businessLicenseQualificationId, 'business license qualification id') assertPresent(json.data?.businessLicenseQualificationId, 'business license qualification id')
assertPresent(json.data?.legalPersonIdQualificationId, 'legal person qualification id') assertTrue(json.data?.legalPersonIdQualificationId == null, 'legal person qualification id must stay optional')
assertEqual(json.data?.tenantStatus, 'PROFILE_REQUIRED', 'tenant status after upload') assertEqual(json.data?.tenantStatus, 'PROFILE_REQUIRED', 'tenant status after upload')
}, ({ json }) => ({ }, ({ json }) => ({
businessLicenseQualificationId: json.data.businessLicenseQualificationId, businessLicenseQualificationId: json.data.businessLicenseQualificationId,
@@ -274,7 +275,9 @@ async function qualificationAndReviewModule() {
method: 'GET', path: '/api/merchant/v1/iam/tenant/qualifications', token: merchantToken, method: 'GET', path: '/api/merchant/v1/iam/tenant/qualifications', token: merchantToken,
}, ({ json }) => { }, ({ json }) => {
requireSuccess(json) requireSuccess(json)
assertQualificationStatuses(json.data, ['BUSINESS_LICENSE', 'LEGAL_PERSON_ID'], 'PENDING') assertQualificationStatuses(json.data, ['BUSINESS_LICENSE'], 'PENDING')
assertTrue(!arrayData(json.data).some((item) => item.qualificationType === 'LEGAL_PERSON_ID'),
'optional legal person ID must remain absent')
assertQualificationStatuses(json.data, ['FREIGHT_FORWARDER_CERTIFICATE', 'BANK_ACCOUNT_LICENSE'], 'PENDING') assertQualificationStatuses(json.data, ['FREIGHT_FORWARDER_CERTIFICATE', 'BANK_ACCOUNT_LICENSE'], 'PENDING')
}, ({ json }) => ({ qualifications: qualificationEvidence(json.data) })) }, ({ json }) => ({ qualifications: qualificationEvidence(json.data) }))
@@ -293,6 +296,19 @@ async function qualificationAndReviewModule() {
}, () => ({ username: platformUsername })) }, () => ({ username: platformUsername }))
platformToken = platformLogin.json.data.accessToken platformToken = platformLogin.json.data.accessToken
const dimensionPath = `/api/platform/v1/iam/tenants/${report.resources.tenantId}/dimension-initialization`
for (let attempt = 0; attempt < 30; attempt += 1) {
const statusResponse = await httpRequest({ method: 'GET', path: dimensionPath, token: platformToken })
if (String(statusResponse.json?.data?.status) === 'READY') break
if (attempt < 29) await new Promise((resolve) => setTimeout(resolve, 1_000))
}
await apiStep('qualification-review', 'customer default dimensions are ready before approval', {
method: 'GET', path: dimensionPath, token: platformToken,
}, ({ json }) => {
requireSuccess(json)
assertEqual(json.data?.status, 'READY', 'dimension initialization status')
}, ({ json }) => ({ status: json.data.status, attemptCount: json.data.attemptCount }))
const reviews = await apiStep('qualification-review', 'platform finds pending review', { const reviews = await apiStep('qualification-review', 'platform finds pending review', {
method: 'GET', path: '/api/platform/v1/iam/reviews', token: platformToken, method: 'GET', path: '/api/platform/v1/iam/reviews', token: platformToken,
}, ({ json }) => { }, ({ json }) => {
@@ -310,7 +326,9 @@ async function qualificationAndReviewModule() {
method: 'GET', path: `/api/platform/v1/iam/tenants/${report.resources.tenantId}/qualifications`, token: platformToken, method: 'GET', path: `/api/platform/v1/iam/tenants/${report.resources.tenantId}/qualifications`, token: platformToken,
}, ({ json }) => { }, ({ json }) => {
requireSuccess(json) requireSuccess(json)
assertQualificationStatuses(json.data, ['BUSINESS_LICENSE', 'LEGAL_PERSON_ID'], 'PENDING') assertQualificationStatuses(json.data, ['BUSINESS_LICENSE'], 'PENDING')
assertTrue(!arrayData(json.data).some((item) => item.qualificationType === 'LEGAL_PERSON_ID'),
'platform must not require a legal person ID record')
}, ({ json }) => ({ qualifications: qualificationEvidence(json.data) })) }, ({ json }) => ({ qualifications: qualificationEvidence(json.data) }))
const approval = await apiStep('qualification-review', 'platform approves enterprise', { const approval = await apiStep('qualification-review', 'platform approves enterprise', {
@@ -343,6 +361,51 @@ async function qualificationAndReviewModule() {
}) })
} }
async function loginIdentifierModule() {
const staleToken = merchantToken
await apiStep('login-identifiers', 'change username with current-password verification', {
method: 'PUT', path: '/api/merchant/v1/iam/profile/login-identifiers', token: merchantToken,
body: { username: temporaryUsername, phone: legalPhone, currentPassword: clientPassword },
}, ({ json }) => {
requireSuccess(json)
assertEqual(json.data?.username, temporaryUsername, 'updated username')
assertEqual(json.data?.phone, legalPhone, 'updated phone')
}, ({ json }) => ({ username: json.data.username, phone: json.data.phone }))
await apiStep('login-identifiers', 'identifier change revokes the previous access token', {
method: 'GET', path: '/api/merchant/v1/iam/profile', token: staleToken,
}, ({ status, json }) => {
assertEqual(status, 401, 'HTTP status')
assertTrue(String(json?.code || '').length > 0, 'authentication error code must be present')
}, ({ status, json }) => ({ status, rejectionCode: json?.code }))
const renamedLogin = await apiStep('login-identifiers', 'login with the changed username', {
method: 'POST', path: '/api/auth/login', body: { username: temporaryUsername, password: clientPassword },
}, ({ json }) => {
requireSuccess(json)
assertPresent(json.data?.accessToken, 'accessToken')
}, () => ({ username: temporaryUsername }))
merchantToken = renamedLogin.json.data.accessToken
merchantRefreshToken = renamedLogin.json.data.refreshToken
await apiStep('login-identifiers', 'restore the original username for the remaining acceptance flow', {
method: 'PUT', path: '/api/merchant/v1/iam/profile/login-identifiers', token: merchantToken,
body: { username, phone: legalPhone, currentPassword: clientPassword },
}, ({ json }) => {
requireSuccess(json)
assertEqual(json.data?.username, username, 'restored username')
}, ({ json }) => ({ username: json.data.username }))
const restoredLogin = await apiStep('login-identifiers', 'login after restoring the original username', {
method: 'POST', path: '/api/auth/login', body: { username, password: clientPassword },
}, ({ json }) => {
requireSuccess(json)
assertPresent(json.data?.accessToken, 'accessToken')
}, () => ({ username }))
merchantToken = restoredLogin.json.data.accessToken
merchantRefreshToken = restoredLogin.json.data.refreshToken
}
async function merchantAfterApprovalModule() { async function merchantAfterApprovalModule() {
const login = await apiStep('merchant-session', 'client re-login after approval', { const login = await apiStep('merchant-session', 'client re-login after approval', {
method: 'POST', path: '/api/auth/login', body: { loginId: username, password: clientPassword }, method: 'POST', path: '/api/auth/login', body: { loginId: username, password: clientPassword },
@@ -365,7 +428,9 @@ async function merchantAfterApprovalModule() {
method: 'GET', path: '/api/merchant/v1/iam/tenant/qualifications', token: merchantToken, method: 'GET', path: '/api/merchant/v1/iam/tenant/qualifications', token: merchantToken,
}, ({ json }) => { }, ({ json }) => {
requireSuccess(json) requireSuccess(json)
assertQualificationStatuses(json.data, ['BUSINESS_LICENSE', 'LEGAL_PERSON_ID'], 'APPROVED') assertQualificationStatuses(json.data, ['BUSINESS_LICENSE'], 'APPROVED')
assertTrue(!arrayData(json.data).some((item) => item.qualificationType === 'LEGAL_PERSON_ID'),
'approved enterprise must not gain a synthetic legal person ID record')
assertQualificationStatuses(json.data, ['FREIGHT_FORWARDER_CERTIFICATE', 'BANK_ACCOUNT_LICENSE'], 'APPROVED') assertQualificationStatuses(json.data, ['FREIGHT_FORWARDER_CERTIFICATE', 'BANK_ACCOUNT_LICENSE'], 'APPROVED')
}, ({ json }) => ({ qualifications: qualificationEvidence(json.data) })) }, ({ json }) => ({ qualifications: qualificationEvidence(json.data) }))
@@ -1004,9 +1069,9 @@ async function orderLifecycleModule() {
}, ({ bytes, headers }) => ({ sizeBytes: bytes.length, contentType: headers.get('content-type') })) }, ({ bytes, headers }) => ({ sizeBytes: bytes.length, contentType: headers.get('content-type') }))
const payload = buildOrderPayload(masterData, productImageFileId, 'ACTIVE', 'fresh draft after approval') const payload = buildOrderPayload(masterData, productImageFileId, 'ACTIVE', 'fresh draft after approval')
const invalidOrderCases = [ const incompleteSubmitCases = [
{ {
name: 'FBA order missing Amazon identifiers is rejected', name: 'FBA order missing Amazon identifiers',
mutate(value) { mutate(value) {
value.amazonWarehouseCode = null value.amazonWarehouseCode = null
value.shipmentId = null value.shipmentId = null
@@ -1014,7 +1079,7 @@ async function orderLifecycleModule() {
}, },
}, },
{ {
name: 'FBA cargo missing SKU UPC shipment and reference is rejected', name: 'FBA cargo missing SKU UPC shipment and reference',
mutate(value) { mutate(value) {
value.cargoes[0].skuNo = null value.cargoes[0].skuNo = null
value.cargoes[0].upcNo = null value.cargoes[0].upcNo = null
@@ -1022,9 +1087,35 @@ async function orderLifecycleModule() {
value.cargoes[0].referenceId = null value.cargoes[0].referenceId = null
}, },
}, },
]
for (const testCase of incompleteSubmitCases) {
const incompletePayload = structuredClone(payload)
testCase.mutate(incompletePayload)
const draft = await apiStep('order-validation', `${testCase.name} can be saved as a draft`, {
method: 'POST', path: '/api/merchant/v1/orders/first-leg', token: merchantToken,
headers: { 'Idempotency-Key': newIdempotencyKey('incomplete-draft') }, body: incompletePayload,
}, ({ json }) => {
requireSuccess(json)
assertPresent(json.data, 'incomplete draft order id')
}, ({ json }) => ({ orderId: json.data, status: 'DRAFT' }))
report.resources.orderIds.push(draft.json.data)
await softApiStep('order-validation', `${testCase.name} is rejected on submit`, {
method: 'POST', path: `/api/merchant/v1/orders/first-leg/${draft.json.data}/submit`, token: merchantToken,
headers: { 'Idempotency-Key': newIdempotencyKey('incomplete-submit') },
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0422', 'business code')
}, ({ json }) => ({ orderId: draft.json.data, rejectionCode: json?.code, message: json?.message }))
}
const invalidOrderCases = [
{ {
name: 'PICKUP order without sender is rejected', name: 'PICKUP order without a selected pickup address is rejected',
mutate(value) { value.sender = null }, mutate(value) {
value.pickupMethod = 'PICKUP'
value.requestedPickupAt = new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 19)
value.sender = null
value.senderAddressId = null
},
}, },
{ {
name: 'PRIVATE destination without receiver is rejected', name: 'PRIVATE destination without receiver is rejected',
@@ -1191,11 +1282,11 @@ async function orderLifecycleModule() {
requireSuccess(json) requireSuccess(json)
assertPresent(pageRecords(json.data).find((item) => String(item.orderId) === String(orderId)), 'combined-filtered order') assertPresent(pageRecords(json.data).find((item) => String(item.orderId) === String(orderId)), 'combined-filtered order')
}, ({ json }) => ({ total: json.data?.total, orderId })) }, ({ json }) => ({ total: json.data?.total, orderId }))
await apiStep('order-filters', 'sender phone filter contains submitted order', { await apiStep('order-filters', 'customer contact filter contains submitted order by customer name', {
method: 'GET', path: `/api/merchant/v1/orders/first-leg?page=1&size=50&customerContact=${encodeURIComponent(legalPhone)}`, token: merchantToken, method: 'GET', path: `/api/merchant/v1/orders/first-leg?page=1&size=50&customerContact=${encodeURIComponent(payload.customerName)}`, token: merchantToken,
}, ({ json }) => { }, ({ json }) => {
requireSuccess(json) requireSuccess(json)
assertPresent(pageRecords(json.data).find((item) => String(item.orderId) === String(orderId)), 'sender-phone-filtered order') assertPresent(pageRecords(json.data).find((item) => String(item.orderId) === String(orderId)), 'customer-contact-filtered order')
}, ({ json }) => ({ total: json.data?.total, orderId })) }, ({ json }) => ({ total: json.data?.total, orderId }))
await apiStep('order-filters', 'unmatched keyword returns an empty order page', { await apiStep('order-filters', 'unmatched keyword returns an empty order page', {
method: 'GET', path: `/api/merchant/v1/orders/first-leg?page=1&size=50&keyword=${randomUUID()}`, token: merchantToken, method: 'GET', path: `/api/merchant/v1/orders/first-leg?page=1&size=50&keyword=${randomUUID()}`, token: merchantToken,
@@ -1815,7 +1906,7 @@ function buildOrderPayload(masterData, productImageFileId, suffix, remark) {
destinationWarehouseName: masterData.destination.warehouseName, destinationWarehouseName: masterData.destination.warehouseName,
expectedShipDate: addDays(new Date(), 30), expectedShipDate: addDays(new Date(), 30),
customerRemark: `${remark} ${marker}`, customerRemark: `${remark} ${marker}`,
pickupMethod: 'PICKUP', pickupMethod: 'SELF_EXPRESS',
receivingWarehouseId: masterData.receiving.id, receivingWarehouseId: masterData.receiving.id,
receivingWarehouseName: masterData.receiving.warehouseName, receivingWarehouseName: masterData.receiving.warehouseName,
channelCode: masterData.channel.channelCode, channelCode: masterData.channel.channelCode,
@@ -1867,6 +1958,7 @@ function buildOrderPayload(masterData, productImageFileId, suffix, remark) {
upcNo: `123456${`${Date.now()}`.slice(-6)}`.slice(0, 12), upcNo: `123456${`${Date.now()}`.slice(-6)}`.slice(0, 12),
productImageFileId, productImageFileId,
dangerousGoods: false, dangerousGoods: false,
woodenProduct: false,
cargoRemark: `cargo-${remark} ${marker}`, cargoRemark: `cargo-${remark} ${marker}`,
}], }],
} }
@@ -1911,10 +2003,13 @@ async function softApiStep(module, name, request, validate, evidenceBuilder = de
async function httpRequest({ method = 'GET', path, token, headers = {}, body, responseType = 'json' }) { async function httpRequest({ method = 'GET', path, token, headers = {}, body, responseType = 'json' }) {
const requestHeaders = { Accept: 'application/json', 'X-Request-Id': `client-e2e-${randomUUID()}`, ...headers } const requestHeaders = { Accept: 'application/json', 'X-Request-Id': `client-e2e-${randomUUID()}`, ...headers }
if (token) requestHeaders.Authorization = `Bearer ${token}` if (token) requestHeaders.Authorization = `Bearer ${token}`
let requestBody = body const normalizedBody = path === '/api/auth/login' && body?.username && !body?.loginId
if (body != null && !(body instanceof FormData)) { ? { ...body, loginId: body.username, username: undefined }
: body
let requestBody = normalizedBody
if (normalizedBody != null && !(normalizedBody instanceof FormData)) {
requestHeaders['Content-Type'] = 'application/json' requestHeaders['Content-Type'] = 'application/json'
requestBody = JSON.stringify(body) requestBody = JSON.stringify(normalizedBody)
} }
const response = await fetch(`${gateway}${path}`, { const response = await fetch(`${gateway}${path}`, {
method, method,