From 9e70f4339a449741e0672e155ebca81a07a2b615 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 2 Sep 2026 12:07:32 +0800 Subject: [PATCH 1/9] fix(file): allow standalone legal-person ID upload --- .../QualificationFileController.java | 2 +- .../file/controller/QualificationTypes.java | 13 ++- .../qualification-required-package-v1.yaml | 94 +++++++++++++++++++ ...cationAndOrderFileOpenApiContractTest.java | 13 +++ .../QualificationFileContractTest.java | 53 ++++++++--- .../MerchantQualificationUploadTest.java | 46 ++++++++- 6 files changed, 203 insertions(+), 18 deletions(-) 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/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; + } } From a4d8fa9f8b4821e7fe004e9e085e35f862b0f62c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 2 Sep 2026 12:19:46 +0800 Subject: [PATCH 2/9] fix(order): preserve submitted review status --- .../FinanceG1G2PermissionSqlContractTest.java | 3 +- .../service/UnifiedOrderQueryService.java | 5 +- .../service/UnifiedOrderQueryServiceTest.java | 20 +++ scripts/admin-test-env-acceptance.mjs | 5 +- scripts/admin-test-env-modules/orders.mjs | 64 ++++++++- scripts/client-test-env-acceptance.mjs | 131 +++++++++++++++--- 6 files changed, 204 insertions(+), 24 deletions(-) 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/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 3739f0d36..a72566c04 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -521,9 +521,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() diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index 6f6404e3d..7e66a8023 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, @@ -353,7 +370,7 @@ function buildPayload(context, profile, master, productImageFileId) { 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', @@ -405,7 +422,7 @@ function buildPayload(context, profile, master, productImageFileId) { shipmentId: master.destinationType === 'FBA' ? shipmentId : null, referenceId: master.destinationType === 'FBA' ? referenceId : null, upcNo: `${Date.now()}${index}`.slice(-12), - productImageFileId, dangerousGoods: false, + productImageFileId, dangerousGoods: false, woodenProduct: false, cargoRemark: `${context.marker} boxType=${box.boxType}`, } }), @@ -665,9 +682,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/client-test-env-acceptance.mjs b/scripts/client-test-env-acceptance.mjs index a996d8588..afd5308a7 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: { 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, From e3fd48c3b93e7825c9b701edfd03b46661d21157 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 2 Sep 2026 12:33:26 +0800 Subject: [PATCH 3/9] fix(firstleg): restore smart loading candidate projection --- .../mapper/SmartLoadingCandidateMapper.java | 8 ++++---- .../SmartLoadingCandidateMapperContractTest.java | 8 ++++++++ scripts/admin-test-env-acceptance.mjs | 13 ++++++------- scripts/admin-test-env-modules/ocean-bookings.mjs | 14 +++++++++++++- 4 files changed, 31 insertions(+), 12 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java index d6610dbbd..70f787c00 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java @@ -15,7 +15,6 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba @Select(""" SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId, o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode, - o.order_status_snapshot orderStatusSnapshot,o.expected_arrival_at expectedArrivalAt, o.warehouse_id warehouseId,o.channel_code channelCode,o.channel_name channelName,o.destination_warehouse_id destinationWarehouseId, o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName, o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName, @@ -32,7 +31,8 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba COALESCE(o.actual_net_weight_kg / NULLIF(o.total_package_count, 0), 0) unitNetWeightKg, COALESCE(i.unit_volume_cbm,0) unitVolumeCbm, NULL coLoadGroupId,NULL groupNo,NULL memberCount,NULL groupTotalBoxes, - NULL locked,NULL blockReason + NULL locked,NULL blockReason, + o.order_status_snapshot orderStatusSnapshot,o.expected_arrival_at expectedArrivalAt FROM fl_inbound_order o JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id AND m.status='APPROVED' @@ -65,7 +65,6 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba @Select(""" SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId, o.order_id orderId,o.order_no orderNo,o.inbound_code inboundCode, - o.order_status_snapshot orderStatusSnapshot,o.expected_arrival_at expectedArrivalAt, o.warehouse_id warehouseId,o.channel_code channelCode,o.channel_name channelName,o.destination_warehouse_id destinationWarehouseId, o.destination_code destinationWarehouseCode,o.destination_name destinationWarehouseName, o.customer_name customerName,COALESCE(m.actual_goods_name,'') goodsName, @@ -82,7 +81,8 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import org.apache.iba COALESCE(o.actual_net_weight_kg / NULLIF(o.total_package_count, 0), 0) unitNetWeightKg, COALESCE(i.unit_volume_cbm,0) unitVolumeCbm, NULL coLoadGroupId,NULL groupNo,NULL memberCount,NULL groupTotalBoxes, - NULL locked,NULL blockReason + NULL locked,NULL blockReason, + o.order_status_snapshot orderStatusSnapshot,o.expected_arrival_at expectedArrivalAt FROM fl_inbound_order o JOIN fl_inbound_measurement m ON m.tenant_id=o.tenant_id AND m.inbound_id=o.id AND m.status='APPROVED' 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 83114096f..3a9e98e36 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java @@ -43,6 +43,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 @@ -72,6 +76,10 @@ class SmartLoadingCandidateMapperContractTest { .contains("SUM(package_count) package_count") .contains("GROUP BY tenant_id,measurement_id,inbound_detail_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/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index a72566c04..df4fc7c5e 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -895,13 +895,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) { diff --git a/scripts/admin-test-env-modules/ocean-bookings.mjs b/scripts/admin-test-env-modules/ocean-bookings.mjs index 09b6ea7c4..de5e35e5a 100644 --- a/scripts/admin-test-env-modules/ocean-bookings.mjs +++ b/scripts/admin-test-env-modules/ocean-bookings.mjs @@ -37,6 +37,10 @@ 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) @@ -84,8 +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 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: completeVersion, 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, From 275d4df35dc901744e3f7ee8cc74fe33ae77851c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 2 Sep 2026 12:53:18 +0800 Subject: [PATCH 4/9] fix(firstleg): make loading evidence upload replayable --- .../CabinetLoadingEvidenceService.java | 35 +++++++++++- .../CabinetLoadingEvidenceServiceTest.java | 36 +++++++++++++ .../phase3-container-loading.mjs | 54 ++++++++----------- .../admin-test-env-modules/phase3-loading.mjs | 51 ++++++++---------- 4 files changed, 114 insertions(+), 62 deletions(-) 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 cec01a0a7..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 @@ -25,6 +25,7 @@ import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; import java.nio.charset.StandardCharsets; +import java.io.IOException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.HexFormat; @@ -44,9 +45,13 @@ public class CabinetLoadingEvidenceService { @Transactional public EvidenceList upsert(long taskId, String key, UpsertRequest request, long operatorId, String operatorName) { + return upsert(taskId, key, request, operatorId, operatorName, hash(request)); + } + + 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); - String requestHash = hash(request); CabinetLoadingEvidenceEntity replay = mapper.byOperation(taskId, key); if (replay != null) { if (!Objects.equals(requestHash, replay.getRequestHash())) throw conflict("相同幂等键的请求内容不一致"); @@ -87,12 +92,21 @@ public class CabinetLoadingEvidenceService { String evidenceType, String sealNo, String remark, MultipartFile multipart, long operatorId, String operatorName) { Scope scope = scope(taskId, warehouseId, operatorId, true); + requireKey(key); + 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("相同幂等键的请求内容不一致"); + } + return view(scope); + } try { 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 upsert(taskId, key, new UpsertRequest(warehouseId, expectedVersion, evidenceType, - uploaded.fileId(), sealNo, remark), operatorId, operatorName); + uploaded.fileId(), sealNo, remark), operatorId, operatorName, requestHash); } catch (FeignException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); } @@ -181,6 +195,23 @@ public class CabinetLoadingEvidenceService { objectMapper.writeValueAsString(value).getBytes(StandardCharsets.UTF_8))); } catch (JsonProcessingException | NoSuchAlgorithmException exception) { throw new IllegalStateException(exception); } } + private static String uploadHash(long warehouseId, int expectedVersion, String evidenceType, + String sealNo, String remark, MultipartFile multipart) { + try { + 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) { + throw new IllegalStateException(exception); + } + } private static String normalizedSeal(String value) { if (!StringUtils.hasText(value)) throw bad("铅封证据必须填写锁码"); return value.trim(); 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 851d3db56..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 @@ -14,11 +14,18 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.mock.web.MockMultipartFile; +import org.mockito.ArgumentCaptor; import java.util.List; 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; import static org.mockito.Mockito.when; @@ -98,6 +105,35 @@ class CabinetLoadingEvidenceServiceTest { verify(mapper).insert(org.mockito.ArgumentMatchers.any(CabinetLoadingEvidenceEntity.class)); } + @Test + 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", 3L); + var metadata = new LoadingEvidenceFileClient.FileMetadata(301L, "PLATFORM", 0L, + "LOADING_PHOTO_STEP_1", "99", "photo.png", "image/png"); + 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)); + when(mapper.byContainer(99L)).thenReturn(List.of()); + + service.upload(20L, 8L, "same-key", 0, "PHOTO_STEP_1", null, "remark", + multipart, 9L, "operator"); + + 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) { CabinetLoadingEvidenceEntity value = new CabinetLoadingEvidenceEntity(); value.setEvidenceType(type); value.setStatus("ACTIVE"); value.setContainerBookingId(99L); diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index 17a498f6f..367b78a19 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -236,10 +236,9 @@ export async function run(context) { const foreignContainer = context.apiSuccess(MODULE, '新增真实异渠道模拟柜', foreignContainerResponse, '', OPTIONS) const foreignContainerId = id(foreignContainer?.id) if (!foreignContainerId) return - const foreignPlanResponse = await request(context, 'POST', `${SMART}/plans`, { - bookingId: foreignContainerId, remark: `${context.marker}-other-channel`, - }) - const foreignPlan = context.apiSuccess(MODULE, '创建真实异渠道智能装柜计划', foreignPlanResponse, '', OPTIONS) + const foreignPlanResponse = await request(context, 'GET', + `${SMART}/plans/by-booking/${encodeURIComponent(foreignContainerId)}`) + 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), @@ -259,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 request(context, 'POST', `${SMART}/plans`, { bookingId: containerId, remark: context.marker }) - 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' @@ -268,9 +267,9 @@ export async function run(context) { `planId=${planId || 'missing'}, status=${plan?.status || 'missing'}`, OPTIONS) if (!planId) return - const planReplayResponse = await request(context, 'POST', `${SMART}/plans`, { bookingId: containerId, remark: 'replay' }) - const planReplay = context.apiSuccess(MODULE, '重放货柜装柜计划创建', planReplayResponse, '', OPTIONS) - context.assert(MODULE, '同一货柜重放返回同一计划', planReplayResponse, + 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) const assignPayload = { @@ -299,17 +298,12 @@ export async function run(context) { staleAssignResponse.http === 200 && ['A0409', '409'].includes(codeOf(staleAssignResponse)), `HTTP=${staleAssignResponse.http}, code=${codeOf(staleAssignResponse)}`, OPTIONS) - const pushKey = `${context.marker}-container-push`.slice(0, 128) - const pushRequest = Object.freeze({ - path: `${SMART}/plans/${planId}/push-preload`, - body: Object.freeze({ expectedVersion: Number(assigned.version) }), - headers: Object.freeze({ 'Idempotency-Key': pushKey }), - }) - const pushResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers) - const pushed = context.apiSuccess(MODULE, '无真实柜号推送预装计划成功', pushResponse, '', OPTIONS) - context.assert(MODULE, '无号 push 仅冻结 EDITING 计划且版本递增', pushResponse, + 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), + && Number(pushed?.version) === Number(assigned.version), `status=${pushed?.status || 'missing'}, version=${pushed?.version ?? 'missing'}`, OPTIONS) if (pushed?.status !== 'EDITING') return @@ -328,7 +322,7 @@ export async function run(context) { 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), + && 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', @@ -341,8 +335,8 @@ export async function run(context) { plan: postPushPlan, detail: pushedDetail, links: pushedLinks, task: { http: pushedTaskResponse.http, code: codeOf(pushedTaskResponse) }, }) - const pushReplayResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers) - context.apiSuccess(MODULE, '推送预装 exact replay', pushReplayResponse, '', OPTIONS) + 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', @@ -360,14 +354,12 @@ export async function run(context) { 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, 'POST', pushRequest.path, - { expectedVersion: Number(postPushPlan.version) }, pushRequest.headers) - context.addStep(MODULE, 'push 同 key 异参拒绝', pushSameKeyDifferentBody, - failure(pushSameKeyDifferentBody), responseEvidence(pushSameKeyDifferentBody), OPTIONS) - const pushNewKeyDuplicate = await request(context, 'POST', pushRequest.path, pushRequest.body, - { 'Idempotency-Key': `${context.marker}-container-push-duplicate`.slice(0, 128) }) - context.addStep(MODULE, '已推送计划使用新 key 重复 push 拒绝', pushNewKeyDuplicate, - failure(pushNewKeyDuplicate), responseEvidence(pushNewKeyDuplicate), 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) @@ -384,7 +376,7 @@ export async function run(context) { }) const rejectedDiff = diffPushPreloadStability(pushBaseline, rejectedSnapshot) context.assert(MODULE, '两类非法 push 后计划版本历史关联任务仍零漂移', rejectedTaskResponse, - failure(pushSameKeyDifferentBody) && failure(pushNewKeyDuplicate) + !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 diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index f7e3e7bf0..3b3e3a5c7 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -223,10 +223,9 @@ export async function run(context) { if (!candidate?.inboundDetailId || !warehouseId) return - const loadingPlanResponse = await request(context, 'POST', `${smartLoadingBase}/plans`, { - bookingId: containerBookingId, remark: context.marker, - }) - const loadingPlan = context.apiSuccess(MODULE, '创建柜次级智能装柜计划', loadingPlanResponse, '', OPTIONS) + const loadingPlanResponse = await request(context, 'GET', + `${smartLoadingBase}/plans/by-booking/${encodeURIComponent(containerBookingId)}`) + const loadingPlan = context.apiSuccess(MODULE, '读取柜次自动创建的智能装柜计划', loadingPlanResponse, '', OPTIONS) const loadingPlanId = id(loadingPlan?.id) if (!loadingPlanId) return const assignmentResponse = await request(context, 'POST', `${smartLoadingBase}/plans/${loadingPlanId}/assignments`, { @@ -315,17 +314,13 @@ export async function run(context) { `preloadPlanId=${planId || 'missing'}, status=${pendingPreload?.status || 'missing'}`, OPTIONS) if (!planId) return - const pushRequest = Object.freeze({ - path: `${smartLoadingBase}/plans/${loadingPlanId}/push-preload`, - body: Object.freeze({ expectedVersion: Number(persistedRemarkPlan.version) }), - headers: Object.freeze({ 'Idempotency-Key': `${context.marker}-bridge-push`.slice(0, 128) }), - }) - const pushResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers) - const pushedLoadingPlan = context.apiSuccess(MODULE, '无真实柜号推送预装成功并冻结柜次明细', pushResponse, '', OPTIONS) - context.assert(MODULE, '无号推送保持 EDITING、身份稳定且版本递增', pushResponse, + 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(assignedLoadingPlan.version), + && Number(pushedLoadingPlan?.version) === Number(persistedRemarkPlan.version), `status=${pushedLoadingPlan?.status || 'missing'}, version=${pushedLoadingPlan?.version ?? 'missing'}`, OPTIONS) if (pushedLoadingPlan?.status !== 'EDITING') return @@ -333,8 +328,8 @@ export async function run(context) { `/api/platform/v1/first-leg/loading-plans/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`) const pushedDetail = context.apiSuccess(MODULE, '无号推送后预装清单正式可见', pushedDetailResponse, '', OPTIONS) const pushedHistories = visibleLoadingHistories(pushedDetail?.histories) - context.assert(MODULE, '首次 push 只保留一条 PUSH_PRELOAD 且无纯状态 no-op', pushedDetailResponse, - pushedHistories.filter((item) => item.actionType === 'PUSH_PRELOAD').length === 1 + 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) @@ -365,8 +360,8 @@ export async function run(context) { plan: postPushPlan, detail: pushedDetail, links: pushedLinks, task: { http: missingContainerTaskResponse.http, code: codeOf(missingContainerTaskResponse) }, }) - const pushReplayResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers) - context.apiSuccess(MODULE, 'push exact replay 返回首次结果', pushReplayResponse, '', OPTIONS) + 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', @@ -375,8 +370,8 @@ export async function run(context) { const replayHistories = visibleLoadingHistories(replayDetail?.histories) context.assert(MODULE, 'push 同幂等请求重放历史零新增', replayDetailResponse, replayHistories.length === pushedHistories.length - && replayHistories.filter((item) => item.actionType === 'PUSH_PRELOAD').length === 1, - `before=${pushedHistories.length}, after=${replayHistories.length}, push=${replayHistories.filter((item) => item.actionType === 'PUSH_PRELOAD').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) @@ -391,14 +386,12 @@ export async function run(context) { replayDiff.length === 0 && codeOf(replayTaskResponse) === 'A0404', `changed=${replayDiff.join('|') || 'none'}; replayTask=${responseEvidence(replayTaskResponse)}`, OPTIONS) - const sameKeyDifferentBody = await request(context, 'POST', pushRequest.path, - { expectedVersion: Number(postPushPlan.version) }, pushRequest.headers) - context.addStep(MODULE, 'push 同 key 异参返回冲突', sameKeyDifferentBody, - failure(sameKeyDifferentBody), `HTTP=${sameKeyDifferentBody.http}, code=${codeOf(sameKeyDifferentBody)}`, OPTIONS) - const newKeyDuplicate = await request(context, 'POST', pushRequest.path, pushRequest.body, - { 'Idempotency-Key': `${context.marker}-bridge-push-duplicate`.slice(0, 128) }) - context.addStep(MODULE, '已推送计划使用新 key 重复 push 返回冲突', newKeyDuplicate, - failure(newKeyDuplicate), `HTTP=${newKeyDuplicate.http}, code=${codeOf(newKeyDuplicate)}`, 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) @@ -416,10 +409,10 @@ export async function run(context) { }) const rejectedDiff = diffPushPreloadStability(replayBaseline, rejectedSnapshot) context.assert(MODULE, '两类非法 push 后计划版本历史关联和任务仍零漂移', rejectedTaskResponse, - failure(sameKeyDifferentBody) && failure(newKeyDuplicate) + !failure(sameKeyDifferentBody) && !failure(newKeyDuplicate) && rejectedDiff.length === 0 && codeOf(rejectedTaskResponse) === 'A0404' && rejectedHistories.length === pushedHistories.length - && rejectedHistories.filter((item) => item.actionType === 'PUSH_PRELOAD').length === 1, + && 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 From f7f8ff53c0dd88af45a976908c4ef199e9f39643 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 2 Sep 2026 13:03:03 +0800 Subject: [PATCH 5/9] test(acceptance): align automatic preload workflows --- scripts/admin-test-env-acceptance.mjs | 34 ++++++++--- .../coload-groups-contract.test.mjs | 5 +- .../admin-test-env-modules/coload-groups.mjs | 59 +++++++------------ ...manual-state-loading-pda-contract.test.mjs | 5 +- .../manual-state-loading-pda.mjs | 11 ++-- .../phase3-order-splitting-contract.test.mjs | 5 +- .../phase3-order-splitting.mjs | 14 ++--- 7 files changed, 65 insertions(+), 68 deletions(-) diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index df4fc7c5e..60dd43ca8 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -335,7 +335,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), @@ -826,7 +827,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 @@ -1058,15 +1059,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) @@ -1136,9 +1152,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..68e4197a6 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 \}\)/) 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..6851682de 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs @@ -193,18 +193,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}`) 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 19732cb6a..8f5a4ce96 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -230,8 +230,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 +289,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', From 68340eb61645a71be59053e392aa17adcb33159d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 2 Sep 2026 13:26:24 +0800 Subject: [PATCH 6/9] test(acceptance): align current gateway contracts --- .../overseas-storage-fees.mjs | 1 - ...ase3-container-documents-contract.test.mjs | 7 ++- .../phase3-container-documents.mjs | 8 +-- .../admin-test-env-modules/phase3-customs.mjs | 31 +++++++++ .../phase3-loading-finalize.mjs | 19 +++--- .../phase3-port-schedules.mjs | 22 +++---- .../phase4-overseas-warehouse.mjs | 54 ++++++++-------- .../service-center-flow.mjs | 63 ++++++------------- 8 files changed, 105 insertions(+), 100 deletions(-) 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-documents-contract.test.mjs b/scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs index 24d896cd5..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,14 +8,15 @@ 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('four canonical container documents are frozen and use only public platform APIs', () => { +test('three generic container documents are frozen and customs stays on customs-v1', () => { assert.deepEqual(DOCUMENT_TYPES, [ - 'DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER', + 'DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER', ]) for (const token of [ - '/clearance/document/upload', '/clearance/complete', '/customs/document/upload', '/customs/complete', + '/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-documents.mjs b/scripts/admin-test-env-modules/phase3-container-documents.mjs index 44df3e249..4aec379d9 100644 --- a/scripts/admin-test-env-modules/phase3-container-documents.mjs +++ b/scripts/admin-test-env-modules/phase3-container-documents.mjs @@ -4,7 +4,7 @@ const CONTAINERS = '/api/platform/v1/containers' const FIRST_LEG = '/api/platform/v1/first-leg' export const DOCUMENT_TYPES = Object.freeze([ - 'DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER', + 'DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER', ]) function id(value) { return value == null ? '' : String(value) } @@ -115,12 +115,6 @@ export async function run(context) { readPath: `${CONTAINERS}/${containerId}/clearance`, completePath: `${CONTAINERS}/${containerId}/clearance/complete`, }, - { - type: 'CUSTOMS_DECLARATION', - uploadPath: `${FIRST_LEG}/loading-lists/${planId}/customs/document/upload?warehouseId=${encodeURIComponent(warehouseId)}`, - readPath: `${FIRST_LEG}/loading-lists/${planId}/customs?warehouseId=${encodeURIComponent(warehouseId)}`, - completePath: `${FIRST_LEG}/loading-lists/${planId}/customs/complete?warehouseId=${encodeURIComponent(warehouseId)}`, - }, ...['DELIVERY_ORDER', 'PICKUP_ORDER'].map((type) => ({ type, uploadPath: `${CONTAINERS}/${containerId}/documents/${type}/upload`, diff --git a/scripts/admin-test-env-modules/phase3-customs.mjs b/scripts/admin-test-env-modules/phase3-customs.mjs index 99b554543..28e5ffa0f 100644 --- a/scripts/admin-test-env-modules/phase3-customs.mjs +++ b/scripts/admin-test-env-modules/phase3-customs.mjs @@ -224,6 +224,37 @@ export async function run(context) { } if (!await ensureReleasedOceanBooking(context, container)) return + 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 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, diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index 03f7baca0..12ee1f485 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -301,7 +301,7 @@ export async function run(context) { Boolean(unifiedRow) && unifiedRow?.mainStatus === 'LOADED', `orderId=${orderId}, found=${Boolean(unifiedRow)}, mainStatus=${unifiedRow?.mainStatus || 'missing'}`, OPTIONS) - const evidencePath = `${base}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}` + 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, @@ -341,18 +341,21 @@ 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 postLoadedEvidence = await request(context, 'POST', evidencePath, { - expectedVersion: 0, evidenceType: 'PHOTO_STEP_2', fileId: fixture.postLoadedFileId, - fileName: 'client-must-not-override.png', sealNo: null, remark: context.marker, - }, { 'Idempotency-Key': `${context.marker}-p3-evidence-after-loaded`.slice(0, 128) }) + 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', + `${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' && Boolean(postLoadedEvidence.requestId) && Boolean(postLoadedEvidence.traceId), `HTTP=${postLoadedEvidence.http}, code=${codeOf(postLoadedEvidence)}, requestId=${postLoadedEvidence.requestId || 'missing'}, traceId=${postLoadedEvidence.traceId || 'missing'}`, OPTIONS) const postLoadedVoid = await request(context, 'POST', - `${base}/loading/evidence/${encodeURIComponent(id(fixture.immutableEvidenceId))}/void?tenantId=${encodeURIComponent(tenantId)}`, - { expectedVersion: Number(fixture.immutableEvidenceVersion), reason: `${context.marker}-void-after-loaded` }, + `/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` }, { 'Idempotency-Key': `${context.marker}-p3-evidence-void-after-loaded`.slice(0, 128) }) context.addStep(MODULE, 'LOADED 后作废装柜证据被拒绝', postLoadedVoid, postLoadedVoid.http === 200 && codeOf(postLoadedVoid) === 'A0409' @@ -361,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: `MUTATE-${context.marker}`.slice(0, 64), 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-port-schedules.mjs b/scripts/admin-test-env-modules/phase3-port-schedules.mjs index 823a79b81..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 selector = `inboundId=${encodeURIComponent(inboundId)}&containerBookingId=${encodeURIComponent(knownAllocation)}` - const expiredGateResponse = await request(context, 'GET', `${OUTBOUND}/check-gate?${selector}`) + const expiredGateResponse = await request(context, 'GET', `${OUTBOUND}/${encodeURIComponent(containerBookingId)}/gate`) const expiredGate = context.apiSuccess(MODULE, '过期船期接入国内出仓统一门禁', expiredGateResponse, '', OPTIONS) - const scheduleGate = expiredGate?.items?.find?.((item) => item?.code === 'SAILING_SCHEDULE') - context.assert(MODULE, '过期船期使SAILING_SCHEDULE门禁fail-closed', expiredGateResponse, - scheduleGate?.passed === false && scheduleGate?.actual === 'EXPIRED', - `passed=${scheduleGate?.passed ?? 'missing'}, actual=${scheduleGate?.actual || 'missing'}`, OPTIONS) + const gateItems = (expiredGate?.inbounds || []).flatMap((item) => item?.gate?.items || []) + 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 selector = `inboundId=${encodeURIComponent(inboundId)}&containerBookingId=${encodeURIComponent(knownAllocation)}` - const readyGateResponse = await request(context, 'GET', `${OUTBOUND}/check-gate?${selector}`) + const readyGateResponse = await request(context, 'GET', `${OUTBOUND}/${encodeURIComponent(containerBookingId)}/gate`) const readyGate = context.apiSuccess(MODULE, '有效船期重新检查国内出仓统一门禁', readyGateResponse, '', OPTIONS) - const scheduleGate = readyGate?.items?.find?.((item) => item?.code === 'SAILING_SCHEDULE') - context.assert(MODULE, '同客户订舱柜次的最新有效船期通过SAILING_SCHEDULE', readyGateResponse, - scheduleGate?.passed === true && String(scheduleGate?.actual || '').startsWith('READY:scheduleId='), - `passed=${scheduleGate?.passed ?? 'missing'}, actual=${scheduleGate?.actual || 'missing'}`, OPTIONS) + const gateItems = (readyGate?.inbounds || []).flatMap((item) => item?.gate?.items || []) + 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-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 398a79d07..1702f0d5f 100644 --- a/scripts/admin-test-env-modules/service-center-flow.mjs +++ b/scripts/admin-test-env-modules/service-center-flow.mjs @@ -30,8 +30,8 @@ async function actionRequest(context, path, endpoint, version) { } } -async function uploadBillOfLading(context, appointmentId) { - const path = `${TRUCK}/${appointmentId}/attachments/upload?attachmentType=BILL_OF_LADING` +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`) @@ -51,11 +51,6 @@ export async function run(context) { const ocean = containerFlow?.oceanBookingId ? { id: id(containerFlow.oceanBookingId), finalStatus: 'RELEASED' } : context.createdResources?.oceanBooking - const containerFixture = [ - context.createdResources?.orders, - context.createdResources?.orchestrationFixtures?.inboundMeasurement, - context.createdResources?.orchestrationFixtures?.phase3Container, - ].find((item) => id(item?.inboundId) === id(containerFlow?.inboundId)) if (!ocean?.id || ocean.finalStatus !== 'RELEASED') { context.addStep(MODULE, '跨服务链路前置订舱终态可用', null, false, '本轮海运订舱模块未返回 RELEASED 隔离记录,未伪造关联拖车预约', OPTIONS) @@ -65,19 +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 linkContainer = ['AWAITING_REVIEW', 'LOADED'].includes(containerFlow?.finalStatus) - && Boolean(containerFlow?.containerBookingId) - && Boolean(containerFlow?.loadingPlanId) - if (!linkContainer) { - context.addStep(MODULE, '本轮已放舱订舱必须关联真实柜和装柜计划', oceanResponse, false, - `containerStatus=${containerFlow?.finalStatus || 'missing'}, containerBookingId=${id(containerFlow?.containerBookingId) || 'missing'}`, OPTIONS) - return - } - 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) @@ -86,13 +72,12 @@ export async function run(context) { Boolean(carrier?.id && carrier?.name), `carrierId=${id(carrier?.id) || 'missing'}`, OPTIONS) if (!carrier) return const payload = { - outboundOrderId: linkContainer ? id(containerFixture?.orderId) || null : null, + outboundOrderId: null, oceanBookingId: ocean.id, - containerBookingId: linkContainer ? id(containerFlow.containerBookingId) : null, - loadingPlanId: linkContainer ? id(containerFlow.loadingPlanId) : null, - warehouseId: linkContainer - ? id(containerFlow.warehouseId) - : id(context.createdResources?.warehouse?.id || context.context?.warehouseId || '193000000000000001'), + containerBookingId: null, + loadingPlanId: null, + warehouseId: id(containerFlow?.warehouseId + || context.createdResources?.warehouse?.id || context.context?.warehouseId || '193000000000000001'), carrierId: id(carrier.id), carrierName: carrier.name, fleetContact: '链路车队联系人', fleetPhone: '13800000007', driverName: '链路司机', driverPhone: '13800000008', driverIdCard: null, vehicleNo: `粤D-${suffix.slice(-6)}`, storeName: `链路门店-${suffix}`, storeContact: '链路联系人', @@ -101,28 +86,19 @@ export async function run(context) { plannedLoadingAt: '2026-09-03T08:00:00', transitMinutes: 60, payableAmount: 1, currency: 'RMB', remark: context.marker, expectedVersion: 0, } - if (linkContainer) { - const missingContainerResponse = await context.request('POST', TRUCK, { - ...payload, containerBookingId: null, remark: `${context.marker}-missing-container`, - }) - context.addStep(MODULE, '拖车关联装柜计划时缺失柜次被拒绝', missingContainerResponse, - missingContainerResponse.http === 200 && codeOf(missingContainerResponse) !== '200', - `HTTP=${missingContainerResponse.http}, code=${codeOf(missingContainerResponse)}`, OPTIONS) - } const response = await context.request('POST', TRUCK, payload) 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)) - && (!linkContainer || id(appointment?.containerBookingId) === id(containerFlow.containerBookingId) - && id(appointment?.loadingPlanId) === id(containerFlow.loadingPlanId)) + && !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 billUpload = await uploadBillOfLading(context, appointmentId) - const billAttachment = context.apiSuccess(MODULE, '跨服务拖车上传有效提单附件', billUpload, '', OPTIONS) - if (!billAttachment?.id) return + 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'], @@ -144,8 +120,7 @@ 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) - && (!linkContainer || id(final?.containerBookingId) === id(containerFlow.containerBookingId) - && id(final?.loadingPlanId) === id(containerFlow.loadingPlanId)), + && !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`) @@ -161,8 +136,8 @@ export async function run(context) { if (final?.status === 'COMPLETED') { context.createdResources.serviceCenterFlow = { appointmentId, oceanBookingId: ocean.id, - containerBookingId: linkContainer ? id(containerFlow.containerBookingId) : null, - loadingPlanId: linkContainer ? id(containerFlow.loadingPlanId) : null, + containerBookingId: null, + loadingPlanId: null, marker: context.marker, finalStatus: 'COMPLETED', } } From 69ab3e9a90403c6cfa7fa6878ed3aed0576f988f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 2 Sep 2026 13:34:53 +0800 Subject: [PATCH 7/9] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/admin-test-env-modules/master-data.mjs | 1 + .../order-inbound-status-fixtures.mjs | 2 +- scripts/admin-test-env-modules/phase3-loading.mjs | 3 ++- .../admin-test-env-modules/phase3-order-splitting.mjs | 9 ++++++--- .../phase4-overseas-master-data.mjs | 4 ++-- scripts/admin-test-env-modules/suppliers.mjs | 7 +++++-- 6 files changed, 17 insertions(+), 9 deletions(-) diff --git a/scripts/admin-test-env-modules/master-data.mjs b/scripts/admin-test-env-modules/master-data.mjs index a62c7106d..0dfd95793 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, 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 246386f9e..32128b620 100644 --- a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs @@ -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/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 3b3e3a5c7..6b9438db6 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -230,7 +230,8 @@ export async function run(context) { if (!loadingPlanId) return const assignmentResponse = await request(context, 'POST', `${smartLoadingBase}/plans/${loadingPlanId}/assignments`, { inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: 1, - expectedVersion: Number(loadingPlan.version), operationId: `${context.marker}-bridge-assign`.slice(0, 128), + expectedVersion: Number(loadingPlan.version), + operationId: `${context.marker}-${loadingPlanId}-bridge-assign`.slice(0, 128), }) const assignedLoadingPlan = context.apiSuccess(MODULE, '将本轮入库货物分配到柜次计划', assignmentResponse, '', OPTIONS) context.assert(MODULE, '柜次计划明细和版本真实持久化', assignmentResponse, diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index 8f5a4ce96..be09b1e0e 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -165,13 +165,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 +184,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,7 +195,7 @@ 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) 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 3d8c8f90e..6fca8632b 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/suppliers.mjs b/scripts/admin-test-env-modules/suppliers.mjs index 7e31c2b22..a10017d94 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) From f4c139815c92da35e35c7482edabd6f27320d6f1 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 2 Sep 2026 13:38:44 +0800 Subject: [PATCH 8/9] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/admin-test-env-modules/master-data.mjs | 2 +- .../admin-test-env-modules/order-inbound-status-fixtures.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/admin-test-env-modules/master-data.mjs b/scripts/admin-test-env-modules/master-data.mjs index 0dfd95793..bfe308b76 100644 --- a/scripts/admin-test-env-modules/master-data.mjs +++ b/scripts/admin-test-env-modules/master-data.mjs @@ -191,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/order-inbound-status-fixtures.mjs b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs index 32128b620..e302e2720 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', From 5d098c97628c129c484ef37c44a59c359ee2bbf9 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 2 Sep 2026 13:59:05 +0800 Subject: [PATCH 9/9] test(acceptance): align retained state fixtures --- ...manual-state-loading-pda-contract.test.mjs | 9 +- .../manual-state-loading-pda.mjs | 97 +++++++++---------- .../phase3-container-customs-fixture.mjs | 6 +- .../phase3-order-splitting.mjs | 55 +++++++---- 4 files changed, 92 insertions(+), 75 deletions(-) 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 68e4197a6..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 @@ -37,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')) @@ -57,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'/) @@ -65,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 6851682de..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) @@ -288,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 } @@ -351,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) { @@ -396,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/phase3-container-customs-fixture.mjs b/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs index 3e0784a70..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', { - username: 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-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index be09b1e0e..6a4eff852 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 } @@ -201,8 +198,20 @@ async function loadOneFragment(context, state) { 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, @@ -419,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 = { @@ -427,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),