diff --git a/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java b/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java index b4cf838f0..d47487852 100644 --- a/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java +++ b/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java @@ -40,7 +40,7 @@ public class QualificationFileController { @RequestParam(required = false) LocalDate validTo, @RequestParam(defaultValue = "false") boolean replace) { long tenantId = merchantTenant(user); - qualificationType = QualificationTypes.requireOptional(qualificationType); + qualificationType = QualificationTypes.requireSingleUpload(qualificationType); var stored = storage.store(tenantId, file); try { var response = iamClient.submit(internalToken, diff --git a/file/src/main/java/com/baiyue/file/controller/QualificationTypes.java b/file/src/main/java/com/baiyue/file/controller/QualificationTypes.java index 2d59ccc1c..ce5363724 100644 --- a/file/src/main/java/com/baiyue/file/controller/QualificationTypes.java +++ b/file/src/main/java/com/baiyue/file/controller/QualificationTypes.java @@ -21,11 +21,22 @@ final class QualificationTypes { 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) { String type = requireSupported(qualificationType); if ("BUSINESS_LICENSE".equals(type) || "LEGAL_PERSON_ID".equals(type)) { 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; } diff --git a/file/src/main/resources/openapi/qualification-required-package-v1.yaml b/file/src/main/resources/openapi/qualification-required-package-v1.yaml index 482a0e339..1316db9eb 100644 --- a/file/src/main/resources/openapi/qualification-required-package-v1.yaml +++ b/file/src/main/resources/openapi/qualification-required-package-v1.yaml @@ -4,6 +4,43 @@ info: version: 1.0.0 description: 营业执照为必填资质;法人身份证为选填资质,缺省时不创建、不替换法人身份证记录。 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: post: summary: 客户上传必需资质包 @@ -37,6 +74,27 @@ components: securitySchemes: bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} 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: name: tenantId in: path @@ -88,6 +146,11 @@ components: nullable: true description: 法人身份证,选填;PDF/JPG/PNG,最大5MB responses: + Error: + description: 认证或授权失败,响应包含请求关联元数据 + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} PackageResult: description: 资质包上传结果 content: @@ -100,6 +163,37 @@ components: data: {$ref: '#/components/schemas/PackageUploadResult'} schemas: 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: type: object required: [businessLicenseQualificationId, tenantStatus, businessLicenseDocumentUrl] diff --git a/file/src/test/java/com/baiyue/file/config/QualificationAndOrderFileOpenApiContractTest.java b/file/src/test/java/com/baiyue/file/config/QualificationAndOrderFileOpenApiContractTest.java index 3d365911d..948b3eef3 100644 --- a/file/src/test/java/com/baiyue/file/config/QualificationAndOrderFileOpenApiContractTest.java +++ b/file/src/test/java/com/baiyue/file/config/QualificationAndOrderFileOpenApiContractTest.java @@ -8,6 +8,19 @@ import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; 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 void qualificationPackageRequiresOnlyTheBusinessLicenseFile() throws Exception { String yaml = Files.readString(Path.of( diff --git a/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java b/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java index 297cfd856..1ea6be0ab 100644 --- a/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java +++ b/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java @@ -96,6 +96,16 @@ class QualificationFileContractTest { 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 void platformQualificationRejectsCustomerScopedPlatformToken() { var storage = mock(LocalQualificationStorage.class); @@ -219,7 +229,7 @@ class QualificationFileContractTest { } @Test - void merchantUploadForwardsItsQualificationTypeAndCleansTheReplacedFile() { + void merchantLegalPersonIdReplacementUsesTheAuthenticatedTenantAndCleansTheReplacedFile() { var storage = mock(LocalQualificationStorage.class); var iamClient = mock(IamQualificationClient.class); var controller = merchantController(storage, iamClient); @@ -230,15 +240,30 @@ class QualificationFileContractTest { when(iamClient.submit(eq("internal-token"), any())).thenReturn(ApiResponse.success( 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); verify(iamClient).submit(eq("internal-token"), requestCaptor.capture()); 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()); } + @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 void merchantUploadRejectsMissingOrUnsupportedTypesBeforeWritingTheFile() { var storage = mock(LocalQualificationStorage.class); @@ -258,7 +283,7 @@ class QualificationFileContractTest { @Test void everyOptionalQualificationTypeIsAcceptedAndForwarded() { 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 iamClient = mock(IamQualificationClient.class); var controller = merchantController(storage, iamClient); @@ -277,19 +302,17 @@ class QualificationFileContractTest { } @Test - void legacySingleUploadRejectsBothRequiredTypesBeforeWritingTheFile() { - for (String qualificationType : Set.of("BUSINESS_LICENSE", "LEGAL_PERSON_ID")) { - var storage = mock(LocalQualificationStorage.class); - var iamClient = mock(IamQualificationClient.class); - var controller = merchantController(storage, iamClient); - var file = new MockMultipartFile("file", "qualification.pdf", "application/pdf", "%PDF-1.4".getBytes()); + void singleUploadStillRejectsTheBusinessLicenseBeforeWritingTheFile() { + var storage = mock(LocalQualificationStorage.class); + var iamClient = mock(IamQualificationClient.class); + var controller = merchantController(storage, iamClient); + var file = new MockMultipartFile("file", "qualification.pdf", "application/pdf", "%PDF-1.4".getBytes()); - assertThrows(BizException.class, - () -> controller.upload(merchantUser(), file, qualificationType, null, null, false)); + assertThrows(BizException.class, + () -> controller.upload(merchantUser(), file, "BUSINESS_LICENSE", null, null, false)); - verify(storage, never()).store(any(Long.class), any()); - verify(iamClient, never()).submit(any(), any()); - } + verify(storage, never()).store(any(Long.class), any()); + verify(iamClient, never()).submit(any(), any()); } private PlatformQualificationFileController platformController( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceService.java index 93daa7d93..5ed4d2e85 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceService.java @@ -24,8 +24,8 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; -import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.io.IOException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.HexFormat; @@ -45,13 +45,13 @@ public class CabinetLoadingEvidenceService { @Transactional public EvidenceList upsert(long taskId, String key, UpsertRequest request, long operatorId, String operatorName) { - Scope scope = scope(taskId, request.warehouseId(), operatorId, true); - requireKey(key); - return persist(scope, taskId, key, request, hash(request), operatorId, operatorName); + return upsert(taskId, key, request, operatorId, operatorName, hash(request)); } - private EvidenceList persist(Scope scope, long taskId, String key, UpsertRequest request, - String requestHash, long operatorId, String operatorName) { + private EvidenceList upsert(long taskId, String key, UpsertRequest request, long operatorId, + String operatorName, String requestHash) { + Scope scope = scope(taskId, request.warehouseId(), operatorId, true); + requireKey(key); CabinetLoadingEvidenceEntity replay = mapper.byOperation(taskId, key); if (replay != null) { if (!Objects.equals(requestHash, replay.getRequestHash())) throw conflict("相同幂等键的请求内容不一致"); @@ -93,22 +93,20 @@ public class CabinetLoadingEvidenceService { long operatorId, String operatorName) { Scope scope = scope(taskId, warehouseId, operatorId, true); requireKey(key); - String type = evidenceType.trim(); - 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))); + String requestHash = uploadHash(warehouseId, expectedVersion, evidenceType, sealNo, remark, multipart); CabinetLoadingEvidenceEntity replay = mapper.byOperation(taskId, key); if (replay != null) { - if (!Objects.equals(requestHash, replay.getRequestHash())) throw conflict("相同幂等键的请求内容不一致"); + if (!Objects.equals(requestHash, replay.getRequestHash())) { + throw conflict("相同幂等键的请求内容不一致"); + } return view(scope); } 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(); if (uploaded == null) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "装柜证据文件上传失败"); - return persist(scope, taskId, key, new UpsertRequest(warehouseId, expectedVersion, type, - uploaded.fileId(), normalizedSealNo, remark), requestHash, operatorId, operatorName); + return upsert(taskId, key, new UpsertRequest(warehouseId, expectedVersion, evidenceType, + uploaded.fileId(), sealNo, remark), operatorId, operatorName, requestHash); } catch (FeignException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); } @@ -197,9 +195,17 @@ public class CabinetLoadingEvidenceService { objectMapper.writeValueAsString(value).getBytes(StandardCharsets.UTF_8))); } 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 { - 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) { throw bad("装柜证据文件读取失败"); } 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 conflict(String message) { return new BizException(ResultCode.CONFLICT, message); } 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) {} } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceServiceTest.java index 4b28e2219..8ee5e9b2e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceServiceTest.java @@ -13,15 +13,17 @@ import jakarta.annotation.Resource; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; 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.mock.web.MockMultipartFile; +import org.mockito.ArgumentCaptor; 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.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.times; import static org.mockito.Mockito.verify; @@ -104,35 +106,32 @@ class CabinetLoadingEvidenceServiceTest { } @Test - void multipartReplayUsesRequestAndContentHashBeforeUploadingAgain() throws Exception { - when(objectMapper.writeValueAsString(org.mockito.ArgumentMatchers.any())).thenReturn("request"); - 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)); + void multipartReplayUsesContentHashBeforeUploadingAnotherFile() { + var multipart = new MockMultipartFile("file", "photo.png", "image/png", new byte[] {1, 2, 3}); var upload = new LoadingEvidenceFileClient.UploadResult(301L, "PLATFORM", 0L, - "LOADING_PHOTO_STEP_1", "99", "photo.png", "image/png", 5L); - 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)); + "LOADING_PHOTO_STEP_1", "99", "photo.png", "image/png", 3L); var metadata = new LoadingEvidenceFileClient.FileMetadata(301L, "PLATFORM", 0L, "LOADING_PHOTO_STEP_1", "99", "photo.png", "image/png"); - when(files.validate(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.eq(99L), - org.mockito.ArgumentMatchers.eq("PHOTO_STEP_1"), org.mockito.ArgumentMatchers.eq(301L))) + when(files.upload(anyString(), anyLong(), anyLong(), anyString(), any())) + .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)); - 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, "photo-replay", 0, "PHOTO_STEP_1", null, "remark", file, 9L, "operator"); + service.upload(20L, 8L, "same-key", 0, "PHOTO_STEP_1", null, "remark", + multipart, 9L, "operator"); - verify(files, times(1)).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()); - verify(mapper, times(1)).insert(org.mockito.ArgumentMatchers.any(CabinetLoadingEvidenceEntity.class)); + ArgumentCaptor inserted = + ArgumentCaptor.forClass(CabinetLoadingEvidenceEntity.class); + verify(mapper).insert(inserted.capture()); + 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) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java index 38c98417a..793829de4 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java @@ -58,6 +58,10 @@ class SmartLoadingCandidateMapperContractTest { .contains("SELECT SUM(li.package_count)") .contains("li.deleted=0") .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 @@ -90,6 +94,10 @@ class SmartLoadingCandidateMapperContractTest { .contains("source.fulfillment_type='ONE_CLICK_DROPSHIP'") .contains("i.inbound_id=o.id") .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 diff --git a/iam/src/test/java/com/baiyue/iam/repository/FinanceG1G2PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/FinanceG1G2PermissionSqlContractTest.java index ccf424a03..46230f310 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/FinanceG1G2PermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/FinanceG1G2PermissionSqlContractTest.java @@ -46,7 +46,8 @@ class FinanceG1G2PermissionSqlContractTest { @Test 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 人工财务权限"), sql.indexOf("-- Admin 原型菜单与财务五页权限补充")); diff --git a/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java b/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java index 24429a27a..80173ee17 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java @@ -1,11 +1,14 @@ 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.RequiredQualificationPackageRequest; import com.baiyue.iam.entity.IamTenant; +import com.baiyue.iam.entity.IamTenantQualification; import com.baiyue.iam.repository.ProfileResourceRepository; import com.baiyue.iam.repository.TenantRepository; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.springframework.boot.test.mock.mockito.MockBean; 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.when; import static org.mockito.Mockito.times; -import com.baiyue.common.core.exception.BizException; @SpringJUnitConfig(ProfileResourceService.class) class MerchantQualificationUploadTest { @@ -49,6 +51,38 @@ class MerchantQualificationUploadTest { 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 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 void requiredQualificationsRejectTheLegacySingleFilePath() { IamTenant tenant = new IamTenant(); @@ -118,4 +152,14 @@ class MerchantQualificationUploadTest { 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; + } } diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index 24e2dadb3..25a95c586 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -297,8 +297,11 @@ public class UnifiedOrderQueryService { order.getCreatedAt(), order.getUpdatedAt(), order.getRequestedPickupAt()); } 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(), - summary.mainStatus(), summary.nextAction(), relation(order, childCounts), order.getStatus(), + mainStatus, nextAction, relation(order, childCounts), order.getStatus(), order.getCustomerId(), order.getCustomerName(), order.getChannelCode(), order.getChannelName(), Boolean.TRUE.equals(order.getChannelChanged()), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(), order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), inbound.id(), inbound.version(), inbound.inboundCode(), diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java index 1df73770b..f1d3c1de6 100644 --- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -81,6 +81,26 @@ class UnifiedOrderQueryServiceTest { 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 void newlyCreatedOrderUsesItsOwnCargoPackageCountBeforeInboundExists() { UnifiedOrderQuery query = query(null); diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 89b7e826b..75e2bc053 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -336,7 +336,8 @@ export function parseArguments(argv) { channelCode: values.get('channel-code') || '', orderProfileMode: values.get('order-profile-mode') || 'fixed', 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), waitSplitSequence: Number(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 } if (token) headers.Authorization = `Bearer ${token}` 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 { 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), }) const content = await response.text() @@ -824,7 +828,7 @@ export class AcceptanceRun { this.context.phase3LoadingIncrementalPackageCount = variant.packageCountPerSku const profile = this.options.orderProfileMode === 'randomized' ? this.randomizedOrderProfile(sequence) - : { destinationType: 'FBA', fulfillmentType: 'STANDARD', pickupMethod: 'PICKUP', + : { destinationType: 'FBA', fulfillmentType: 'STANDARD', pickupMethod: 'SELF_EXPRESS', taxMode: 'BUY_DECLARATION', containerMode: 'LCL', customsInspection: false } this.context.orderProfile = profile this.context.orderDestinationType = profile.destinationType @@ -893,13 +897,12 @@ export class AcceptanceRun { } async findAvailablePhone(module = 'customers') { - const seed = BigInt(Date.now() % 10_000_000_000) - for (let attempt = 0; attempt < 20; attempt += 1) { - const phone = `1${((seed + BigInt(attempt)) % 10_000_000_000n).toString().padStart(10, '0')}` - const response = await this.request('GET', `/api/platform/v1/iam/tenants/contact-phone-availability?contactPhone=${phone}`) - if (this.apiSuccess(module, '检查测试联系电话可用性', response, `attempt=${attempt + 1}`) === true) return phone - } - throw new Error('连续 20 个测试联系电话均不可用') + this.phoneSequence = Number(this.phoneSequence || 0) + 1 + const markerDigits = this.marker.replace(/\D/g, '').slice(-8).padStart(8, '0') + const phone = `18${markerDigits}${String(this.phoneSequence % 10).padStart(1, '0')}` + this.addStep(module, '生成本轮隔离测试联系电话', null, /^1\d{10}$/.test(phone), + `sequence=${this.phoneSequence}`) + return phone } async runCustomerDimensionRules(tenantId) { @@ -1057,15 +1060,30 @@ export class AcceptanceRun { && /[A-Za-z]/.test(createData.temporaryPassword) && /\d/.test(createData.temporaryPassword), `generated=${typeof createData.temporaryPassword === 'string'}, length=${createData.temporaryPassword?.length || 0}`, ownerProvisioningOptions) - this.assert(module, '管理员用户名默认使用联系电话', createResponse, - createData.administratorUsername === phone, - `matches=${createData.administratorUsername === phone}`, + const administratorUsername = String(createData.administratorUsername || '') + this.assert(module, '管理员用户名作为独立登录标识返回', createResponse, + administratorUsername.length > 0, + `present=${administratorUsername.length > 0}`, ownerProvisioningOptions) this.assert(module, '待完善客户的审核字段保持为空', createResponse, customer.unifiedSocialCreditCode == null && customer.approvedAt == null, `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 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 owner = members?.find?.((item) => item.memberType === 'OWNER') - this.assert(module, '联系电话账号是企业管理员', memberResponse, - owner?.username === phone, - `ownerFound=${Boolean(owner)}, matches=${owner?.username === phone}`, + this.assert(module, '独立用户名账号是企业管理员且联系电话不承担用户名约束', memberResponse, + owner?.username === administratorUsername, + `ownerFound=${Boolean(owner)}, matches=${owner?.username === administratorUsername}`, 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) this.assert(module, '业务概况返回当前客户运单数', countResponse, count && Number.isFinite(Number(count.orderCount)), `orderCount=${count?.orderCount ?? 'missing'}`) diff --git a/scripts/admin-test-env-modules/coload-groups-contract.test.mjs b/scripts/admin-test-env-modules/coload-groups-contract.test.mjs index 14d8fa4e7..7b7f33686 100644 --- a/scripts/admin-test-env-modules/coload-groups-contract.test.mjs +++ b/scripts/admin-test-env-modules/coload-groups-contract.test.mjs @@ -9,7 +9,7 @@ test('同柜不可拆组HTTP验收锁定公开接口与防绕过矩阵', async ( '/api/platform/v1/first-leg/coload-groups/by-inbound-code/', '/group-assignments', '/inbound-assignments', - '/push-preload', + '/plans/by-booking/', '创建同键同载荷精确重放', '创建同键异载荷返回并发冲突', '不同目的仓港仍可创建同柜不可拆组', @@ -31,7 +31,7 @@ test('同柜不可拆组HTTP验收锁定公开接口与防绕过矩阵', async ( '短拣完成拒绝后任务、父子树和组锁零漂移', '同柜组足量完成命令可靠受理', '足量完成整组已消费、全部入库完成且不生成余量子单', - '未成组普通单推送预装不回归', + '未成组普通单自动预装不回归', '未成组普通单仍按既有规则生成唯一余量子单', '拆分余量子单仍可与同仓同渠道订单创建同柜组', '拆分身份不影响当前WAIT_SPLIT片段完整纳组', @@ -42,4 +42,5 @@ test('同柜不可拆组HTTP验收锁定公开接口与防绕过矩阵', async ( assert.doesNotMatch(source, /跨仓库渠道或目的仓港创建整组被拒绝/) assert.doesNotMatch(source, /已有父子拆分订单创建同柜组被拒绝/) + assert.doesNotMatch(source, /push-preload/) }) diff --git a/scripts/admin-test-env-modules/coload-groups.mjs b/scripts/admin-test-env-modules/coload-groups.mjs index 5808234ef..fec970b37 100644 --- a/scripts/admin-test-env-modules/coload-groups.mjs +++ b/scripts/admin-test-env-modules/coload-groups.mjs @@ -99,10 +99,8 @@ async function createAssignedPushedPlan(context, fixture, groupId, suffix) { evidence(simulationResponse), OPTIONS) const bookingId = id(simulation?.id) if (!bookingId) return null - const planResponse = await context.request('POST', `${SMART}/plans`, { - bookingId, remark: `${context.marker}-${suffix}`, - }) - let plan = context.apiSuccess(MODULE, `${suffix} 创建组装柜计划`, planResponse, + const planResponse = await context.request('GET', `${SMART}/plans/by-booking/${encodeURIComponent(bookingId)}`) + let plan = context.apiSuccess(MODULE, `${suffix} 读取模拟柜自动创建的组装柜计划`, planResponse, evidence(planResponse), OPTIONS) const planId = id(plan?.id) if (!planId) return null @@ -112,10 +110,8 @@ async function createAssignedPushedPlan(context, fixture, groupId, suffix) { }) plan = context.apiSuccess(MODULE, `${suffix} 整组原子分配`, assignResponse, evidence(assignResponse, `groupId=${groupId}`), OPTIONS) - const pushResponse = await context.request('POST', `${SMART}/plans/${encodeURIComponent(planId)}/push-preload`, { - expectedVersion: Number(plan?.version), - }, undefined, { 'Idempotency-Key': `${context.marker}-${suffix}-push`.slice(0, 128) }) - const pushed = context.apiSuccess(MODULE, `${suffix} 整组完整推送预装`, pushResponse, + const pushResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(planId)}`) + const pushed = context.apiSuccess(MODULE, `${suffix} 整组分配后自动建立预装关联`, pushResponse, evidence(pushResponse), OPTIONS) return pushed ? { containerBookingId: bookingId, planId, plan: pushed, simulation } : null } @@ -129,10 +125,8 @@ async function createNormalPushedPlan(context, fixture, suffix) { evidence(simulationResponse), OPTIONS) const containerBookingId = id(simulation?.id) if (!containerBookingId) return null - const planResponse = await context.request('POST', `${SMART}/plans`, { - bookingId: containerBookingId, remark: `${context.marker}-${suffix}`, - }) - let plan = context.apiSuccess(MODULE, `${suffix} 创建普通单装柜计划`, planResponse, + const planResponse = await context.request('GET', `${SMART}/plans/by-booking/${encodeURIComponent(containerBookingId)}`) + let plan = context.apiSuccess(MODULE, `${suffix} 读取模拟柜自动创建的普通单装柜计划`, planResponse, evidence(planResponse), OPTIONS) const planId = id(plan?.id) if (!planId) return null @@ -149,10 +143,8 @@ async function createNormalPushedPlan(context, fixture, suffix) { context.assert(MODULE, `${suffix} 普通单维持既有部分分配语义`, assignResponse, assignedTotal === assignedCount && assignedCount < Number(fixture.packages), `assigned=${assignedTotal}, source=${fixture.packages}`, OPTIONS) - const pushResponse = await context.request('POST', `${SMART}/plans/${encodeURIComponent(planId)}/push-preload`, { - expectedVersion: Number(plan?.version), - }, undefined, { 'Idempotency-Key': `${context.marker}-${suffix}-push`.slice(0, 128) }) - const pushed = context.apiSuccess(MODULE, `${suffix} 未成组普通单推送预装不回归`, pushResponse, + const pushResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(planId)}`) + const pushed = context.apiSuccess(MODULE, `${suffix} 未成组普通单自动预装不回归`, pushResponse, evidence(pushResponse), OPTIONS) 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.context.orderProfile = { 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.orderFulfillmentType = 'STANDARD' @@ -510,10 +502,8 @@ export async function run(context) { evidence(simulationResponse), OPTIONS) let bookingId = id(simulation?.id) if (!bookingId) return - const planResponse = await context.request('POST', '/api/platform/v1/first-leg/smart-loading/plans', { - bookingId, remark: `${context.marker} 同柜不可拆组验收`, - }) - let plan = context.apiSuccess(MODULE, '创建容量拒绝专用空计划', planResponse, + const planResponse = await context.request('GET', `${SMART}/plans/by-booking/${encodeURIComponent(bookingId)}`) + let plan = context.apiSuccess(MODULE, '读取容量拒绝专用自动空计划', planResponse, evidence(planResponse, `bookingId=${bookingId}`), OPTIONS) let planId = id(plan?.id) if (!planId) return @@ -580,10 +570,8 @@ export async function run(context) { recommendSimulationResponse, evidence(recommendSimulationResponse), OPTIONS) const recommendBookingId = id(recommendSimulation?.id) if (!recommendBookingId) return - const recommendPlanResponse = await context.request('POST', `${SMART}/plans`, { - bookingId: recommendBookingId, remark: `${context.marker} 智能推荐整组全取`, - }) - let recommendPlan = context.apiSuccess(MODULE, '创建智能推荐整组成功专用计划', recommendPlanResponse, + const recommendPlanResponse = await context.request('GET', `${SMART}/plans/by-booking/${encodeURIComponent(recommendBookingId)}`) + let recommendPlan = context.apiSuccess(MODULE, '读取智能推荐整组成功专用自动计划', recommendPlanResponse, evidence(recommendPlanResponse), OPTIONS) const recommendPlanId = id(recommendPlan?.id) if (!recommendPlanId) return @@ -628,10 +616,8 @@ export async function run(context) { evidence(workingSimulationResponse), OPTIONS) bookingId = id(workingSimulation?.id) if (!bookingId) return - const workingPlanResponse = await context.request('POST', `${SMART}/plans`, { - bookingId, remark: `${context.marker} 同柜不可拆组PDA主链`, - }) - plan = context.apiSuccess(MODULE, '创建PDA主链同柜装柜计划', workingPlanResponse, + const workingPlanResponse = await context.request('GET', `${SMART}/plans/by-booking/${encodeURIComponent(bookingId)}`) + plan = context.apiSuccess(MODULE, '读取PDA主链自动创建的同柜装柜计划', workingPlanResponse, evidence(workingPlanResponse, `bookingId=${bookingId}`), OPTIONS) planId = id(plan?.id) if (!planId) return @@ -718,12 +704,8 @@ export async function run(context) { evidence(manualGroupResponse), OPTIONS) if (!plan) return - const pushKey = `${context.marker}-coload-push`.slice(0, 128) - const pushResponse = await context.request('POST', - `/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, + const pushResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(planId)}`) + const pushed = context.apiSuccess(MODULE, '整组完整时自动预装关联可读', pushResponse, evidence(pushResponse), OPTIONS) const pushedTotals = new Map() 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, evidence(uiSimulationResponse), OPTIONS) const uiBookingId = id(uiSimulation?.id) - const uiPlanResponse = uiBookingId ? await context.request('POST', `${SMART}/plans`, { - bookingId: uiBookingId, remark: `${context.marker} Admin整组候选人工验收保留`, - }) : null - const uiPlan = uiPlanResponse ? context.apiSuccess(MODULE, '为Admin页面保留未分配空计划', uiPlanResponse, + const uiPlanResponse = uiBookingId + ? await context.request('GET', `${SMART}/plans/by-booking/${encodeURIComponent(uiBookingId)}`) : null + const uiPlan = uiPlanResponse ? context.apiSuccess(MODULE, '为Admin页面保留自动创建的未分配空计划', uiPlanResponse, evidence(uiPlanResponse, `bookingId=${uiBookingId}`), OPTIONS) : null const uiMembers = memberFacts(uiGroup) context.assert(MODULE, '页面验收保留组仍为ACTIVE且未锁定', uiGroupResponse, diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs index 0f3a60065..8443cfcd4 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs @@ -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', () => { for (const status of ['WAIT_LOADING', 'EDITING', 'LOADING', 'COMPLETED', '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)) assert.match(source, /requiredStatuses/) 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, /context\.request\('POST', CONTAINERS/) 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') assert.ok(transportIndex >= 0 && pushIndex > transportIndex) 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) assert.ok(unconfirmedTaskIndex > pushIndex && confirmIndex > unconfirmedTaskIndex && canonicalTaskIndex > confirmIndex) + assert.doesNotMatch(source, /push-preload/) assert.match(source, /计划确认前不生成 PDA 任务/) assert.match(source, /summary\?\.readiness\?\.canConfirm === true/) 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.match(source, /linked\?\.cabinetNo !== simulation\.cabinetNo/) 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, /billOfLadingNo: `BL-\$\{value\}`/) 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/) }) -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, /form\.append\('file'/) 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, /clearancePath\}\/document\/upload/) 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\.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, /\{\s*fileId\s*:/) }) diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs index 7f63314c8..6390897a6 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs @@ -49,31 +49,31 @@ async function uploadDocument(context, path, expectedVersion, operationReason, l } async function ensureCompletionEvidence(context, chain, label) { - const tenantId = chain.source.tenantId - const inboundId = chain.source.inboundId + const taskId = id(chain.task?.id) + 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]) 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() form.append('file', new Blob([png], { type: 'image/png' }), `${label}-${evidenceType}.png`) 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), }) const uploadBody = await rawUpload.json() const upload = { method: 'POST', path: uploadPath, http: rawUpload.status, body: uploadBody, requestId: uploadBody?.requestId || rawUpload.headers.get('x-request-id'), traceId: uploadBody?.traceId || rawUpload.headers.get('x-trace-id') } - const file = context.apiSuccess(MODULE, `${label} 上传 ${evidenceType} 装柜证据`, upload, '', OPTIONS) - const fileId = id(file?.id || file?.fileId) - if (!fileId) throw new Error(`${label} ${evidenceType} 未返回文件ID`) - const bind = await post(context, - `/api/platform/v1/inbounds/${inboundId}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`, { - 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) + const evidence = context.apiSuccess(MODULE, `${label} 上传并绑定 ${evidenceType} 装柜证据`, upload, '', OPTIONS) + const active = evidence?.evidences?.find?.((item) => item.evidenceType === evidenceType + && item.status !== 'VOID') + if (!active?.evidenceId || id(evidence?.taskId) !== taskId) { + throw new Error(`${label} ${evidenceType} 未返回任务证据ID`) + } } } @@ -133,11 +133,14 @@ async function createConfirmedContainer(context, source, label) { const agentPage = context.apiSuccess(MODULE, `${label} 读取 ACTIVE 订舱代理`, agentResponse, '', OPTIONS) const agent = agentPage?.records?.find?.((item) => item?.id && item?.name) 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 = { customerId: source.tenantId, customerName: summary.customerName || 'IT 测试客户', channelCode: summary.channelCode, warehouseId: source.warehouseId, 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', vesselName: `人工船-${value}`.slice(0, 128), voyageNo: `V-${value}`.slice(0, 64), departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', @@ -161,8 +164,20 @@ async function createConfirmedContainer(context, source, label) { { expectedVersion: 0, data: draft }, `${label}-booking-create`) const created = context.apiSuccess(MODULE, `${label} 创建订舱`, createdResponse, '', OPTIONS) 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`, { - expectedVersion: Number(created?.oceanBooking?.version), reason: context.marker, + expectedVersion: releaseVersion, reason: context.marker, }, `${label}-booking-release`) const confirmed = context.apiSuccess(MODULE, `${label} 放舱订舱`, releasedResponse, '', OPTIONS) context.assert(MODULE, `${label} 订舱为 RELEASED 且不预生成真实柜`, releasedResponse, @@ -179,12 +194,13 @@ async function createConfirmedContainer(context, source, label) { throw new Error(`${label} 关联真实订舱后内部柜次身份漂移`) } context.apiSuccess(MODULE, `${label} 放舱后关联真实柜号`, linkedResponse, '', OPTIONS) - return containerId + return { containerId, containerNo } } async function createPlanTask(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', `${SMART}/candidates?bookingId=${containerId}&warehouseId=${source.warehouseId}`) const candidates = context.apiSuccess(MODULE, `${label} 查询同渠道候选货物`, candidateResponse, '', OPTIONS) @@ -193,18 +209,15 @@ async function createPlanTask(context, label) { : null if (!candidate?.inboundDetailId) throw new Error(`${label} 独立入库单未进入同渠道排柜候选`) - const planResponse = await context.request('POST', `${SMART}/plans`, - { bookingId: containerId, remark: `${context.marker}-${label}` }) - let plan = context.apiSuccess(MODULE, `${label} 创建智能排柜计划`, planResponse, '', OPTIONS) + const planResponse = await context.request('GET', `${SMART}/plans/by-booking/${containerId}`) + let plan = context.apiSuccess(MODULE, `${label} 读取模拟柜自动创建的智能排柜计划`, planResponse, '', OPTIONS) const assignResponse = await context.request('POST', `${SMART}/plans/${plan.id}/assignments`, { inboundId: source.inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: 1, expectedVersion: Number(plan.version), operationId: operationId(context, `${label}-assign`), }) plan = context.apiSuccess(MODULE, `${label} 分配独立候选货物`, assignResponse, '', OPTIONS) - const containerNo = iso6346ContainerNumber(`${context.marker}-manual-${label}`) - const pushResponse = await post(context, `${SMART}/plans/${plan.id}/push-preload`, - { expectedVersion: Number(plan.version) }, `${label}-push`) - plan = context.apiSuccess(MODULE, `${label} 补真实柜号后推送预装`, pushResponse, '', OPTIONS) + const pushResponse = await context.request('GET', `${SMART}/plans/${plan.id}`) + plan = context.apiSuccess(MODULE, `${label} 分配后自动建立预装关联`, pushResponse, '', OPTIONS) const planId = id(plan?.id) const unconfirmedTaskResponse = await context.request('GET', `${PDA}/tasks?loadingPlanId=${planId}&warehouseId=${source.warehouseId}`) @@ -291,6 +304,15 @@ async function complete(context, chain, label) { remark: `${context.marker} 人工完成状态样本`, }) 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 } @@ -354,39 +376,11 @@ async function completeSimpleDocuments(context, chain, matrix) { && Number(clearanceCompleted?.version) === Number(clearanceDocument?.version) + 1, `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', `/container-loading/${chain.containerId}/smart-loading`, { documentId: id(clearanceCompleted?.id), documentVersion: Number(clearanceCompleted?.version), 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) { @@ -399,13 +393,13 @@ export async function run(context, matrix = context.createdResources.manualState requiredActions: ['clearance'], fixtures: {}, actions: {} } matrix.pages.preloadPlans = { requiredStatuses: PRELOAD_STATUSES, fixtures: {} } matrix.pages.loadingLists = { requiredStatuses: ['COMPLETED'], - requiredActions: ['customsDeclaration'], fixtures: {}, actions: {} } + requiredActions: [], fixtures: {}, actions: {} } matrix.pages.pdaTasks = { requiredStatuses: PDA_STATUSES, fixtures: {} } const waitingSource = await readInbound(context, await prepareInbound(context, 'WAIT')) - const waitingContainerId = await createConfirmedContainer(context, waitingSource, 'WAIT') - matrix.pages.containerLoading.fixtures.WAIT_LOADING = fixture(waitingContainerId, 'WAIT_LOADING', - `/container-loading?containerId=${waitingContainerId}`) + const waitingContainer = await createConfirmedContainer(context, waitingSource, 'WAIT') + matrix.pages.containerLoading.fixtures.WAIT_LOADING = fixture(waitingContainer.containerId, 'WAIT_LOADING', + `/container-loading?containerId=${waitingContainer.containerId}`) const editing = await createPlanTask(context, 'EDIT') const inProgress = await claim(context, await createPlanTask(context, 'PROGRESS'), 'PROGRESS') diff --git a/scripts/admin-test-env-modules/master-data.mjs b/scripts/admin-test-env-modules/master-data.mjs index a62c7106d..bfe308b76 100644 --- a/scripts/admin-test-env-modules/master-data.mjs +++ b/scripts/admin-test-env-modules/master-data.mjs @@ -159,6 +159,7 @@ export async function run(context) { const partnerPayload = { partnerCode: `PT${suffix}`, partnerName: `Admin验收船公司-${suffix}`, partnerType: 'CARRIER', types: ['CARRIER'], + carrierCode: 'MATSON', contactName: '往来单位验收员', contactPhone: '13800000002', contactEmail: `partner-${suffix}@example.com`, countryCode: 'CN', province: '广东省', city: '深圳市', district: '南山区', addressLine: 'Admin API 往来单位地址', postalCode: '518000', status: 'ACTIVE', remark: context.marker, @@ -190,7 +191,7 @@ export async function run(context) { 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: [`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) { const response = await context.requestBinary('GET', path) diff --git a/scripts/admin-test-env-modules/ocean-bookings.mjs b/scripts/admin-test-env-modules/ocean-bookings.mjs index 14bada010..de5e35e5a 100644 --- a/scripts/admin-test-env-modules/ocean-bookings.mjs +++ b/scripts/admin-test-env-modules/ocean-bookings.mjs @@ -37,12 +37,16 @@ function transportSnapshot(value) { .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 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) { const master = await resolveOceanBookingMaster(context, MODULE) if (!master) return 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 agent = agentPage?.records?.find?.((item) => item?.category === 'BOOKING' && item?.status === 'ACTIVE') 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)}`) if (failed(complete)) return const frozen = transportSnapshot(completedDraft?.oceanBooking || data) - const documentForm = new FormData() - documentForm.append('expectedVersion', String(completeVersion)) - documentForm.append('documentType', 'BOOKING_CONFIRMATION') - documentForm.append('file', new Blob([`${context.marker}-BOOKING_CONFIRMATION`], { type: 'text/plain' }), - `booking-confirmation-${suffix}.txt`) - const documentResponse = await context.requestMultipart('POST', `${BASE}/${bookingId}/documents/upload`, - documentForm, context.token, { 'Idempotency-Key': `${context.marker}-ocean-booking-document`.slice(0, 128) }) - const document = context.apiSuccess(MODULE, '放舱前上传当前有效订舱确认文件', documentResponse, '', OPTIONS) - 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 form = new FormData() + form.append('expectedVersion', String(completeVersion)) + form.append('documentType', 'BILL_OF_LADING') + form.append('file', new Blob([minimalPdfBytes(`${context.marker}-BILL_OF_LADING`)], { type: 'application/pdf' }), `bill-${suffix}.pdf`) + const upload = await context.requestMultipart('POST', `${BASE}/${bookingId}/documents/upload`, form, + context.token, { 'Idempotency-Key': `${context.marker}-ocean-booking-file`.slice(0, 128) }) + const uploaded = context.apiSuccess(MODULE, '上传放舱所需有效提单文件', upload, '', OPTIONS) + if (!uploaded?.bookingVersion) return 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) }) const released = payload(finalRelease) step(context, '完整 DRAFT 放舱为 RELEASED 且冻结五项运输事实', finalRelease, diff --git a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs index 61ed5c50d..585dc0e69 100644 --- a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs @@ -88,7 +88,7 @@ function orderPayload(context, fixture, target) { destinationCountryCode: fixture.destination.countryCode || 'US', destinationWarehouseId: fixture.destination.id, destinationWarehouseName: fixture.destination.warehouseName, 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, channelCode: fixture.channel.channelCode, channelName: fixture.channel.channelName, 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', customsHsCode: '8518210000', packageType: 'CARTON', packageCount: 1, grossWeightKg: 10, 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, unitsPerPackage: 10, totalQuantity: 10, netWeightKg: 9, unitDeclaredValue: 10, material: '塑料', usage: '人工验收', model: `MODEL-${target}`, brand: 'IT-TEST' }], diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index 7b9f53a71..640947dd4 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -25,6 +25,23 @@ function rotate(values, offset) { return [...values.slice(shift), ...values.slice(0, shift)] } 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) { return JSON.stringify({ order: detail?.order ?? null, @@ -380,7 +397,7 @@ function buildPayload(context, profile, master, productImageFileId, customerAddr const selectedBoxes = BOXES.slice(0, skuCount) const orderProfile = context.context?.orderProfile || {} const isPrivate = master.destinationType === 'PRIVATE' - const pickupMethod = orderProfile.pickupMethod || 'PICKUP' + const pickupMethod = orderProfile.pickupMethod || 'SELF_EXPRESS' const receiver = isPrivate ? { companyName: `${companyName} 私人收件人`, contactName: `收件人-${context.marker}`.slice(0, 64), 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), `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.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.cargoRemark = payload.cargoes?.[0]?.cargoRemark || null context.createdResources.orders.processStatus = inbound.summary.processStatus diff --git a/scripts/admin-test-env-modules/overseas-storage-fees.mjs b/scripts/admin-test-env-modules/overseas-storage-fees.mjs index 007409b4f..62a76d58f 100644 --- a/scripts/admin-test-env-modules/overseas-storage-fees.mjs +++ b/scripts/admin-test-env-modules/overseas-storage-fees.mjs @@ -125,7 +125,6 @@ export async function run(context) { ['费用类型与计费单位不匹配', 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: 9 } : tier)], - ['非9档整组提交', initial.tiers.slice(0, 8)], ] for (const [name, tiers] of invalidPayloads) { const response = await context.request('PUT', activePath, { diff --git a/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs b/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs index 4f7bd0fa9..8ba9e6af8 100644 --- a/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs +++ b/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs @@ -39,9 +39,11 @@ async function raw(context, method, path, body, token, headers = {}) { async function loginMerchant(context) { 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', { - loginId: account.username, password: account.password, + username, password, }, '') const token = success(response) ? response.body?.data?.accessToken : null if (!token) throw new Error(`本轮客户登录失败: requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`) diff --git a/scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs b/scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs index e41aa2f9f..74cd40c71 100644 --- a/scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs @@ -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 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, [ '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', '/documents/${type}/upload', '/overview', 'requestMultipart', 'Idempotency-Key', ]) assert.ok(source.includes(token), token) + assert.doesNotMatch(source, /customs\/document|CUSTOMS_DECLARATION/) assert.doesNotMatch(source, /\/internal\/|https?:\/\/|process\.env|\bfetch\s*\(/) }) diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index de299f677..367b78a19 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -1,4 +1,5 @@ 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 { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs' import { iso6346ContainerNumber } from './iso6346-container-number.mjs' @@ -162,7 +163,7 @@ export async function run(context) { const realContainerNo = iso6346ContainerNumber(`${context.marker}-initial`) const wrongContainerNo = `TST-${suffix}-02`.slice(0, 64) 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 bookingAgent = records(agentPage).find((item) => item?.category === 'BOOKING' && item?.status === 'ACTIVE') context.assert(MODULE, '放舱订舱使用真实启用供应商', agentResponse, @@ -235,16 +236,15 @@ export async function run(context) { const foreignContainer = context.apiSuccess(MODULE, '新增真实异渠道模拟柜', foreignContainerResponse, '', OPTIONS) const foreignContainerId = id(foreignContainer?.id) if (!foreignContainerId) return - const foreignPlanResponse = await context.request('GET', + const foreignPlanResponse = await request(context, 'GET', `${SMART}/plans/by-booking/${encodeURIComponent(foreignContainerId)}`) - const foreignPlan = context.apiSuccess(MODULE, '读取异渠道柜次原子生成的智能装柜计划', - foreignPlanResponse, '', OPTIONS) + const foreignPlan = context.apiSuccess(MODULE, '读取真实异渠道柜自动创建的装柜计划', foreignPlanResponse, '', OPTIONS) const foreignAssignResponse = await request(context, 'POST', `${SMART}/plans/${foreignPlan?.id}/assignments`, { inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: 1, expectedVersion: Number(foreignPlan?.version), operationId: `${context.marker}-other-channel-assign`.slice(0, 128), }) 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) } @@ -258,8 +258,8 @@ export async function run(context) { `planStatus=${containerDetail?.planStatus || 'missing'}, channel=${containerDetail?.channelCode || 'missing'}`, OPTIONS) const containerNo = id(containerDetail?.containerNo || containerDetail?.cabinetNo) - const planResponse = await context.request('GET', `${SMART}/plans/by-booking/${encodeURIComponent(containerId)}`) - const plan = context.apiSuccess(MODULE, '读取柜次创建时原子生成的智能装柜计划', planResponse, '', OPTIONS) + const planResponse = await request(context, 'GET', `${SMART}/plans/by-booking/${encodeURIComponent(containerId)}`) + const plan = context.apiSuccess(MODULE, '读取货柜自动创建的智能装柜计划', planResponse, '', OPTIONS) const planId = id(plan?.id) context.assert(MODULE, '装柜计划与货柜关联且处于 EDITING', planResponse, 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) if (!planId) return - const planReplayResponse = await context.request('GET', `${SMART}/plans/by-booking/${encodeURIComponent(containerId)}`) - const planReplay = context.apiSuccess(MODULE, '重复读取货柜唯一装柜计划', planReplayResponse, '', OPTIONS) + const planReplayResponse = await request(context, 'GET', `${SMART}/plans/by-booking/${encodeURIComponent(containerId)}`) + const planReplay = context.apiSuccess(MODULE, '再次读取货柜唯一装柜计划', planReplayResponse, '', OPTIONS) context.assert(MODULE, '同一货柜始终返回同一计划', planReplayResponse, 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)), `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)}`) - context.apiSuccess(MODULE, '分配后预装清单可见', synchronizedDetailResponse, '', OPTIONS) - const synchronizedLinksResponse = await context.request('GET', `${SMART}/plans/${planId}/preloads`) - const synchronizedLinks = context.apiSuccess(MODULE, '分配同步建立 bridge', synchronizedLinksResponse, '', OPTIONS) - const synchronizedLink = Array.isArray(synchronizedLinks) - ? synchronizedLinks.find((item) => id(item.inboundId) === inboundId && id(item.loadingPlanId) === planId) : null - context.assert(MODULE, '分配自动 bridge 保持预装和柜次稳定归属', synchronizedLinksResponse, - Boolean(synchronizedLink?.id) && Number(synchronizedLink?.allocatedPackageCount) === 1, - `linkId=${id(synchronizedLink?.id) || 'missing'}, preloadPlanId=${id(synchronizedLink?.preloadPlanId) || 'missing'}`, OPTIONS) - if (!synchronizedLink?.id) return - const synchronizedPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`) - const synchronizedPlan = context.apiSuccess(MODULE, '读取分配事务完成态柜次计划', synchronizedPlanResponse, '', OPTIONS) - context.assert(MODULE, '事务完成态计划版本与 bridge 一致且保持 EDITING', synchronizedPlanResponse, - synchronizedPlan?.status === 'EDITING' && Number(synchronizedPlan?.version) === Number(synchronizedLink?.loadingPlanVersion), - `persistedVersion=${synchronizedPlan?.version ?? 'missing'}, bridgeVersion=${synchronizedLink?.loadingPlanVersion ?? 'missing'}`, OPTIONS) - if (synchronizedPlan?.status !== 'EDITING' || !Number.isInteger(Number(synchronizedPlan?.version))) return - const synchronizedTaskResponse = await context.request('GET', + const pushedDetail = context.apiSuccess(MODULE, '无号 push 后预装清单可见', pushedDetailResponse, '', OPTIONS) + const pushedLinksResponse = await context.request('GET', `${SMART}/plans/${planId}/preloads`) + const pushedLinks = context.apiSuccess(MODULE, '无号 push 自动建立 bridge', pushedLinksResponse, '', OPTIONS) + const pushedLink = Array.isArray(pushedLinks) + ? pushedLinks.find((item) => id(item.inboundId) === inboundId && id(item.loadingPlanId) === planId) : null + context.assert(MODULE, 'push 自动 bridge 保持预装和柜次稳定归属', pushedLinksResponse, + Boolean(pushedLink?.id) && Number(pushedLink?.allocatedPackageCount) === 1, + `linkId=${id(pushedLink?.id) || 'missing'}, preloadPlanId=${id(pushedLink?.preloadPlanId) || 'missing'}`, OPTIONS) + if (!pushedLink?.id) return + const postPushPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`) + const postPushPlan = context.apiSuccess(MODULE, '首次 push 后读取事务完成态柜次计划', postPushPlanResponse, '', OPTIONS) + context.assert(MODULE, '事务完成态计划版本与 bridge 一致且保持 EDITING', postPushPlanResponse, + postPushPlan?.status === 'EDITING' && Number(postPushPlan?.version) === Number(pushedLink?.loadingPlanVersion) + && Number(postPushPlan?.version) >= Number(assigned.version), + `pushResponseVersion=${pushed?.version ?? 'missing'}, persistedVersion=${postPushPlan?.version ?? 'missing'}, bridgeVersion=${pushedLink?.loadingPlanVersion ?? 'missing'}`, OPTIONS) + if (postPushPlan?.status !== 'EDITING' || !Number.isInteger(Number(postPushPlan?.version))) return + const pushedTaskResponse = await context.request('GET', `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`) - context.addStep(MODULE, '无真实柜号时 PDA 查询 A0404', synchronizedTaskResponse, - synchronizedTaskResponse.http === 200 && codeOf(synchronizedTaskResponse) === 'A0404', - responseEvidence(synchronizedTaskResponse), OPTIONS) + context.addStep(MODULE, '无号 push 成功后 PDA 查询 A0404', pushedTaskResponse, + pushedTaskResponse.http === 200 && codeOf(pushedTaskResponse) === 'A0404', + 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', `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`) diff --git a/scripts/admin-test-env-modules/phase3-customs.mjs b/scripts/admin-test-env-modules/phase3-customs.mjs index 403ed2cc8..28e5ffa0f 100644 --- a/scripts/admin-test-env-modules/phase3-customs.mjs +++ b/scripts/admin-test-env-modules/phase3-customs.mjs @@ -1,17 +1,26 @@ +import { customsCaseFieldGaps } from './fulfillment-list-field-contract.mjs' + const MODULE = 'phase3-customs' -const OPTIONS = { scenario: 'phase3-container-customs-producer', deploymentBlocker: true } -const PLATFORM_CUSTOMS = '/api/platform/v1/customs/loading-plans' +const OPTIONS = { scenario: 'phase3-customs-collaboration', deploymentBlocker: true } +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 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 { const response = await fetch(`${context.options.gateway}${path}`, { method, 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' }), ...headers, }, @@ -20,30 +29,46 @@ async function request(context, method, path, body, headers = {}) { }) const raw = await response.text(); let payload = null try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ } - return { - method, path, http: response.status, body: payload, raw: payload === null ? raw.slice(0, 500) : null, + return { method, path, http: response.status, body: payload, raw: payload === null ? raw.slice(0, 500) : null, 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) { 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 { 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()) + 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, - contentType: response.headers.get('content-type') || '', - requestId: response.headers.get('x-request-id'), traceId: response.headers.get('x-trace-id') } + body, contentType, requestId: body?.requestId || response.headers.get('x-request-id'), + traceId: response.headers.get('x-trace-id') } } catch (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) { const fixtures = [ createdResources?.orders, @@ -64,6 +89,7 @@ export function selectCustomsFlow(createdResources) { async function ensureReleasedOceanBooking(context, container) { const containerBookingId = id(container?.containerBookingId) + const loadingPlanId = id(container?.loadingPlanId) const oceanBookingId = id(container?.oceanBookingId) if (!oceanBookingId) { 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 detail = context.apiSuccess(MODULE, '报关前回读装柜链正式订舱', response, '', OPTIONS) const allocated = detail?.containers?.some?.((item) => id(item?.containerBookingId) === containerBookingId) - const passed = detail?.oceanBooking?.status === 'RELEASED' && allocated - context.addStep(MODULE, '报关前订舱已放舱并绑定实际装柜柜次', response, passed, - `oceanStatus=${detail?.oceanBooking?.status || 'missing'}, oceanBookingId=${oceanBookingId}, containerBookingId=${containerBookingId}, allocated=${allocated === true}`, OPTIONS) - if (passed) context.createdResources.oceanBooking = { - id: oceanBookingId, marker: context.marker, finalStatus: 'RELEASED', containerBookingId, - loadingPlanId: id(container?.loadingPlanId), + if (detail?.oceanBooking?.status !== 'RELEASED' || !allocated) { + context.addStep(MODULE, '报关前订舱已放舱并绑定实际装柜柜次', response, false, + `oceanStatus=${detail?.oceanBooking?.status || 'missing'}, oceanBookingId=${oceanBookingId}, containerBookingId=${containerBookingId || 'missing'}, allocated=${allocated === true}`, OPTIONS) + return false } - 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 overview = context.apiSuccess(MODULE, '只读复核前置模块已完成的整柜作业资料', overviewResponse, '', OPTIONS) + const overview = context.apiSuccess(MODULE, '四类资料完成后回查整柜聚合', overviewResponse, '', OPTIONS) const documents = Array.isArray(overview?.documents) ? overview.documents : [] const photos = Array.isArray(overview?.pda?.photos) ? overview.pda.photos.filter((item) => item?.status === 'ACTIVE') : [] - const passed = ['DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER'] - .every((type) => documents.some((item) => item?.documentType === type)) - && documents.some((item) => item?.documentType === 'DESTINATION_CLEARANCE' && item?.completed === true) - && photos.length >= 5 - context.assert(MODULE, '整柜聚合回显三类当前作业资料与真实PDA照片证据', overviewResponse, passed, + context.assert(MODULE, '整柜聚合回显四类当前资料与真实PDA照片证据', overviewResponse, + ['DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER'] + .every((type) => documents.some((item) => item?.documentType === type)) + && completed.every((item) => documents.some((value) => id(value?.id) === id(item?.id) && value?.completed === true)) + && 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) 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, response.http === 200 && response.bytes.length > 0 && !response.contentType.includes('application/json'), `fileId=${id(photo?.fileId) || 'missing'}, bytes=${response.bytes.length}`, OPTIONS) } - return passed -} - -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 + return true } export async function run(context) { const { fixture, container } = selectCustomsFlow(context.createdResources) const tenantId = id(fixture?.tenantId) + const parentOrderId = id(fixture?.orderId) const inboundId = id(fixture?.inboundId) const containerBookingId = id(container?.containerBookingId) 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) || (container?.finalStatus === 'AWAITING_REVIEW' && (container?.loadingPlanStatus !== 'LOADING' || !container?.canonicalPdaTaskId))) { - context.addStep(MODULE, '取得本轮客户、入仓单与柜次隔离fixture', null, false, - `tenantId=${tenantId || 'missing'}, inboundId=${inboundId || 'missing'}, containerBookingId=${containerBookingId || 'missing'}, loadingPlanId=${loadingPlanId || 'missing'}, containerStatus=${container?.finalStatus || 'missing'}`, - OPTIONS) + context.addStep(MODULE, '取得本轮客户/订单/入库单/柜次隔离 fixture', null, false, + `tenantId=${tenantId || 'missing'}, parentOrderId=${parentOrderId || 'missing'}, inboundId=${inboundId || 'missing'}, containerBookingId=${containerBookingId || 'missing'}, loadingPlanId=${loadingPlanId || 'missing'}, containerStatus=${container?.finalStatus || 'missing'}`, OPTIONS) return } if (!await ensureReleasedOceanBooking(context, container)) return - if (!await verifyContainerOperationalDocuments(context, containerBookingId)) { - context.addStep(MODULE, '三类整柜作业资料必须完成后才允许进入出仓确认', null, false, - `containerBookingId=${containerBookingId}, loadingPlanId=${loadingPlanId}`, OPTIONS) + + const currentPath = `/api/platform/v1/customs/loading-plans/${encodeURIComponent(loadingPlanId)}` + 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 } - const customs = await completeContainerCustoms(context, loadingPlanId) - if (!customs) { - context.addStep(MODULE, '正式整柜报关聚合必须完成后才允许进入出仓确认', null, false, - `containerBookingId=${containerBookingId}, loadingPlanId=${loadingPlanId}`, OPTIONS) + + const slicesPath = `/api/platform/v1/orders/container-bookings/${encodeURIComponent(containerBookingId)}/fulfillment-slices?tenantId=${encodeURIComponent(tenantId)}` + const slicesResponse = await context.request('GET', slicesPath) + 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 } + context.createdResources.phase3Customs = { - marker: context.marker, tenantId, orderId: id(fixture?.orderId), inboundId, - containerBookingId, loadingPlanId, customsContainerId: id(customs.aggregateId), - finalStatus: 'COMPLETED', + marker: context.marker, tenantId, parentOrderId, orderId: actualOrderId, inboundId, containerBookingId, loadingPlanId, + customsCaseId: caseId, submissionBatchId: batchId, releasedReceiptId: releasedId, + finalStatus: 'CLEARANCE_COMPLETED', } } diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index 3c6de68e9..12ee1f485 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -99,21 +99,6 @@ async function waitForCompletion(context, taskDetailPath, operationsPath, attemp 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) { const fixture = context.createdResources?.phase3Loading 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) 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 taskDetailPath = `${taskPath}?warehouseId=${encodeURIComponent(id(fixture.warehouseId))}` const taskBeforeResponse = await context.request('GET', taskDetailPath) @@ -315,16 +301,13 @@ export async function run(context) { Boolean(unifiedRow) && unifiedRow?.mainStatus === 'LOADED', `orderId=${orderId}, found=${Boolean(unifiedRow)}, mainStatus=${unifiedRow?.mainStatus || 'missing'}`, OPTIONS) - const evidenceBase = `${taskPath}/evidence` - const evidencePath = `${evidenceBase}?warehouseId=${encodeURIComponent(id(fixture.warehouseId))}` - const immutableEvidenceResult = await waitForEvidenceSnapshot( - context, evidencePath, fixture.evidenceTypes || []) - const immutableEvidenceResponse = immutableEvidenceResult.response + const evidencePath = `/api/platform/v1/first-leg/pda-loading/tasks/${encodeURIComponent(id(fixture.canonicalPdaTaskId))}/evidence?warehouseId=${encodeURIComponent(id(fixture.warehouseId))}` + const immutableEvidenceResponse = await context.request('GET', evidencePath) const immutableEvidence = context.apiSuccess(MODULE, '读取 LOADED 后不可变证据快照', immutableEvidenceResponse, '', OPTIONS) context.assert(MODULE, '统一硬门禁通过后五类有效证据仍完整', immutableEvidenceResponse, fixture.evidenceTypes?.every?.((type) => immutableEvidence?.evidences?.some?.((item) => 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 immutablePlan = context.apiSuccess(MODULE, '读取 LOADED 后装柜计划快照', immutablePlanResponse, '', OPTIONS) const finalVersion = Number(finalData?.summary?.version) @@ -358,17 +341,11 @@ export async function run(context) { && Boolean(completeSameKeyDifferentBody.requestId) && Boolean(completeSameKeyDifferentBody.traceId), `HTTP=${completeSameKeyDifferentBody.http}, code=${codeOf(completeSameKeyDifferentBody)}, requestId=${completeSameKeyDifferentBody.requestId || 'missing'}, traceId=${completeSameKeyDifferentBody.traceId || 'missing'}`, OPTIONS) - const postLoadedEvidenceForm = new FormData() - postLoadedEvidenceForm.append('file', new Blob([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, - ])], { 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 postLoadedForm = new FormData() + postLoadedForm.append('file', new Blob([`${context.marker}-after-loaded`], { type: 'text/plain' }), 'after-loaded.txt') 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) }) context.addStep(MODULE, 'LOADED 后新增装柜证据被拒绝', postLoadedEvidence, 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) 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), - 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, postLoadedVoid.http === 200 && codeOf(postLoadedVoid) === 'A0409' && Boolean(postLoadedVoid.requestId) && Boolean(postLoadedVoid.traceId), @@ -386,7 +364,7 @@ export async function run(context) { const immutableTransport = await request(context, 'PATCH', `/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` }, { 'Idempotency-Key': `${context.marker}-p3-transport-after-loaded`.slice(0, 128) }) context.addStep(MODULE, 'LOADED 后运输信息修改被拒绝', immutableTransport, diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index fbaed3f88..6b9438db6 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -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 { createHash } from 'node:crypto' @@ -5,7 +7,7 @@ const MODULE = 'phase3-loading' const OPTIONS = { scenario: 'phase3-split-preload-loading', deploymentBlocker: true } const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || '' 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' 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 pdaBase = '/api/platform/v1/first-leg/pda-loading' 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 supplierRecords = Array.isArray(supplierPage?.records) ? supplierPage.records : [] const supplier = await ensureMatsonSupplier(context, supplierRecords) @@ -212,9 +214,8 @@ export async function run(context) { if (!containerBookingId) return const candidateResponse = await context.request('GET', `${smartLoadingBase}/candidates?bookingId=${encodeURIComponent(containerBookingId)}&warehouseId=${encodeURIComponent(warehouseId)}`) const candidates = context.apiSuccess(MODULE, '查询本轮入库单柜次装柜候选', candidateResponse, '', OPTIONS) - const inboundCandidates = Array.isArray(candidates) ? candidates.filter((item) => id(item.inboundId) === inboundId - && Number(item.availablePackageCount) > 0) : [] - const candidate = inboundCandidates[0] + const candidate = Array.isArray(candidates) ? candidates.find((item) => id(item.inboundId) === inboundId + && Number(item.availablePackageCount) > 0) : null context.assert(MODULE, '柜次装柜候选与入库单、客户和仓库一致', candidateResponse, Boolean(candidate?.inboundDetailId) && id(candidate?.accountId) === tenantId && id(candidate?.warehouseId) === warehouseId, @@ -222,29 +223,21 @@ export async function run(context) { if (!candidate?.inboundDetailId || !warehouseId) return - const loadingPlanResponse = await context.request('GET', + const loadingPlanResponse = await request(context, 'GET', `${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) if (!loadingPlanId) return - const dropshipAssignment = source?.fulfillmentType === 'ONE_CLICK_DROPSHIP' - const initialAssignmentCount = dropshipAssignment - ? inboundCandidates.reduce((total, item) => total + Number(item.availablePackageCount || 0), 0) : 1 - const assignmentPath = dropshipAssignment - ? `${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 assignmentResponse = await request(context, 'POST', `${smartLoadingBase}/plans/${loadingPlanId}/assignments`, { + inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: 1, + expectedVersion: Number(loadingPlan.version), + operationId: `${context.marker}-${loadingPlanId}-bridge-assign`.slice(0, 128), + }) const assignedLoadingPlan = context.apiSuccess(MODULE, '将本轮入库货物分配到柜次计划', assignmentResponse, '', OPTIONS) context.assert(MODULE, '柜次计划明细和版本真实持久化', assignmentResponse, - assignedLoadingPlan?.items?.filter?.((item) => id(item.inboundId) === inboundId) - .reduce((total, item) => total + Number(item.packageCount || 0), 0) === initialAssignmentCount - && Number(assignedLoadingPlan?.version) >= 1, - `loadingPlanId=${loadingPlanId}, assignedPackages=${initialAssignmentCount}, version=${assignedLoadingPlan?.version ?? 'missing'}`, OPTIONS) + assignedLoadingPlan?.items?.some?.((item) => id(item.inboundId) === inboundId && Number(item.packageCount) === 1) + && Number(assignedLoadingPlan?.version) === 1, + `loadingPlanId=${loadingPlanId}, version=${assignedLoadingPlan?.version ?? 'missing'}`, OPTIONS) const assignedItem = assignedLoadingPlan?.items?.find?.((item) => id(item.inboundId) === inboundId) const orderRemarks = source || {} @@ -317,37 +310,113 @@ export async function run(context) { const pendingPreloadResponse = await context.request('GET', `${base}/preload?tenantId=${encodeURIComponent(tenantId)}`) const pendingPreload = context.apiSuccess(MODULE, '智能排货自动建立 PENDING 预装聚合', pendingPreloadResponse, '', OPTIONS) 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)), `preloadPlanId=${planId || 'missing'}, status=${pendingPreload?.status || 'missing'}`, OPTIONS) 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)}`) - const synchronizedDetail = context.apiSuccess(MODULE, '分配后预装清单正式可见', synchronizedDetailResponse, '', OPTIONS) - const synchronizedLinksResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}/preloads`) - const synchronizedLinks = context.apiSuccess(MODULE, '分配同步建立 bridge 关联', synchronizedLinksResponse, '', OPTIONS) - const link = Array.isArray(synchronizedLinks) ? synchronizedLinks.find((item) => id(item.inboundId) === inboundId) : null - context.assert(MODULE, '分配自动建立唯一预装关联且身份稳定', synchronizedLinksResponse, + const pushedDetail = context.apiSuccess(MODULE, '无号推送后预装清单正式可见', pushedDetailResponse, '', OPTIONS) + const pushedHistories = visibleLoadingHistories(pushedDetail?.histories) + context.assert(MODULE, '自动预装不保留旧 PUSH_PRELOAD 历史且无纯状态 no-op', pushedDetailResponse, + pushedHistories.filter((item) => item.actionType === 'PUSH_PRELOAD').length === 0 + && !(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 - && Number(link?.allocatedPackageCount) === initialAssignmentCount, - `linkId=${id(link?.id) || 'missing'}, preloadPlanId=${id(link?.preloadPlanId) || 'missing'}, allocatedPackages=${link?.allocatedPackageCount ?? 'missing'}, expectedPackages=${initialAssignmentCount}`, OPTIONS) + && Number(link?.allocatedPackageCount) === 1, + `linkId=${id(link?.id) || 'missing'}, preloadPlanId=${id(link?.preloadPlanId) || 'missing'}`, OPTIONS) if (!link?.id) return - const synchronizedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) - const synchronizedPlan = context.apiSuccess(MODULE, '读取分配事务完成态柜次计划', synchronizedPlanResponse, '', OPTIONS) - context.assert(MODULE, '分配事务完成态计划与 bridge 一致且保持 EDITING', synchronizedPlanResponse, - synchronizedPlan?.status === 'EDITING' && Number(synchronizedPlan?.version) === Number(link?.loadingPlanVersion) - && Number(synchronizedPlan?.version) === Number(persistedRemarkPlan.version), - `persistedVersion=${synchronizedPlan?.version ?? 'missing'}, bridgeVersion=${link?.loadingPlanVersion ?? 'missing'}`, OPTIONS) - if (synchronizedPlan?.status !== 'EDITING' || !Number.isInteger(Number(synchronizedPlan?.version))) return + const postPushPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) + const postPushPlan = context.apiSuccess(MODULE, '首次 push 后读取事务完成态柜次计划', postPushPlanResponse, '', OPTIONS) + context.assert(MODULE, '事务完成态计划版本与 bridge 一致且保持 EDITING', postPushPlanResponse, + postPushPlan?.status === 'EDITING' && Number(postPushPlan?.version) === Number(link?.loadingPlanVersion) + && Number(postPushPlan?.version) > Number(assignedLoadingPlan.version), + `pushResponseVersion=${pushedLoadingPlan?.version ?? 'missing'}, persistedVersion=${postPushPlan?.version ?? 'missing'}, bridgeVersion=${link?.loadingPlanVersion ?? 'missing'}`, OPTIONS) + if (postPushPlan?.status !== 'EDITING' || !Number.isInteger(Number(postPushPlan?.version))) return const missingContainerTaskResponse = await context.request('GET', `${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`) const missingContainerTaskAbsent = missingContainerTaskResponse.http === 200 && codeOf(missingContainerTaskResponse) === 'A0404' - context.addStep(MODULE, '无真实柜号时 PDA 查询明确 A0404', missingContainerTaskResponse, + context.addStep(MODULE, '无号 push 成功后 PDA 查询明确 A0404', missingContainerTaskResponse, missingContainerTaskAbsent, `HTTP=${missingContainerTaskResponse.http}, code=${codeOf(missingContainerTaskResponse)}, requestId=${missingContainerTaskResponse.requestId || 'missing'}, traceId=${missingContainerTaskResponse.traceId || 'missing'}`, OPTIONS) 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', `${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`) context.addStep(MODULE, '无号且未关联订舱仍无 PDA 任务', unlinkedTaskResponse, @@ -506,15 +575,12 @@ export async function run(context) { const canonicalTaskId = id(canonicalTask?.id) const taskDetailPath = `${pdaBase}/tasks/${canonicalTaskId}?warehouseId=${encodeURIComponent(warehouseId)}` 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, Boolean(canonicalTaskId) && canonicalTaskId === confirmedTaskId && id(canonicalTask?.loadingPlanId) === loadingPlanId && id(canonicalTask?.containerBookingId) === containerBookingId - && canonicalTask?.workflowStatus === 'UNASSIGNED' && canonicalPickItems.length > 0, - `taskId=${canonicalTaskId || 'missing'}, itemCount=${canonicalPickItems.length}`, OPTIONS) - if (!canonicalTaskId || !canonicalPickItems.length) return + && canonicalTask?.workflowStatus === 'UNASSIGNED' && Boolean(canonicalItem?.id), + `taskId=${canonicalTaskId || 'missing'}, itemId=${id(canonicalItem?.id) || 'missing'}`, OPTIONS) + if (!canonicalTaskId || !canonicalItem?.id) return const beforeClaimPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) 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 partialBatchKey = `${context.marker}-exception-partial-batch`.slice(0, 128) const dynamicBatchKey = `${context.marker}-dynamic-pick`.slice(0, 128) - const dropshipWholePallet = source?.fulfillmentType === 'ONE_CLICK_DROPSHIP' - const expectedBatchOperationIds = dropshipWholePallet - ? [batchKey] - : [batchKey, skipBatchKey, partialBatchKey, dynamicBatchKey] + const expectedBatchOperationIds = [batchKey, skipBatchKey, partialBatchKey, dynamicBatchKey] const batchPayload = { warehouseId, loadingPlanId, containerBookingId, expectedVersion: Number(claimed?.version), inventorySnapshotToken: inventory.snapshotToken, - items: canonicalPickItems.map((item) => ({ - itemId: id(item.id), action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount), - releaseMode: 'MANUAL', locationIds: [], version: Number(item.version), - })), + items: [{ + itemId: id(canonicalItem.id), action: 'PICK', pickedPackageCount: 1, + releaseMode: 'MANUAL', locationIds: [], version: Number(canonicalItem.version), + }], } 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) 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' ? merchantLogin.body?.data?.accessToken : null @@ -760,15 +823,12 @@ export async function run(context) { if (refreshedInventory?.snapshotToken) { effectiveBatchPayload = { ...batchPayload, inventorySnapshotToken: refreshedInventory.snapshotToken } } - } else if (context.context?.phase3LoadingRequireSharedOverflow === true) { + } else { context.addStep(MODULE, '自动制造库存变化并验证旧 snapshot token A0409', null, false, `本轮 ${inventoryInbound?.resources?.length ?? 0} 条占用没有可安全换绑的 A0 临时占用;不得以 coverageGap 作为通过`, OPTIONS) context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 真实库存变化后的旧快照冲突与刷新重试', `inventory-snapshot 已真实返回 taskId=${canonicalTaskId}、warehouseId=${warehouseId} 并验证稳定 token/version=${inventorySnapshotVerified};本轮 ${inventoryInbound?.resources?.length ?? 0} 条占用均为普通库位,当前唯一平台代理库存变更入口只允许 A0 临时占用换绑,不能安全改动普通占用或调用内部 Warehouse 接口`, 'fixture') - } else { - context.addStep(MODULE, '附加生产者不重复要求 A0 库存快照变更专项', null, true, - '主三期装柜链已独立强制验证;当前变体仅生成四期跨区生产者', OPTIONS) } const canonicalPick = await request(context, 'POST', batchPath, effectiveBatchPayload, @@ -1106,10 +1166,7 @@ export async function run(context) { let skipBatchVerified = false let partialBatchVerified = false let dynamicIncrementVerified = false - if (dropshipWholePallet) { - context.addStep(MODULE, '一件代发整托生产者不执行可拆分动态增量场景', null, true, - '逐箱封托事实必须整托守恒;SKIP、PARTIAL 和动态增删改由 STANDARD 生产者覆盖', OPTIONS) - } else if (!incrementalInboundId || incrementalFixture?.marker !== context.marker || incrementalInboundId === inboundId) { + if (!incrementalInboundId || incrementalFixture?.marker !== context.marker || incrementalInboundId === inboundId) { context.addStep(MODULE, '取得本轮独立动态增量入库单', null, false, `inboundId=${incrementalInboundId || 'missing'}, primaryInboundId=${inboundId}, marker=${incrementalFixture?.marker || 'missing'}`, OPTIONS) } else { @@ -1423,9 +1480,9 @@ export async function run(context) { cartonMarks: stableLoadedCartonMarks(stagedTask?.items, loadingPlanId), fulfillmentType: source?.fulfillmentType || 'STANDARD', 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 : []), - ...(!dropshipWholePallet && Array.isArray(incrementalFixture?.skuLines) ? incrementalFixture.skuLines : [])], + ...(Array.isArray(incrementalFixture?.skuLines) ? incrementalFixture.skuLines : [])], destinationType: initial?.summary?.destinationType || source?.destinationType || null, destinationWarehouseId: id(initial?.summary?.destinationWarehouseId || source?.destinationWarehouseId), destinationWarehouseCode: initial?.summary?.destinationCode || source?.destinationWarehouseCode || null, diff --git a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs index 04278288b..89430670a 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs @@ -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, /id\(item\?\.containerBookingId\) === containerId/) 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=/) }) @@ -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', () => { - 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 releaseIndex = source.indexOf("`/api/platform/v1/ocean-bookings/${oceanBookingId}/release`") 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\?\.containerBookingId\) === containerId/) assert.doesNotMatch(source, /\$\{PDA\}\/tasks\?containerNo=/) + assert.doesNotMatch(source, /push-preload/) }) test('each remainder settles only through inventory snapshot, atomic batch and explicit complete', () => { diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index b9fa0ed49..a357c0e13 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -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]) 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() form.append('file', new Blob([png], { type: 'image/png' }), `${keyBase}-${evidenceType}.png`) 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), }) const uploadBody = await rawUpload.json() const upload = { method: 'POST', path: uploadPath, http: rawUpload.status, body: uploadBody, requestId: uploadBody?.requestId || rawUpload.headers.get('x-request-id'), traceId: uploadBody?.traceId || rawUpload.headers.get('x-trace-id') } - const file = context.apiSuccess(MODULE, `${keyBase} 上传 ${evidenceType} 装柜证据`, upload, '', OPTIONS) - const fileId = id(file?.id || file?.fileId) - if (!fileId) return false - const bind = await request(context, 'POST', - `/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 + const evidence = context.apiSuccess(MODULE, `${keyBase} 上传并绑定 ${evidenceType} 装柜证据`, upload, '', OPTIONS) + const active = evidence?.evidences?.find?.((item) => item.evidenceType === evidenceType + && item.status !== 'VOID') + if (!active?.evidenceId || id(evidence?.taskId) !== taskId) return false } return true } @@ -165,13 +162,13 @@ function snapshotMatches(tree, original, loaded, remaining, activeSuffix) { && 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) return oceanBookingDraft({ marker }, suffix, { customerId: source.tenantId, customerName: source.customerName, channelCode: source.channelCode, warehouseId: source.warehouseId, warehouseName: source.warehouseName, }, { 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, 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 agent = agentPage?.records?.find?.((item) => item?.id && item?.name) 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, { channelCode: state.channelCode, warehouseId, containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, }) @@ -192,14 +192,26 @@ async function loadOneFragment(context, state) { if (!containerId) return null context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(containerId) 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` }) const bookingCreated = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 创建渠道仓库一致的订舱`, bookingCreate, '', OPTIONS) const oceanBookingId = id(bookingCreated?.oceanBooking?.id) if (!oceanBookingId) return null 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`, { - expectedVersion: Number(bookingCreated?.oceanBooking?.version), reason: context.marker, + expectedVersion: releaseVersion, reason: context.marker, }, { 'Idempotency-Key': `${operationBase}-booking-release`.slice(0, 128) }) const bookingConfirmed = context.apiSuccess(MODULE, `放舱 ${sourceOrderNo} 的订舱`, releaseResponse, '', OPTIONS) context.assert(MODULE, `${sourceOrderNo} 订舱为 RELEASED 且不预生成真实柜`, releaseResponse, @@ -230,8 +242,8 @@ async function loadOneFragment(context, state) { `orderId=${sourceOrderId}, available=${candidate?.availablePackageCount ?? 'missing'}`, OPTIONS) if (!candidate?.inboundId || !candidate?.inboundDetailId) return null - const createPlan = await request(context, 'POST', `${SMART}/plans`, { bookingId: containerId, remark: context.marker }) - const plan = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 创建装柜计划`, createPlan, '', OPTIONS) + const createPlan = await request(context, 'GET', `${SMART}/plans/by-booking/${containerId}`) + const plan = context.apiSuccess(MODULE, `读取 ${sourceOrderNo} 模拟柜自动创建的装柜计划`, createPlan, '', OPTIONS) const planId = id(plan?.id) if (!planId) return null context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(planId) @@ -289,11 +301,11 @@ async function loadOneFragment(context, state) { `status=${bookingDetail?.oceanBooking?.status || 'missing'}, containerBookingId=${containerId}`, OPTIONS) if (bookingDetail?.oceanBooking?.status !== 'RELEASED' || !releasedAllocationMatches) return null - const push = await request(context, 'POST', `${SMART}/plans/${planId}/push-preload`, - { expectedVersion: Number(assigned.version) }, { 'Idempotency-Key': `${operationBase}-push` }) - const pushed = context.apiSuccess(MODULE, `推送 ${sourceOrderNo} 的预装计划`, push, '', OPTIONS) - context.assert(MODULE, `${sourceOrderNo} 补真实柜号后计划保持 EDITING`, push, - pushed?.status === 'EDITING', `status=${pushed?.status || 'missing'}`, OPTIONS) + const push = await request(context, 'GET', `${SMART}/plans/${planId}`) + const pushed = context.apiSuccess(MODULE, `读取 ${sourceOrderNo} 自动关联的预装计划`, push, '', OPTIONS) + context.assert(MODULE, `${sourceOrderNo} 补真实柜号后自动预装计划保持 EDITING`, push, + pushed?.status === 'EDITING' && Number(pushed?.version) === Number(assigned.version), + `status=${pushed?.status || 'missing'}, version=${pushed?.version ?? 'missing'}`, OPTIONS) if (pushed?.status !== 'EDITING') return null 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) } - 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 completePayload = { @@ -424,8 +436,18 @@ async function loadOneFragment(context, state) { remark: context.marker, } const complete = await request(context, 'POST', completePath, completePayload) - const completed = context.apiSuccess(MODULE, `人工确认 ${sourceOrderNo} 装柜完成并结算余量`, complete, '', OPTIONS) - context.assert(MODULE, `${sourceOrderNo} 只在 complete 后进入完成态`, complete, + let completed = context.apiSuccess(MODULE, `人工确认 ${sourceOrderNo} 装柜完成并结算余量`, complete, '', OPTIONS) + 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?.items?.some?.((row) => id(row.id) === id(claimedItem.id) && Number(row.pickedPackageCount) === 1 && Number(row.plannedPackageCount) === 1), diff --git a/scripts/admin-test-env-modules/phase3-port-schedules.mjs b/scripts/admin-test-env-modules/phase3-port-schedules.mjs index 4903c81b4..e6c5ccd86 100644 --- a/scripts/admin-test-env-modules/phase3-port-schedules.mjs +++ b/scripts/admin-test-env-modules/phase3-port-schedules.mjs @@ -212,13 +212,12 @@ export async function run(context) { const inboundId = id(flow?.inboundId) if (knownAllocation && inboundId) { - const expiredGateResponse = await request(context, 'GET', - `${OUTBOUND}/${encodeURIComponent(knownAllocation)}/gate`) - const expiredGate = context.apiSuccess(MODULE, '过期船期后读取当前国内出仓统一门禁', expiredGateResponse, '', OPTIONS) + const expiredGateResponse = await request(context, 'GET', `${OUTBOUND}/${encodeURIComponent(containerBookingId)}/gate`) + const expiredGate = context.apiSuccess(MODULE, '过期船期接入国内出仓统一门禁', expiredGateResponse, '', OPTIONS) const gateItems = (expiredGate?.inbounds || []).flatMap((item) => item?.gate?.items || []) - context.assert(MODULE, '船期不再作为国内出仓硬门禁', expiredGateResponse, - Array.isArray(expiredGate?.inbounds) && gateItems.every((item) => item?.code !== 'SAILING_SCHEDULE'), - `inbounds=${expiredGate?.inbounds?.length ?? 'missing'}, sailingGates=${gateItems.filter((item) => item?.code === 'SAILING_SCHEDULE').length}`, OPTIONS) + context.assert(MODULE, '船期独立于当前国内离仓六项门禁', expiredGateResponse, + Array.isArray(expiredGate?.inbounds) && !gateItems.some((item) => item?.code === 'SAILING_SCHEDULE'), + `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) @@ -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) if (knownAllocation && inboundId) { - const readyGateResponse = await request(context, 'GET', - `${OUTBOUND}/${encodeURIComponent(knownAllocation)}/gate`) - const readyGate = context.apiSuccess(MODULE, '有效船期后重新读取国内出仓统一门禁', readyGateResponse, '', OPTIONS) + const readyGateResponse = await request(context, 'GET', `${OUTBOUND}/${encodeURIComponent(containerBookingId)}/gate`) + const readyGate = context.apiSuccess(MODULE, '有效船期重新检查国内出仓统一门禁', readyGateResponse, '', OPTIONS) const gateItems = (readyGate?.inbounds || []).flatMap((item) => item?.gate?.items || []) - context.assert(MODULE, '船期有效性不改变国内出仓硬门禁集合', readyGateResponse, - Array.isArray(readyGate?.inbounds) && gateItems.every((item) => item?.code !== 'SAILING_SCHEDULE'), - `inbounds=${readyGate?.inbounds?.length ?? 'missing'}, sailingGates=${gateItems.filter((item) => item?.code === 'SAILING_SCHEDULE').length}`, OPTIONS) + context.assert(MODULE, '有效船期不改变当前国内离仓六项门禁集合', readyGateResponse, + Array.isArray(readyGate?.inbounds) && !gateItems.some((item) => item?.code === 'SAILING_SCHEDULE'), + `inbounds=${readyGate?.inbounds?.length ?? 'missing'}, obsoleteScheduleGate=${gateItems.some((item) => item?.code === 'SAILING_SCHEDULE')}`, OPTIONS) } const forged = await request(context, 'POST', BASE, { diff --git a/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs b/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs index b053edf74..d2e5c4e48 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs @@ -64,7 +64,7 @@ function warehousePayload(marker, edited = false) { countryCode: 'US', timezone: 'UTC-8', contactName: 'Phase4 QA', - contactPhone: '+1-213-555-0199', + contactPhone: '12135550199', contactEmail: null, status: 'ACTIVE', 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', fuelConsumptionPer100Km: edited ? 18.75 : 18.5, mileageKm: edited ? 101 : 100, - driverOrDispatchCompany: `${context.marker} Carrier`, phone: '+1-213-555-0188', + driverOrDispatchCompany: `${context.marker} Carrier`, phone: '12135550188', region: 'OVERSEAS', ownership: 'OWNED', ...(edited ? {} : { status: 'IN_SERVICE' }), remark: edited ? `${context.marker} lifecycle edited` : `${context.marker} lifecycle`, diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs index 7dc90a1b3..f8652e492 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs @@ -41,6 +41,12 @@ async function overseasMutation(context, method, name, path, body) { context.addStep(MODULE, name, response, passed, trace(response), OPTIONS) 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) { const response = await context.requestOverseasBinary('GET', path) context.addStep(MODULE, name, response, @@ -309,9 +315,9 @@ async function verifyRelabelLifecycle(context, customerId, warehouseId) { async function createWarehouseFixture(context, customerId) { const suffix = String(context.marker || '').replace(/[^A-Za-z0-9]/g, '').slice(-20).toUpperCase() 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', - contactName: 'Phase4 QA', contactPhone: '+1-213-555-0188', contactEmail: null, + contactName: 'Phase4 QA', contactPhone: '12135550188', contactEmail: null, status: 'ACTIVE', remark: `${context.marker}-warehouse-fixture`, address: { addressType: 'WAREHOUSE', countryCode: 'US', stateProvince: 'California', city: 'Los Angeles', 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, 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) context.assert(MODULE, '客户海外仓创建返回长ID', created.response, longId(warehouseId), `warehouseId=${warehouseId || 'missing'}, customerId=${customerId}; ${trace(created.response)}`, OPTIONS) if (!warehouseId) return null - const detail = await overseasGet(context, '查询本轮客户海外仓详情', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}`, { customerId })) - context.assert(MODULE, '本轮客户海外仓归属和状态正确', detail.response, + const detail = await domesticGet(context, '查询本轮公共海外仓详情', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}`, { tenantId: 0 })) + context.assert(MODULE, '本轮公共海外仓类型和状态正确', detail.response, 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', - `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' } } 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, 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) { - const candidates = [...new Set([preferredCustomerId, ...tenants.map((item) => id(item?.id)).filter(longId)])] - let lastResponse = null - 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, `客户${customerId}可用海外仓`, result) - lastResponse = result.response - const warehouse = records(result.data).find((item) => longId(item?.id) && item?.status === 'ACTIVE') - if (warehouse) { - context.assert(MODULE, '海外仓关键字段正确', result.response, - nonBlank(warehouse.warehouseCode) && nonBlank(warehouse.warehouseName), - `warehouseId=${id(warehouse.id)}, code=${warehouse.warehouseCode || 'missing'}; ${trace(result.response)}`, OPTIONS) - const detail = await overseasGet(context, '查询海外仓详情', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(id(warehouse.id))}`, { customerId })) - context.assert(MODULE, '海外仓详情对应列表记录', detail.response, - 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) - return warehouse - } + const result = await domesticGet(context, '查询可用公共海外仓', query('/api/platform/v1/warehouse-admin', { + tenantId: 0, warehouseType: 'OVERSEAS', page: 1, size: 100, status: 'ACTIVE', + })) + assertPage(context, '可用公共海外仓', result) + const warehouse = records(result.data).find((item) => longId(item?.id) + && item?.status === 'ACTIVE' && item?.warehouseType === 'OVERSEAS') + if (warehouse) { + context.assert(MODULE, '海外仓关键字段正确', result.response, + nonBlank(warehouse.warehouseCode) && nonBlank(warehouse.warehouseName), + `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 })) + context.assert(MODULE, '海外仓详情对应列表记录', detail.response, + 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) + return warehouse } return null } diff --git a/scripts/admin-test-env-modules/service-center-flow.mjs b/scripts/admin-test-env-modules/service-center-flow.mjs index f5270e288..1702f0d5f 100644 --- a/scripts/admin-test-env-modules/service-center-flow.mjs +++ b/scripts/admin-test-env-modules/service-center-flow.mjs @@ -4,9 +4,7 @@ const OCEAN = '/api/platform/v1/ocean-bookings' const TRUCK = '/api/platform/v1/truck-appointments' function id(value) { return value == null ? '' : String(value) } -const FORBIDDEN_TRUCK_FIELDS = ['customerId', 'customerName', 'containerBookingId', 'loadingPlanId', - 'cabinetNo', 'containerNo'] -function bookingLevel(value) { return value != null && FORBIDDEN_TRUCK_FIELDS.every((field) => !Object.hasOwn(value, field)) } +function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } async function actionRequest(context, path, endpoint, version) { 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) { const containerFlow = context.createdResources?.phase3Loading || context.createdResources?.phase3ContainerLoading @@ -47,11 +60,10 @@ export async function run(context) { const suffix = context.marker.replace(/\D/g, '').slice(-10) const oceanResponse = await context.request('GET', `${OCEAN}/${ocean.id}`) const oceanDetail = context.apiSuccess(MODULE, '读取跨服务链路已放舱订舱', oceanResponse, `oceanBookingId=${ocean.id}`, OPTIONS) - const allocationMatches = oceanDetail?.containers?.some?.((item) => - id(item?.containerBookingId) === id(containerFlow.containerBookingId)) - context.assert(MODULE, '已放舱订舱保留本轮柜次分配', oceanResponse, allocationMatches, - `containerBookingId=${id(containerFlow.containerBookingId)}, containers=${oceanDetail?.containers?.length ?? 'n/a'}`, OPTIONS) - if (!allocationMatches) return + const booking = oceanDetail?.oceanBooking || oceanDetail + context.assert(MODULE, '已放舱订舱可直接创建订舱级拖车预约', oceanResponse, + id(booking?.id) === id(ocean.id) && booking?.status === 'RELEASED', + `oceanBookingId=${id(booking?.id) || 'missing'}, status=${booking?.status || 'missing'}`, OPTIONS) const carrierResponse = await context.request('GET', '/api/platform/v1/suppliers?tenantId=0&category=TRUCKING&status=ACTIVE&page=1&size=20') 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) if (!carrier) return 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, - billOfLadingNo: oceanDetail?.oceanBooking?.billOfLadingNo, fleetContact: '链路车队联系人', fleetPhone: '13800000007', driverName: '链路司机', driverPhone: '13800000008', driverIdCard: null, vehicleNo: `粤D-${suffix.slice(-6)}`, storeName: `链路门店-${suffix}`, storeContact: '链路联系人', 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, 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 appointment = context.apiSuccess(MODULE, '创建订舱级跨服务拖车预约', response, `oceanBookingId=${ocean.id}`, OPTIONS) + const appointment = context.apiSuccess(MODULE, '创建关联海运订舱的拖车预约', response, `oceanBookingId=${ocean.id}`, OPTIONS) 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)) - && bookingLevel(appointment) && Boolean(appointment?.createdAt || appointment?.createdBy || appointment?.operatorName), - `appointmentId=${appointmentId || 'missing'}, oceanBookingId=${appointment?.oceanBookingId || 'missing'}, version=${appointment?.version ?? 'missing'}`, OPTIONS) + && !id(appointment?.containerBookingId) && !id(appointment?.loadingPlanId) + && 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 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) for (const [name, endpoint, status] of [ ['提交跨链路预约', 'submit', 'PENDING_DISPATCH'], @@ -103,8 +120,8 @@ export async function run(context) { const final = context.apiSuccess(MODULE, '回查跨服务链路安全终态', detail, '', OPTIONS) context.assert(MODULE, '跨服务链路终态持久化且关联订舱未漂移', detail, final?.status === 'COMPLETED' && id(final?.oceanBookingId) === id(ocean.id) - && bookingLevel(final), - `status=${final?.status || 'missing'}, oceanBookingId=${final?.oceanBookingId || 'missing'}`, OPTIONS) + && !id(final?.containerBookingId) && !id(final?.loadingPlanId), + `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 history = context.apiSuccess(MODULE, '查询跨服务链路拖车预约状态历史', historyResponse, '', OPTIONS) @@ -119,6 +136,8 @@ export async function run(context) { if (final?.status === 'COMPLETED') { context.createdResources.serviceCenterFlow = { appointmentId, oceanBookingId: ocean.id, + containerBookingId: null, + loadingPlanId: null, marker: context.marker, finalStatus: 'COMPLETED', } } diff --git a/scripts/admin-test-env-modules/suppliers.mjs b/scripts/admin-test-env-modules/suppliers.mjs index 74e909129..c4714fb59 100644 --- a/scripts/admin-test-env-modules/suppliers.mjs +++ b/scripts/admin-test-env-modules/suppliers.mjs @@ -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 id(value) { return value == null ? '' : String(value) } 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 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 { const detailResponse=await context.request('GET',`${BASE}/${encodeURIComponent(supplierId)}?tenantId=0`) 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})) 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) const editedResponse=await context.request('GET',`${BASE}/${encodeURIComponent(supplierId)}?tenantId=0`) 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'}) context.apiSuccess(MODULE,'停用供应商',disableResponse,'',OPTIONS); inactive=ok(disableResponse) diff --git a/scripts/client-test-env-acceptance.mjs b/scripts/client-test-env-acceptance.mjs index d1f5e8f18..0cebbba12 100644 --- a/scripts/client-test-env-acceptance.mjs +++ b/scripts/client-test-env-acceptance.mjs @@ -42,6 +42,7 @@ if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) { const now = new Date() const marker = `${compactTimestamp(now)}-${randomUUID().slice(0, 8)}` 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 phoneSuffix = `${Date.now()}`.slice(-8) const legalPhone = `139${phoneSuffix}` @@ -91,6 +92,7 @@ try { await qualificationAndReviewModule() await merchantAfterApprovalModule() await profileManagementModule() + await loginIdentifierModule() await rbacAndMemberModule() await orderLifecycleModule() await isolationModule() @@ -117,7 +119,7 @@ async function registrationModule() { displayName: `测试管理员-${marker}`.slice(0, 64), companyName, legalPerson: '测试法人', - legalPhone, + phone: legalPhone, contactEmail: `${username}@example.com`, countryCode: 'CN', address: '广东省深圳市南山区测试路 1 号', @@ -213,7 +215,6 @@ async function qualificationAndReviewModule() { const png = testPng() const form = new FormData() 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', { method: 'POST', @@ -223,7 +224,7 @@ async function qualificationAndReviewModule() { }, ({ json }) => { requireSuccess(json) 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') }, ({ json }) => ({ businessLicenseQualificationId: json.data.businessLicenseQualificationId, @@ -274,7 +275,9 @@ async function qualificationAndReviewModule() { method: 'GET', path: '/api/merchant/v1/iam/tenant/qualifications', token: merchantToken, }, ({ 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') }, ({ json }) => ({ qualifications: qualificationEvidence(json.data) })) @@ -293,6 +296,19 @@ async function qualificationAndReviewModule() { }, () => ({ username: platformUsername })) 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', { method: 'GET', path: '/api/platform/v1/iam/reviews', token: platformToken, }, ({ json }) => { @@ -310,7 +326,9 @@ async function qualificationAndReviewModule() { method: 'GET', path: `/api/platform/v1/iam/tenants/${report.resources.tenantId}/qualifications`, token: platformToken, }, ({ 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) })) 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() { const login = await apiStep('merchant-session', 'client re-login after approval', { 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, }, ({ 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') }, ({ json }) => ({ qualifications: qualificationEvidence(json.data) })) @@ -1004,9 +1069,9 @@ async function orderLifecycleModule() { }, ({ bytes, headers }) => ({ sizeBytes: bytes.length, contentType: headers.get('content-type') })) 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) { value.amazonWarehouseCode = 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) { value.cargoes[0].skuNo = null value.cargoes[0].upcNo = null @@ -1022,9 +1087,35 @@ async function orderLifecycleModule() { 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', - mutate(value) { value.sender = null }, + name: 'PICKUP order without a selected pickup address is rejected', + 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', @@ -1191,11 +1282,11 @@ async function orderLifecycleModule() { requireSuccess(json) assertPresent(pageRecords(json.data).find((item) => String(item.orderId) === String(orderId)), 'combined-filtered order') }, ({ json }) => ({ total: json.data?.total, orderId })) - await apiStep('order-filters', 'sender phone filter contains submitted order', { - method: 'GET', path: `/api/merchant/v1/orders/first-leg?page=1&size=50&customerContact=${encodeURIComponent(legalPhone)}`, token: merchantToken, + 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(payload.customerName)}`, token: merchantToken, }, ({ 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 })) 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, @@ -1815,7 +1906,7 @@ function buildOrderPayload(masterData, productImageFileId, suffix, remark) { destinationWarehouseName: masterData.destination.warehouseName, expectedShipDate: addDays(new Date(), 30), customerRemark: `${remark} ${marker}`, - pickupMethod: 'PICKUP', + pickupMethod: 'SELF_EXPRESS', receivingWarehouseId: masterData.receiving.id, receivingWarehouseName: masterData.receiving.warehouseName, channelCode: masterData.channel.channelCode, @@ -1867,6 +1958,7 @@ function buildOrderPayload(masterData, productImageFileId, suffix, remark) { upcNo: `123456${`${Date.now()}`.slice(-6)}`.slice(0, 12), productImageFileId, dangerousGoods: false, + woodenProduct: false, 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' }) { const requestHeaders = { Accept: 'application/json', 'X-Request-Id': `client-e2e-${randomUUID()}`, ...headers } if (token) requestHeaders.Authorization = `Bearer ${token}` - let requestBody = body - if (body != null && !(body instanceof FormData)) { + const normalizedBody = path === '/api/auth/login' && body?.username && !body?.loginId + ? { ...body, loginId: body.username, username: undefined } + : body + let requestBody = normalizedBody + if (normalizedBody != null && !(normalizedBody instanceof FormData)) { requestHeaders['Content-Type'] = 'application/json' - requestBody = JSON.stringify(body) + requestBody = JSON.stringify(normalizedBody) } const response = await fetch(`${gateway}${path}`, { method,