Test #1

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