diff --git a/AGENTS.md b/AGENTS.md index fa284389b..76888fedc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,14 @@ - 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。 - 全量验证:`mvn -DskipTests validate`、`mvn test`;单服务优先 `mvn -pl -am test`。不要在本地启动 Java/Nacos/数据库,除非用户明确要求。 +## 全量测试脚本路由 + +- 本地后端静态/单元回归:在 `baiyue-firstleg/` 执行 `pwsh scripts/backend-full-regression.ps1 -DryRun` 先检查覆盖文件,再按需去掉 `-DryRun` 执行 `order + firstleg` Maven 测试;脚本不访问数据库、Gateway 或测试环境。 +- 本地前端回归:在 `baiyue-firstleg/` 执行 `pwsh scripts/frontend-full-regression.ps1 -DryRun` 先检查入口,再按需执行 Admin/Client 的 `pnpm test` 和 `pnpm typecheck`;脚本不启动开发服务器、不访问 Gateway。 +- 测试环境 API 全量验收:`scripts/admin-test-env-acceptance.mjs`、`scripts/client-test-env-acceptance.mjs`、`scripts/phase2-test-env-acceptance.ps1` 及 `scripts/admin-test-env-modules/` 属于有数据写入的场景脚本,会创建测试客户、订单或业务记录;只有用户在当前对话明确授权测试环境验收时才执行,并使用报告目录留存结果。 +- `scripts/local-db-full-acceptance.ps1` 仅允许 `127.0.0.1`/`localhost` 数据库,先用 `-DryRun` 校验统一 SQL 快照和注释门禁;不得改参数连接远程或测试数据库。 +- 验收脚本按“先本地静态回归,再测试环境 API 验收,最后人工浏览器样式验收”顺序使用;脚本通过不等于页面视觉、权限和真实数据链路全部通过。 + ## 阶段文档入口 - 一期:`../docs/一期/README.md`、`../docs/一期/一期头程订单闭环设计.md` diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index f82c3b86f..4aead6e4a 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -4729,12 +4729,16 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item ( CREATE TABLE IF NOT EXISTS fl_container_document ( id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID', - document_type VARCHAR(32) NOT NULL COMMENT '文件类型:CUSTOMS_CLEARANCE清关文件、DELIVERY_ORDER提货单、PICKUP_ORDER取货单', + document_type VARCHAR(32) NOT NULL COMMENT '文件类型:DESTINATION_CLEARANCE目的港清关、CUSTOMS_DECLARATION出口报关、DELIVERY_ORDER提货单、PICKUP_ORDER取货单', file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID,不建立跨库外键', file_name VARCHAR(255) NOT NULL COMMENT '文件名称快照', + file_hash VARCHAR(64) NULL COMMENT 'File服务SHA-256快照', file_url VARCHAR(1000) NULL COMMENT '文件访问地址快照', uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID', uploaded_by_name VARCHAR(64) NULL COMMENT '上传操作人名称快照', + completed_by BIGINT UNSIGNED NULL COMMENT '一键完成操作人ID', + completed_by_name VARCHAR(64) NULL COMMENT '一键完成操作人名称快照', + completed_at DATETIME(3) NULL COMMENT '一键完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '货柜文件关联最后更新时间', @@ -4745,7 +4749,7 @@ CREATE TABLE IF NOT EXISTS fl_container_document ( CONSTRAINT fk_container_document_booking FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id), CONSTRAINT chk_container_document_type - CHECK (document_type IN ('CUSTOMS_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER')) + CHECK (document_type IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')) ) ENGINE=InnoDB COMMENT='平台货柜清关、DO与PU文件关联'; CREATE TABLE IF NOT EXISTS fl_loading_plan_history ( @@ -5128,7 +5132,8 @@ BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.id; - IF current_plan_status = 'COMPLETED' THEN + IF current_plan_status = 'COMPLETED' + AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读,不允许修改'; END IF; IF current_plan_status = 'LOADING' AND ( @@ -5477,7 +5482,8 @@ BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = NEW.container_booking_id; - IF current_plan_status = 'COMPLETED' THEN + IF current_plan_status = 'COMPLETED' + AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增'; END IF; END$$ @@ -11612,7 +11618,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'warehouse_ CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'warehouse_name', '装柜仓库名称快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'container_booking_id', '货柜预订ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'created_at', '创建时间'); -CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'document_type', '文件类型:CUSTOMS_CLEARANCE清关文件、DELIVERY_ORDER提货单、PICKUP_ORDER取货单'); +CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'document_type', '文件类型:DESTINATION_CLEARANCE目的港清关、CUSTOMS_DECLARATION出口报关、DELIVERY_ORDER提货单、PICKUP_ORDER取货单'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'file_id', 'File服务文件ID,不建立跨库外键'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'file_name', '文件名称快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_container_document', 'file_url', '文件访问地址快照'); @@ -14354,6 +14360,58 @@ DELIMITER ; CALL baiyue_iam.assert_notification_center_schema(); DROP PROCEDURE IF EXISTS baiyue_iam.assert_notification_center_schema; +-- 货柜清关与装柜清单报关采用同一份轻量文件事实,不再要求独立报关 case 状态机。 +USE baiyue_firstleg; +DROP PROCEDURE IF EXISTS phase3_simple_customs_add_column; +DELIMITER $$ +CREATE PROCEDURE phase3_simple_customs_add_column(IN p_name VARCHAR(64), IN p_definition TEXT) +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_container_document' + AND column_name = p_name) THEN + SET @ddl = CONCAT('ALTER TABLE fl_container_document ADD COLUMN ', p_name, ' ', p_definition); + PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement; + END IF; +END$$ +DELIMITER ; +CALL phase3_simple_customs_add_column('file_hash', "VARCHAR(64) NULL COMMENT 'File服务SHA-256快照' AFTER file_name"); +CALL phase3_simple_customs_add_column('completed_by', "BIGINT UNSIGNED NULL COMMENT '一键完成操作人ID' AFTER uploaded_by_name"); +CALL phase3_simple_customs_add_column('completed_by_name', "VARCHAR(64) NULL COMMENT '一键完成操作人名称快照' AFTER completed_by"); +CALL phase3_simple_customs_add_column('completed_at', "DATETIME(3) NULL COMMENT '一键完成时间' AFTER completed_by_name"); +DROP PROCEDURE phase3_simple_customs_add_column; + +SET @has_simple_customs_check = (SELECT COUNT(*) FROM information_schema.table_constraints + WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_document' + AND constraint_name='chk_container_document_type' AND constraint_type='CHECK'); +SET @ddl = IF(@has_simple_customs_check=0, 'SELECT 1', + 'ALTER TABLE fl_container_document DROP CHECK chk_container_document_type'); +PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement; +ALTER TABLE fl_container_document ADD CONSTRAINT chk_container_document_type + CHECK (document_type IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION','DELIVERY_ORDER','PICKUP_ORDER')); + +DROP TRIGGER IF EXISTS trg_container_document_before_insert; +DELIMITER $$ +CREATE TRIGGER trg_container_document_before_insert BEFORE INSERT ON fl_container_document FOR EACH ROW +BEGIN + DECLARE current_plan_status VARCHAR(24); + SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id=NEW.container_booking_id; + IF current_plan_status='COMPLETED' AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜完成后货柜物流文件不可新增'; + END IF; +END$$ +DELIMITER ; +DROP TRIGGER IF EXISTS trg_container_document_before_update; +DELIMITER $$ +CREATE TRIGGER trg_container_document_before_update BEFORE UPDATE ON fl_container_document FOR EACH ROW +BEGIN + DECLARE current_plan_status VARCHAR(24); + SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id=OLD.container_booking_id; + IF current_plan_status='COMPLETED' AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE','CUSTOMS_DECLARATION') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='装柜完成后货柜物流文件不可修改'; + END IF; +END$$ +DELIMITER ; + -- 二、三期对外统一使用 INBOUNDED;历史 WAIT_SPLIT 仅由查询适配为 INBOUNDED,统一迁移不改写主表或历史状态。 SELECT 'PASS' AS migration_status, '统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、公共资源及可靠审计结构校验通过' AS message; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingDocumentFileClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingDocumentFileClient.java new file mode 100644 index 000000000..ca2dc02f8 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/LoadingDocumentFileClient.java @@ -0,0 +1,20 @@ +package com.baiyue.firstleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; + +/** Validates that a referenced File object belongs to the exact loading business resource. */ +@FeignClient(name = "file-service", contextId = "firstlegLoadingDocumentFileClient", + url = "${baiyue.clients.file.url:}", path = "/internal/file/v1/business-files") +public interface LoadingDocumentFileClient { + @GetMapping("/{tenantId}/{businessType}/{businessId}/{fileId}") + ApiResponse metadata(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long tenantId, @PathVariable String businessType, + @PathVariable String businessId, @PathVariable long fileId); + + record Metadata(Long fileId, Long tenantId, String businessType, String businessId, + String fileName, String sha256, String contentType, Long sizeBytes) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java index 303515cf5..41fb93736 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java @@ -11,6 +11,10 @@ import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest; import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.service.ContainerService; import com.baiyue.firstleg.service.FirstlegScopeGuard; +import com.baiyue.firstleg.service.LoadingListService; +import com.baiyue.firstleg.dto.LoadingListDtos.BusinessDocument; +import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest; +import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; @@ -25,6 +29,7 @@ import org.springframework.web.bind.annotation.*; @RequestMapping("/api/platform/v1/containers") public class PlatformContainerController { @Resource private ContainerService service; + @Resource private LoadingListService loadingLists; @GetMapping @PreAuthorize("hasAuthority('platform:firstleg:container:list')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) { @@ -60,4 +65,37 @@ public class PlatformContainerController { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.updateTransport(id, user.userId(), user.username(), request)); } + + @GetMapping("/{id}/clearance") + @PreAuthorize("hasAuthority('platform:firstleg:container:view')") + @Operation(summary = "查询货柜目的港清关资料") + public ApiResponse clearance(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long id) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(loadingLists.containerClearance(id)); + } + + @PostMapping("/{id}/clearance/document") + @PreAuthorize("hasAuthority('platform:firstleg:container:edit')") + @Operation(summary = "上传或替换货柜目的港清关资料") + public ApiResponse attachClearance(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long id, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody DocumentAttachRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(loadingLists.attachContainerClearance( + id, user.userId(), user.username(), key, request)); + } + + @PostMapping("/{id}/clearance/complete") + @PreAuthorize("hasAuthority('platform:firstleg:container:edit')") + @Operation(summary = "确认货柜目的港清关完成") + public ApiResponse completeClearance(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long id, + @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody DocumentCompleteRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(loadingLists.completeContainerClearance( + id, user.userId(), user.username(), key, request)); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java index 11e3e0f20..ecf81f30e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java @@ -10,6 +10,9 @@ import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary; import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest; +import com.baiyue.firstleg.dto.LoadingListDtos.BusinessDocument; +import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest; +import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest; import com.baiyue.firstleg.service.FirstlegScopeGuard; import com.baiyue.firstleg.service.LoadingListService; import io.swagger.v3.oas.annotations.Operation; @@ -22,6 +25,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; @@ -89,4 +93,38 @@ public class PlatformLoadingListController { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.confirmedDetail(planId, warehouseId)); } + + @Operation(summary = "查询装柜清单出口报关文档") + @GetMapping("/loading-lists/{planId}/customs") + @PreAuthorize("hasAuthority('platform:firstleg:loading-list:view')") + public ApiResponse declaration( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId, + @RequestParam @Positive long warehouseId) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.loadingDeclaration(planId, warehouseId)); + } + + @Operation(summary = "上传或替换装柜清单出口报关文档") + @PostMapping("/loading-lists/{planId}/customs/document") + @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") + public ApiResponse attachDeclaration( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId, + @RequestParam @Positive long warehouseId, @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody DocumentAttachRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.attachLoadingDeclaration( + planId, warehouseId, user.userId(), user.username(), key, request)); + } + + @Operation(summary = "确认装柜清单出口报关完成") + @PostMapping("/loading-lists/{planId}/customs/complete") + @PreAuthorize("hasAuthority('platform:firstleg:customs:manage')") + public ApiResponse completeDeclaration( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId, + @RequestParam @Positive long warehouseId, @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody DocumentCompleteRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.completeLoadingDeclaration( + planId, warehouseId, user.userId(), user.username(), key, request)); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java index 9e956c704..f72262371 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/LoadingListDtos.java @@ -3,6 +3,7 @@ package com.baiyue.firstleg.dto; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; import java.math.BigDecimal; @@ -57,6 +58,26 @@ public final class LoadingListDtos { ) { } + public record DocumentAttachRequest( + @NotNull @Positive Long fileId, + @NotNull @Min(0) Integer expectedVersion, + @Size(max = 500) String operationReason + ) { + } + + public record DocumentCompleteRequest( + @NotNull @Min(0) Integer expectedVersion, + @Size(max = 500) String operationReason + ) { + } + + public record BusinessDocument( + Long id, String documentType, Long fileId, String fileName, String fileHash, + Long uploadedBy, String uploadedByName, LocalDateTime uploadedAt, + Long completedBy, String completedByName, LocalDateTime completedAt, Integer version + ) { + } + public record Customer(Long accountId, String customerName) { } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerDocumentEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerDocumentEntity.java new file mode 100644 index 000000000..407a00e70 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerDocumentEntity.java @@ -0,0 +1,24 @@ +package com.baiyue.firstleg.entity; + +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +public class ContainerDocumentEntity { + private Long id; + private Long containerBookingId; + private String documentType; + private Long fileId; + private String fileName; + private String fileHash; + private Long uploadedBy; + private String uploadedByName; + private LocalDateTime createdAt; + private Long completedBy; + private String completedByName; + private LocalDateTime completedAt; + private Integer version; +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java index ea72a2c10..1006e5c2a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java @@ -3,6 +3,7 @@ package com.baiyue.firstleg.mapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; +import com.baiyue.firstleg.entity.ContainerDocumentEntity; import lombok.Getter; import lombok.Setter; import org.apache.ibatis.annotations.Insert; @@ -99,6 +100,59 @@ public interface LoadingListQueryMapper { """ + AGGREGATE_GROUP) AggregateRow selectAggregate(@Param("planId") long planId); + @Select(""" + SELECT + """ + AGGREGATE_COLUMNS + """ + FROM fl_loading_plan p + JOIN fl_container_booking b ON b.id = p.container_booking_id + LEFT JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0 + WHERE p.container_booking_id = #{bookingId} + GROUP BY + """ + AGGREGATE_GROUP) + AggregateRow selectAggregateByBooking(@Param("bookingId") long bookingId); + + @Select(""" + SELECT id, container_booking_id, document_type, file_id, file_name, file_hash, + uploaded_by, uploaded_by_name, created_at, completed_by, completed_by_name, + completed_at, version + FROM fl_container_document + WHERE container_booking_id = #{bookingId} AND document_type = #{documentType} + """) + ContainerDocumentEntity selectDocument(@Param("bookingId") long bookingId, + @Param("documentType") String documentType); + + @Insert(""" + INSERT INTO fl_container_document + (id, container_booking_id, document_type, file_id, file_name, file_hash, + uploaded_by, uploaded_by_name, created_at, updated_at, version) + VALUES + (#{id}, #{containerBookingId}, #{documentType}, #{fileId}, #{fileName}, #{fileHash}, + #{uploadedBy}, #{uploadedByName}, CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3), 0) + """) + int insertDocument(ContainerDocumentEntity value); + + @Update(""" + UPDATE fl_container_document + SET file_id = #{fileId}, file_name = #{fileName}, file_hash = #{fileHash}, + uploaded_by = #{uploadedBy}, uploaded_by_name = #{uploadedByName}, + completed_by = NULL, completed_by_name = NULL, completed_at = NULL, + updated_at = CURRENT_TIMESTAMP(3), version = version + 1 + WHERE id = #{id} AND version = #{version} + """) + int replaceDocument(ContainerDocumentEntity value); + + @Update(""" + UPDATE fl_container_document + SET completed_by = #{operatorId}, completed_by_name = #{operatorName}, + completed_at = CURRENT_TIMESTAMP(3), updated_at = CURRENT_TIMESTAMP(3), + version = version + 1 + WHERE id = #{documentId} AND version = #{expectedVersion} AND completed_at IS NULL + """) + int completeDocument(@Param("documentId") long documentId, + @Param("expectedVersion") int expectedVersion, + @Param("operatorId") long operatorId, + @Param("operatorName") String operatorName); + @Select(""" SELECT id AS item_id, account_id, customer_name, inbound_id, inbound_detail_id, inbound_code, sku_code, destination_warehouse_name, product_name_zh, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java index 8cf96ba66..d190c79ba 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingListRepository.java @@ -12,6 +12,7 @@ import com.baiyue.firstleg.dto.LoadingListDtos.Item; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary; +import com.baiyue.firstleg.entity.ContainerDocumentEntity; import com.baiyue.firstleg.mapper.LoadingListQueryMapper; import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow; import com.baiyue.firstleg.mapper.LoadingListQueryMapper.CustomerRow; @@ -50,6 +51,14 @@ public class LoadingListRepository { return mapper.selectAggregate(planId); } + public AggregateRow aggregateByBooking(long bookingId) { return mapper.selectAggregateByBooking(bookingId); } + public ContainerDocumentEntity document(long bookingId, String type) { return mapper.selectDocument(bookingId, type); } + public int createDocument(ContainerDocumentEntity value) { return mapper.insertDocument(value); } + public int replaceDocument(ContainerDocumentEntity value) { return mapper.replaceDocument(value); } + public int completeDocument(long id, int version, long operatorId, String operatorName) { + return mapper.completeDocument(id, version, operatorId, operatorName); + } + public PreloadDetail preloadDetail(AggregateRow row) { List items = mapper.selectItems(row.getPlanId()); List customers = items.stream() diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java index 33a64875d..3d63331b9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingListService.java @@ -11,6 +11,11 @@ import com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadSummary; import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest; +import com.baiyue.firstleg.dto.LoadingListDtos.BusinessDocument; +import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest; +import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest; +import com.baiyue.firstleg.client.LoadingDocumentFileClient; +import com.baiyue.firstleg.entity.ContainerDocumentEntity; import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow; import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow; import com.baiyue.firstleg.repository.LoadingListRepository; @@ -18,6 +23,10 @@ import com.baiyue.firstleg.repository.LoadingPickRepository; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import feign.FeignException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -33,12 +42,20 @@ import java.util.Set; @Service public class LoadingListService { private static final Set PRELOAD_STATUSES = Set.of("EDITING", "LOADING"); + public static final String DESTINATION_CLEARANCE = "DESTINATION_CLEARANCE"; + public static final String CUSTOMS_DECLARATION = "CUSTOMS_DECLARATION"; + private static final String CLEARANCE_FILE_TYPE = "CONTAINER_CLEARANCE_DOCUMENT"; + private static final String DECLARATION_FILE_TYPE = "LOADING_CUSTOMS_DOCUMENT"; @Resource private LoadingListRepository repository; @Resource private LoadingPickRepository pickRepository; @Resource private ObjectMapper objectMapper; + @Resource + private LoadingDocumentFileClient documentFiles; + @Value("${baiyue.security.internal-service-token:}") + private String internalToken; public PageResult preloadPage(PreloadQuery query) { validateRange(query.createdFrom(), query.createdTo()); @@ -74,6 +91,182 @@ public class LoadingListService { return repository.confirmedDetail(row); } + public BusinessDocument containerClearance(long bookingId) { + requireBookingPlan(bookingId); + return document(repository.document(bookingId, DESTINATION_CLEARANCE)); + } + + @Transactional + @BusinessAudit(event = "CONTAINER_CLEARANCE_DOCUMENT_ATTACH", resourceType = "CONTAINER", + resourceId = "#bookingId", description = "上传目的港清关资料") + public BusinessDocument attachContainerClearance(long bookingId, long operatorId, String operatorName, + String key, DocumentAttachRequest request) { + AggregateRow plan = requireBookingPlan(bookingId); + return attachDocument(plan, DESTINATION_CLEARANCE, CLEARANCE_FILE_TYPE, + Long.toString(bookingId), operatorId, operatorName, key, request); + } + + @Transactional + @BusinessAudit(event = "CONTAINER_CLEARANCE_COMPLETE", resourceType = "CONTAINER", + resourceId = "#bookingId", description = "确认目的港清关完成") + public BusinessDocument completeContainerClearance(long bookingId, long operatorId, String operatorName, + String key, DocumentCompleteRequest request) { + return completeDocument(requireBookingPlan(bookingId), DESTINATION_CLEARANCE, + operatorId, operatorName, key, request); + } + + public BusinessDocument loadingDeclaration(long planId, long warehouseId) { + AggregateRow plan = requirePlan(planId); + requireWarehouse(plan, warehouseId); + requireCompletedPlan(plan); + return document(repository.document(plan.getContainerBookingId(), CUSTOMS_DECLARATION)); + } + + @Transactional + @BusinessAudit(event = "LOADING_CUSTOMS_DOCUMENT_ATTACH", resourceType = "LOADING_PLAN", + resourceId = "#planId", description = "上传出口报关文档") + public BusinessDocument attachLoadingDeclaration(long planId, long warehouseId, long operatorId, + String operatorName, String key, + DocumentAttachRequest request) { + AggregateRow plan = requirePlan(planId); + requireWarehouse(plan, warehouseId); + requireCompletedPlan(plan); + return attachDocument(plan, CUSTOMS_DECLARATION, DECLARATION_FILE_TYPE, + Long.toString(planId), operatorId, operatorName, key, request); + } + + @Transactional + @BusinessAudit(event = "LOADING_CUSTOMS_COMPLETE", resourceType = "LOADING_PLAN", + resourceId = "#planId", description = "确认出口报关完成") + public BusinessDocument completeLoadingDeclaration(long planId, long warehouseId, long operatorId, + String operatorName, String key, + DocumentCompleteRequest request) { + AggregateRow plan = requirePlan(planId); + requireWarehouse(plan, warehouseId); + requireCompletedPlan(plan); + return completeDocument(plan, CUSTOMS_DECLARATION, operatorId, operatorName, key, request); + } + + private BusinessDocument attachDocument(AggregateRow plan, String documentType, String businessType, + String businessId, long operatorId, String operatorName, + String key, DocumentAttachRequest request) { + String requestHash = sha256("ATTACH|" + documentType + '|' + plan.getPlanId() + '|' + + request.fileId() + '|' + request.expectedVersion() + '|' + trim(request.operationReason())); + String operationId = operationId("ATTACH_" + documentType, plan.getPlanId(), key); + HistoryRow replay = repository.history(operationId); + if (replay != null) { + requireSameRequest(replay, requestHash); + return requireDocument(plan.getContainerBookingId(), documentType); + } + ContainerDocumentEntity current = repository.document(plan.getContainerBookingId(), documentType); + if (current == null && request.expectedVersion() != 0) throw conflict("资料版本已变化,请刷新后重试"); + if (current != null && !java.util.Objects.equals(current.getVersion(), request.expectedVersion())) { + throw conflict("资料版本已变化,请刷新后重试"); + } + if (current != null && current.getCompletedAt() != null) throw conflict("已完成的资料不可替换"); + LoadingDocumentFileClient.Metadata metadata = validateDocumentFile( + businessType, businessId, request.fileId()); + Map beforeSnapshot = documentSnapshot(current); + ContainerDocumentEntity value = current == null ? new ContainerDocumentEntity() : current; + if (current == null) value.setId(IdWorker.getId()); + value.setContainerBookingId(plan.getContainerBookingId()); value.setDocumentType(documentType); + value.setFileId(metadata.fileId()); value.setFileName(metadata.fileName()); value.setFileHash(metadata.sha256()); + value.setUploadedBy(operatorId); value.setUploadedByName(trim(operatorName)); + try { + int changed = current == null ? repository.createDocument(value) : repository.replaceDocument(value); + if (changed != 1) throw conflict("资料版本已变化,请刷新后重试"); + } catch (DuplicateKeyException exception) { + throw conflict("资料已被其他操作上传,请刷新后重试"); + } + ContainerDocumentEntity saved = requireDocumentEntity(plan.getContainerBookingId(), documentType); + repository.appendHistory(plan.getPlanId(), operationId, "ATTACH_" + documentType, + plan.getPlanStatus(), plan.getPlanStatus(), json(beforeSnapshot), + json(values("requestHash", requestHash, "document", documentSnapshot(saved))), + operatorId, trim(operatorName), trim(request.operationReason())); + return document(saved); + } + + private BusinessDocument completeDocument(AggregateRow plan, String documentType, long operatorId, + String operatorName, String key, + DocumentCompleteRequest request) { + String requestHash = sha256("COMPLETE|" + documentType + '|' + plan.getPlanId() + '|' + + request.expectedVersion() + '|' + trim(request.operationReason())); + String operationId = operationId("COMPLETE_" + documentType, plan.getPlanId(), key); + HistoryRow replay = repository.history(operationId); + if (replay != null) { + requireSameRequest(replay, requestHash); + return requireDocument(plan.getContainerBookingId(), documentType); + } + ContainerDocumentEntity current = requireDocumentEntity(plan.getContainerBookingId(), documentType); + if (current.getCompletedAt() != null) { + if (!java.util.Objects.equals(current.getVersion(), request.expectedVersion())) { + throw conflict("资料版本已变化,请刷新后重试"); + } + return document(current); + } + if (repository.completeDocument(current.getId(), request.expectedVersion(), operatorId, + trim(operatorName)) != 1) throw conflict("资料版本已变化,请刷新后重试"); + ContainerDocumentEntity saved = requireDocumentEntity(plan.getContainerBookingId(), documentType); + repository.appendHistory(plan.getPlanId(), operationId, "COMPLETE_" + documentType, + plan.getPlanStatus(), plan.getPlanStatus(), json(documentSnapshot(current)), + json(values("requestHash", requestHash, "document", documentSnapshot(saved))), + operatorId, trim(operatorName), trim(request.operationReason())); + return document(saved); + } + + private LoadingDocumentFileClient.Metadata validateDocumentFile(String businessType, String businessId, + long fileId) { + try { + var response = documentFiles.metadata(internalToken, 0, businessType, businessId, fileId); + if (response == null || !response.successful() || response.data() == null + || !Long.valueOf(fileId).equals(response.data().fileId()) + || !Long.valueOf(0).equals(response.data().tenantId()) + || !businessType.equals(response.data().businessType()) + || !businessId.equals(response.data().businessId()) + || !StringUtils.hasText(response.data().fileName()) + || !StringUtils.hasText(response.data().sha256())) { + throw badRequest("附件不存在、归属不匹配或文件元数据不完整"); + } + return response.data(); + } catch (FeignException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用"); + } + } + + private AggregateRow requireBookingPlan(long bookingId) { + AggregateRow row = repository.aggregateByBooking(bookingId); + if (row == null) throw notFound("货柜或关联装柜计划不存在"); + return row; + } + + private static void requireCompletedPlan(AggregateRow plan) { + if (!"COMPLETED".equals(plan.getPlanStatus())) throw conflict("仅完成装柜清单可办理报关"); + } + + private ContainerDocumentEntity requireDocumentEntity(long bookingId, String documentType) { + ContainerDocumentEntity value = repository.document(bookingId, documentType); + if (value == null) throw conflict("请先上传资料"); + return value; + } + + private BusinessDocument requireDocument(long bookingId, String documentType) { + return document(requireDocumentEntity(bookingId, documentType)); + } + + private static BusinessDocument document(ContainerDocumentEntity value) { + return value == null ? null : new BusinessDocument(value.getId(), value.getDocumentType(), value.getFileId(), + value.getFileName(), value.getFileHash(), value.getUploadedBy(), value.getUploadedByName(), + value.getCreatedAt(), value.getCompletedBy(), value.getCompletedByName(), value.getCompletedAt(), + value.getVersion()); + } + + private static Map documentSnapshot(ContainerDocumentEntity value) { + return value == null ? Map.of() : values("id", value.getId(), "type", value.getDocumentType(), + "fileId", value.getFileId(), "fileName", value.getFileName(), "fileHash", value.getFileHash(), + "completedAt", value.getCompletedAt() == null ? null : value.getCompletedAt().toString(), + "version", value.getVersion()); + } + @Transactional @BusinessAudit(event = "PRELOAD_TRANSPORT_UPDATE", resourceType = "LOADING_PLAN", resourceId = "#planId", description = "修改预装集装箱号与航次") diff --git a/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql b/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql new file mode 100644 index 000000000..9254149d5 --- /dev/null +++ b/firstleg/src/main/resources/db/phase3-simple-customs-document-migration.sql @@ -0,0 +1,57 @@ +USE baiyue_firstleg; + +DROP PROCEDURE IF EXISTS add_simple_customs_column; +DELIMITER $$ +CREATE PROCEDURE add_simple_customs_column(IN p_name VARCHAR(64), IN p_definition TEXT) +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'fl_container_document' + AND column_name = p_name) THEN + SET @ddl = CONCAT('ALTER TABLE fl_container_document ADD COLUMN ', p_name, ' ', p_definition); + PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement; + END IF; +END$$ +DELIMITER ; + +CALL add_simple_customs_column('file_hash', "VARCHAR(64) NULL COMMENT 'File服务SHA-256快照' AFTER file_name"); +CALL add_simple_customs_column('completed_by', "BIGINT UNSIGNED NULL COMMENT '一键完成操作人ID' AFTER uploaded_by_name"); +CALL add_simple_customs_column('completed_by_name', "VARCHAR(64) NULL COMMENT '一键完成操作人名称快照' AFTER completed_by"); +CALL add_simple_customs_column('completed_at', "DATETIME(3) NULL COMMENT '一键完成时间' AFTER completed_by_name"); +DROP PROCEDURE add_simple_customs_column; + +SET @old_check = (SELECT CONSTRAINT_NAME FROM information_schema.table_constraints + WHERE table_schema = DATABASE() AND table_name = 'fl_container_document' + AND constraint_type = 'CHECK' AND constraint_name = 'chk_container_document_type'); +SET @ddl = IF(@old_check IS NULL, 'SELECT 1', + 'ALTER TABLE fl_container_document DROP CHECK chk_container_document_type'); +PREPARE statement FROM @ddl; EXECUTE statement; DEALLOCATE PREPARE statement; +ALTER TABLE fl_container_document ADD CONSTRAINT chk_container_document_type + CHECK (document_type IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')); + +DROP TRIGGER IF EXISTS trg_container_document_before_insert; +DELIMITER $$ +CREATE TRIGGER trg_container_document_before_insert BEFORE INSERT ON fl_container_document FOR EACH ROW +BEGIN + DECLARE current_plan_status VARCHAR(24); + SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan + WHERE container_booking_id = NEW.container_booking_id; + IF current_plan_status = 'COMPLETED' + AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜物流文件不可新增'; + END IF; +END$$ +DELIMITER ; + +DROP TRIGGER IF EXISTS trg_container_document_before_update; +DELIMITER $$ +CREATE TRIGGER trg_container_document_before_update BEFORE UPDATE ON fl_container_document FOR EACH ROW +BEGIN + DECLARE current_plan_status VARCHAR(24); + SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan + WHERE container_booking_id = OLD.container_booking_id; + IF current_plan_status = 'COMPLETED' + AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜物流文件不可修改'; + END IF; +END$$ +DELIMITER ; diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 29b8e54ae..84263b880 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -180,15 +180,20 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_item ( CONSTRAINT chk_loading_item_deleted CHECK (deleted IN (0, 1)) ) ENGINE=InnoDB COMMENT='智能装柜客户货物明细;客户归属仅在明细层'; +-- 目的港清关和出口报关均为柜次级简单文件事实:上传资料后由平台一键完成。 CREATE TABLE IF NOT EXISTS fl_container_document ( id BIGINT UNSIGNED NOT NULL COMMENT '货柜文件关联ID(雪花ID)', container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID', - document_type VARCHAR(32) NOT NULL COMMENT '文件类型:CUSTOMS_CLEARANCE清关文件、DELIVERY_ORDER提货单、PICKUP_ORDER取货单', + document_type VARCHAR(32) NOT NULL COMMENT '文件类型:DESTINATION_CLEARANCE目的港清关、CUSTOMS_DECLARATION出口报关、DELIVERY_ORDER提货单、PICKUP_ORDER取货单', file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务文件ID,不建立跨库外键', file_name VARCHAR(255) NOT NULL COMMENT '文件名称快照', + file_hash VARCHAR(64) NULL COMMENT 'File服务SHA-256快照', file_url VARCHAR(1000) NULL COMMENT '文件访问地址快照', uploaded_by BIGINT UNSIGNED NOT NULL COMMENT '上传操作人ID', uploaded_by_name VARCHAR(64) NULL COMMENT '上传操作人名称快照', + completed_by BIGINT UNSIGNED NULL COMMENT '一键完成操作人ID', + completed_by_name VARCHAR(64) NULL COMMENT '一键完成操作人名称快照', + completed_at DATETIME(3) NULL COMMENT '一键完成时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '货柜文件关联最后更新时间', version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', @@ -198,7 +203,7 @@ CREATE TABLE IF NOT EXISTS fl_container_document ( CONSTRAINT fk_container_document_booking FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id), CONSTRAINT chk_container_document_type - CHECK (document_type IN ('CUSTOMS_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER')) + CHECK (document_type IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')) ) ENGINE=InnoDB COMMENT='平台货柜清关、DO与PU文件关联'; CREATE TABLE IF NOT EXISTS fl_loading_plan_history ( @@ -557,7 +562,8 @@ BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = OLD.id; - IF current_plan_status = 'COMPLETED' THEN + IF current_plan_status = 'COMPLETED' + AND NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读,不允许修改'; END IF; IF current_plan_status = 'LOADING' AND ( @@ -920,7 +926,8 @@ BEGIN DECLARE current_plan_status VARCHAR(24); SELECT MAX(plan_status) INTO current_plan_status FROM fl_loading_plan WHERE container_booking_id = NEW.container_booking_id; - IF current_plan_status = 'COMPLETED' THEN + IF current_plan_status = 'COMPLETED' + AND OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜文件不可新增'; END IF; END$$ diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 835a4ff6c..f469543d6 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -243,6 +243,27 @@ paths: responses: '200': {description: 成功或状态、版本、重复号冲突} '403': {description: 无货柜编辑权限} + /api/platform/v1/containers/{id}/clearance: + get: + summary: 查询货柜目的港清关资料 + parameters: [{$ref: '#/components/parameters/ContainerBookingId'}] + responses: {'200': {description: 成功或货柜不存在}} + /api/platform/v1/containers/{id}/clearance/document: + post: + summary: 上传或替换货柜目的港清关资料 + parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/ContainerDocumentAttachRequest'}}} + responses: {'200': {description: 成功、文件归属不匹配或版本冲突}} + /api/platform/v1/containers/{id}/clearance/complete: + post: + summary: 点击完成目的港清关 + parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/ContainerDocumentCompleteRequest'}}} + responses: {'200': {description: 成功、未上传资料或版本冲突}} /api/platform/v1/first-leg/smart-loading/plans/by-booking/{bookingId}: get: summary: 按货柜恢复智能装柜草稿 @@ -1034,6 +1055,19 @@ components: voyageNo: {type: string, maxLength: 64, nullable: true} version: {type: integer, minimum: 0} reason: {type: string, maxLength: 500} + ContainerDocumentAttachRequest: + type: object + required: [fileId, expectedVersion] + properties: + fileId: {type: string, pattern: '^[1-9][0-9]*$'} + expectedVersion: {type: integer, minimum: 0} + operationReason: {type: string, maxLength: 500, nullable: true} + ContainerDocumentCompleteRequest: + type: object + required: [expectedVersion] + properties: + expectedVersion: {type: integer, minimum: 0} + operationReason: {type: string, maxLength: 500, nullable: true} MerchantInboundBillingEnvelope: type: object properties: diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index c24aefa6c..f9404e5b4 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -68,6 +68,33 @@ paths: parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}] responses: '200': {$ref: '#/components/responses/ConfirmedDetailResponse'} + /api/platform/v1/first-leg/loading-lists/{planId}/customs: + get: + summary: 查询装柜清单出口报关文档 + x-permission: platform:firstleg:loading-list:view + parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}] + responses: + '200': {$ref: '#/components/responses/BusinessDocumentResponse'} + /api/platform/v1/first-leg/loading-lists/{planId}/customs/document: + post: + summary: 上传或替换装柜清单出口报关文档 + x-permission: platform:firstleg:customs:manage + parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentAttachRequest'}}} + responses: + '200': {$ref: '#/components/responses/BusinessDocumentResponse'} + /api/platform/v1/first-leg/loading-lists/{planId}/customs/complete: + post: + summary: 点击完成出口报关 + x-permission: platform:firstleg:customs:manage + parameters: [{$ref: '#/components/parameters/PlanId'}, {$ref: '#/components/parameters/WarehouseId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/DocumentCompleteRequest'}}} + responses: + '200': {$ref: '#/components/responses/BusinessDocumentResponse'} components: parameters: PlanId: @@ -132,6 +159,9 @@ components: ConfirmedDetailResponse: description: 装柜清单详情查询或确认成功,也可能返回业务失败 content: {application/json: {schema: {$ref: '#/components/schemas/ConfirmedDetailEnvelope'}}} + BusinessDocumentResponse: + description: 报关文档查询或操作结果 + content: {application/json: {schema: {$ref: '#/components/schemas/BusinessDocumentEnvelope'}}} schemas: LongId: type: string @@ -182,6 +212,14 @@ components: data: allOf: [{$ref: '#/components/schemas/ConfirmedDetail'}] nullable: true + BusinessDocumentEnvelope: + allOf: + - {$ref: '#/components/schemas/ApiEnvelope'} + - type: object + properties: + data: + allOf: [{$ref: '#/components/schemas/BusinessDocument'}] + nullable: true PreloadPage: type: object required: [records, page, size, total, pages] @@ -319,3 +357,34 @@ components: voyageNo: {type: string, maxLength: 64, nullable: true, description: 航次} expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前乐观锁版本} operationReason: {type: string, maxLength: 500, nullable: true, description: 操作原因} + DocumentAttachRequest: + type: object + required: [fileId, expectedVersion] + additionalProperties: false + properties: + fileId: {$ref: '#/components/schemas/LongId'} + expectedVersion: {type: integer, format: int32, minimum: 0} + operationReason: {type: string, maxLength: 500, nullable: true} + DocumentCompleteRequest: + type: object + required: [expectedVersion] + additionalProperties: false + properties: + expectedVersion: {type: integer, format: int32, minimum: 0} + operationReason: {type: string, maxLength: 500, nullable: true} + BusinessDocument: + type: object + required: [id, documentType, fileId, fileName, fileHash, uploadedBy, uploadedAt, version] + properties: + id: {$ref: '#/components/schemas/LongId'} + documentType: {type: string, enum: [CUSTOMS_DECLARATION]} + fileId: {$ref: '#/components/schemas/LongId'} + fileName: {type: string} + fileHash: {type: string, pattern: '^[0-9a-fA-F]{64}$'} + uploadedBy: {$ref: '#/components/schemas/LongId'} + uploadedByName: {type: string, nullable: true} + uploadedAt: {type: string, format: date-time} + completedBy: {$ref: '#/components/schemas/NullableLongId'} + completedByName: {type: string, nullable: true} + completedAt: {type: string, format: date-time, nullable: true} + version: {type: integer, format: int32, minimum: 0} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java index 3aaa9655c..6638e2d3a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -5,6 +5,8 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest; +import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest; +import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest; import com.baiyue.firstleg.service.LoadingListService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -32,6 +34,11 @@ class LoadingListControllerContractTest { authority("confirmedPage", "platform:firstleg:loading-list:list", AuthenticatedUser.class, ConfirmedQuery.class); authority("confirmedDetail", "platform:firstleg:loading-list:view", AuthenticatedUser.class, long.class, long.class); + authority("declaration", "platform:firstleg:loading-list:view", AuthenticatedUser.class, long.class, long.class); + authority("attachDeclaration", "platform:firstleg:customs:manage", AuthenticatedUser.class, + long.class, long.class, String.class, DocumentAttachRequest.class); + authority("completeDeclaration", "platform:firstleg:customs:manage", AuthenticatedUser.class, + long.class, long.class, String.class, DocumentCompleteRequest.class); } @Test @@ -91,13 +98,17 @@ class LoadingListControllerContractTest { "/api/platform/v1/first-leg/loading-plans/{planId}", "/api/platform/v1/first-leg/loading-plans/{planId}/transport", "/api/platform/v1/first-leg/loading-lists", - "/api/platform/v1/first-leg/loading-lists/{planId}"); + "/api/platform/v1/first-leg/loading-lists/{planId}", + "/api/platform/v1/first-leg/loading-lists/{planId}/customs", + "/api/platform/v1/first-leg/loading-lists/{planId}/customs/document", + "/api/platform/v1/first-leg/loading-lists/{planId}/customs/complete"); Map components = stringMap(document.get("components")); Map schemas = stringMap(components.get("schemas")); assertThat(schemas).containsKeys( "LongId", "PreloadPage", "ConfirmedPage", "Customer", "PreloadSummary", "ConfirmedSummary", "LoadingItem", "OperationHistory", "PreloadDetail", - "ConfirmedDetail", "TransportUpdateRequest"); + "ConfirmedDetail", "TransportUpdateRequest", "BusinessDocumentEnvelope", + "DocumentAttachRequest", "DocumentCompleteRequest", "BusinessDocument"); assertThat(paths) .doesNotContainKey("/api/platform/v1/first-leg/loading-plans/{planId}/complete"); assertThat(stringMap(schemas.get("LongId"))).containsEntry("type", "string"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java index 9d4c31fb4..3caa2ca0c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java @@ -6,6 +6,11 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery; import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery; import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest; +import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest; +import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest; +import com.baiyue.firstleg.client.LoadingDocumentFileClient; +import com.baiyue.firstleg.entity.ContainerDocumentEntity; +import com.baiyue.common.core.api.ApiResponse; import com.baiyue.firstleg.mapper.LoadingListQueryMapper.AggregateRow; import com.baiyue.firstleg.mapper.LoadingListQueryMapper.HistoryRow; import com.baiyue.firstleg.repository.LoadingListRepository; @@ -38,6 +43,8 @@ class LoadingListServiceTest { private LoadingListRepository repository; @MockBean private LoadingPickRepository pickRepository; + @MockBean + private LoadingDocumentFileClient documentFiles; @Resource private LoadingListService service; @@ -189,6 +196,116 @@ class LoadingListServiceTest { .isEqualTo(ResultCode.NOT_FOUND); } + @Test + void attachesRealContainerClearanceFileAndWritesImmutableHistory() { + AggregateRow plan = row("LOADING", 2); + plan.setContainerBookingId(30L); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(null); + when(repository.document(30L, LoadingListService.DESTINATION_CLEARANCE)) + .thenReturn(null, document(40L, LoadingListService.DESTINATION_CLEARANCE, 0, false)); + when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"), + eq("30"), eq(99L))).thenReturn(ApiResponse.success(new LoadingDocumentFileClient.Metadata( + 99L, 0L, "CONTAINER_CLEARANCE_DOCUMENT", "30", "clearance.pdf", "abc", "application/pdf", 20L))); + when(repository.createDocument(org.mockito.ArgumentMatchers.any())).thenReturn(1); + + var result = service.attachContainerClearance(30L, 9L, "operator", "key-clearance", + new DocumentAttachRequest(99L, 0, "上传清关资料")); + + assertThat(result.fileName()).isEqualTo("clearance.pdf"); + verify(repository).appendHistory(eq(10L), anyString(), eq("ATTACH_DESTINATION_CLEARANCE"), + eq("LOADING"), eq("LOADING"), anyString(), anyString(), eq(9L), eq("operator"), + eq("上传清关资料")); + } + + @Test + void rejectsDeclarationBeforeLoadingListIsCompleted() { + AggregateRow plan = row("LOADING", 2); + plan.setContainerBookingId(30L); + when(repository.aggregate(10L)).thenReturn(plan); + + assertThatThrownBy(() -> service.attachLoadingDeclaration(10L, 100L, 9L, "operator", "key", + new DocumentAttachRequest(99L, 0, null))) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + verify(documentFiles, never()).metadata(anyString(), eq(0L), anyString(), anyString(), eq(99L)); + } + + @Test + void rejectsFileMetadataFromAnotherBusinessResource() { + AggregateRow plan = row("LOADING", 2); + plan.setContainerBookingId(30L); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(null); + when(repository.document(30L, LoadingListService.DESTINATION_CLEARANCE)).thenReturn(null); + when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"), + eq("30"), eq(99L))).thenReturn(ApiResponse.success(new LoadingDocumentFileClient.Metadata( + 99L, 0L, "CONTAINER_CLEARANCE_DOCUMENT", "different-booking", "clearance.pdf", "abc", + "application/pdf", 20L))); + + assertThatThrownBy(() -> service.attachContainerClearance(30L, 9L, "operator", "key", + new DocumentAttachRequest(99L, 0, null))) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.BAD_REQUEST); + verify(repository, never()).createDocument(org.mockito.ArgumentMatchers.any()); + } + + @Test + void completesDeclarationOnlyWhenDocumentVersionMatches() { + AggregateRow plan = row("COMPLETED", 2); + plan.setContainerBookingId(30L); + ContainerDocumentEntity before = document(40L, LoadingListService.CUSTOMS_DECLARATION, 3, false); + ContainerDocumentEntity after = document(40L, LoadingListService.CUSTOMS_DECLARATION, 4, true); + when(repository.aggregate(10L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(null); + when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)).thenReturn(before, after); + when(repository.completeDocument(40L, 3, 9L, "operator")).thenReturn(1); + + var result = service.completeLoadingDeclaration(10L, 100L, 9L, "operator", "complete-key", + new DocumentCompleteRequest(3, "点击报关")); + + assertThat(result.completedAt()).isNotNull(); + verify(repository).appendHistory(eq(10L), anyString(), eq("COMPLETE_CUSTOMS_DECLARATION"), + eq("COMPLETED"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"), + eq("点击报关")); + } + + @Test + void rejectsCompleteWithoutUploadedFile() { + AggregateRow plan = row("LOADING", 2); + plan.setContainerBookingId(30L); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(null); + when(repository.document(30L, LoadingListService.DESTINATION_CLEARANCE)).thenReturn(null); + + assertThatThrownBy(() -> service.completeContainerClearance(30L, 9L, "operator", "key", + new DocumentCompleteRequest(0, null))) + .isInstanceOf(BizException.class) + .extracting(error -> ((BizException) error).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + verify(repository, never()).completeDocument(org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.anyInt(), eq(9L), anyString()); + } + + @Test + void repeatedCompleteWithCurrentVersionIsNaturallyIdempotent() { + AggregateRow plan = row("LOADING", 2); + plan.setContainerBookingId(30L); + ContainerDocumentEntity completed = document(40L, LoadingListService.DESTINATION_CLEARANCE, 4, true); + when(repository.aggregateByBooking(30L)).thenReturn(plan); + when(repository.history(anyString())).thenReturn(null); + when(repository.document(30L, LoadingListService.DESTINATION_CLEARANCE)).thenReturn(completed); + + var result = service.completeContainerClearance(30L, 9L, "operator", "new-key", + new DocumentCompleteRequest(4, null)); + + assertThat(result.completedAt()).isNotNull(); + verify(repository, never()).completeDocument(org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.anyInt(), eq(9L), anyString()); + } + private static AggregateRow row(String status, int version) { AggregateRow row = new AggregateRow(); row.setPlanId(10L); @@ -198,4 +315,14 @@ class LoadingListServiceTest { row.setVersion(version); return row; } + + private static ContainerDocumentEntity document(long id, String type, int version, boolean completed) { + ContainerDocumentEntity value = new ContainerDocumentEntity(); + value.setId(id); value.setContainerBookingId(30L); value.setDocumentType(type); + value.setFileId(99L); value.setFileName("clearance.pdf"); value.setFileHash("abc"); + value.setUploadedBy(9L); value.setUploadedByName("operator"); value.setVersion(version); + value.setCreatedAt(java.time.LocalDateTime.now()); + if (completed) value.setCompletedAt(java.time.LocalDateTime.now()); + return value; + } } diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 52dd8c6cb..c4d76192b 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -12,7 +12,7 @@ import process from 'node:process' const DEFAULT_GATEWAY = 'http://8.163.30.128:9000' const DEFAULT_USERNAME = 'platform_admin' const DEFAULT_PASSWORD = 'ChangeMe123!' -const DEFAULT_MODULES = ['customers', 'overseas-storage-fees', 'system', 'master-data', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full'] +const DEFAULT_MODULES = ['customers', 'overseas-storage-fees', 'system', 'master-data', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full'] const WEIGHT_IMAGE_PATH = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG' const CUSTOMER_FIELDS = [ 'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative', diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs new file mode 100644 index 000000000..1396f57db --- /dev/null +++ b/scripts/admin-test-env-modules/manual-state-loading-pda-contract.test.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const source = readFileSync(new URL('./manual-state-loading-pda.mjs', import.meta.url), 'utf8') + +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', + '/batch', '/complete', '/loading-plans', '/loading-lists']) assert.match(source, new RegExp(endpoint)) + assert.match(source, /requiredStatuses/) + assert.match(source, /detailPath/) + assert.match(source, /loading-plans\/\$\{fixture\.id\}/) + assert.match(source, /loading-lists\/\$\{listFixture\.id\}/) + assert.match(source, /prepareInbound/) + assert.match(source, /\.\/orders\.mjs/) + assert.match(source, /\.\/inbound-measurement\.mjs/) +}) + +test('does not mutate historical fixtures or remove retained records', () => { + assert.doesNotMatch(source, /\bDELETE\b/) + assert.doesNotMatch(source, /createdResources\?\.phase3ContainerLoading/) + assert.doesNotMatch(source, /orchestrationFixtures\?\./) + assert.match(source, /fixtureMarker/) + assert.match(source, /context\.marker = originalMarker/) +}) diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs new file mode 100644 index 000000000..1c0f8b7d9 --- /dev/null +++ b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs @@ -0,0 +1,286 @@ +const MODULE = 'manual-state-loading-pda' +const OPTIONS = { scenario: 'manual-loading-pda-state-matrix', deploymentBlocker: true } +const CONTAINERS = '/api/platform/v1/containers' +const SMART = '/api/platform/v1/first-leg/smart-loading' +const LOADING = '/api/platform/v1/first-leg' +const PDA = `${LOADING}/pda-loading` +const CONTAINER_STATUSES = ['WAIT_LOADING', 'EDITING', 'LOADING', 'COMPLETED'] +const PRELOAD_STATUSES = ['EDITING', 'LOADING'] +const PDA_STATUSES = ['UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW', 'COMPLETED'] + +function id(value) { return value == null ? '' : String(value) } +function records(value) { return Array.isArray(value?.records) ? value.records : [] } +function suffix(context, label) { + return `${String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-10)}${label}`.slice(-20) +} +function operationId(context, label) { + return `${context.marker}-manual-loading-${label}-${crypto.randomUUID()}`.slice(0, 128) +} + +async function post(context, path, body, label) { + return context.request('POST', path, body, context.token, + { 'Idempotency-Key': operationId(context, label) }) +} + +async function prepareInbound(context, label) { + const originalMarker = context.marker + const fixtureMarker = `${originalMarker}-MAN-${label}` + const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement', 'inboundWarehouseResources'] + const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound'] + const createdSnapshot = new Map(createdKeys.map((key) => [key, + { present: Object.hasOwn(context.createdResources, key), value: context.createdResources[key] }])) + const contextSnapshot = new Map(contextKeys.map((key) => [key, + { present: Object.hasOwn(context.context, key), value: context.context[key] }])) + + try { + context.marker = fixtureMarker + const orders = await import(new URL('./orders.mjs', import.meta.url)) + await orders.run(context) + const measurement = await import(new URL('./inbound-measurement.mjs', import.meta.url)) + await measurement.run(context) + const measured = context.createdResources.inboundMeasurement + const order = context.createdResources.orders + if (!measured?.inboundId || !measured?.tenantId) throw new Error(`${label} 未生成独立 INBOUNDED 入库单`) + return { marker: fixtureMarker, tenantId: id(measured.tenantId), inboundId: id(measured.inboundId), + orderId: id(order?.orderId) } + } finally { + context.marker = originalMarker + for (const [key, snapshot] of createdSnapshot) { + if (snapshot.present) context.createdResources[key] = snapshot.value + else delete context.createdResources[key] + } + for (const [key, snapshot] of contextSnapshot) { + if (snapshot.present) context.context[key] = snapshot.value + else delete context.context[key] + } + } +} + +async function readInbound(context, fixture) { + const response = await context.request('GET', + `/api/platform/inbounds/${fixture.inboundId}?tenantId=${encodeURIComponent(fixture.tenantId)}`) + const value = context.apiSuccess(MODULE, '读取人工装柜独立入库单', response, '', OPTIONS) + const warehouseId = id(value?.summary?.warehouseId) + context.assert(MODULE, '人工装柜来源入库单可独立排柜', response, + value?.summary?.processStatus === 'INBOUNDED' && value?.summary?.measureReviewStatus === 'APPROVED' + && Boolean(warehouseId), + `inboundId=${fixture.inboundId}, status=${value?.summary?.processStatus || 'missing'}`, OPTIONS) + return { ...fixture, detail: value, warehouseId } +} + +async function createWaitingContainer(context, warehouseId, label) { + const value = suffix(context, label) + const response = await context.request('POST', CONTAINERS, { + containerNo: null, cabinetNo: `MAN-CAB-${value}`, warehouseId, warehouseName: '人工验收仓', + billOfLadingNo: null, vesselName: null, voyageNo: null, containerType: '40HQ', + shippingCompanyId: null, shippingCompanyName: null, departurePortCode: 'CNSZX', + departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', + destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68, + maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null, + estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null, + actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null, + }) + const container = context.apiSuccess(MODULE, `创建 ${label} 货柜`, response, '', OPTIONS) + if (!container?.id) throw new Error(`${label} 货柜创建失败`) + return container +} + +async function createConfirmedContainer(context, source, label) { + const value = suffix(context, label) + const summary = source.detail.summary + const draft = { + customerId: source.tenantId, customerName: summary.customerName || 'IT 测试客户', + channelCode: summary.channelCode, channelName: summary.channelName, warehouseId: source.warehouseId, + warehouseName: summary.warehouseName, shippingCompanyId: null, shippingCompanyName: `人工船司-${value}`, + bookingAgentId: null, bookingAgentName: `人工代理-${value}`, departurePortCode: 'CNSZX', + departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', + placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID', remark: `${context.marker}-${label}`, + parties: [{ partyType: 'SHIPPER', partyName: `发货方-${value}` }, + { partyType: 'CONSIGNEE', partyName: `收货方-${value}` }, + { partyType: 'NOTIFY_PARTY', partyName: `通知方-${value}` }], + containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 68, + maxWeightKg: 28000, quantity: 1 }], + } + const createdResponse = await post(context, '/api/platform/v1/ocean-bookings', + { expectedVersion: 0, data: draft }, `${label}-booking-create`) + const created = context.apiSuccess(MODULE, `${label} 创建订舱`, createdResponse, '', OPTIONS) + const bookingId = id(created?.oceanBooking?.id) + const submittedResponse = await post(context, `/api/platform/v1/ocean-bookings/${bookingId}/submit`, + { expectedVersion: Number(created?.oceanBooking?.version), reason: context.marker }, `${label}-booking-submit`) + const submitted = context.apiSuccess(MODULE, `${label} 提交订舱`, submittedResponse, '', OPTIONS) + const confirmedResponse = await post(context, `/api/platform/v1/ocean-bookings/${bookingId}/confirm`, + { expectedVersion: Number(submitted?.oceanBooking?.version), reason: context.marker }, `${label}-booking-confirm`) + const confirmed = context.apiSuccess(MODULE, `${label} 确认订舱`, confirmedResponse, '', OPTIONS) + const containerId = id(confirmed?.containerAllocations?.[0]?.containerBookingId) + if (!containerId) throw new Error(`${label} 确认订舱未生成柜次`) + return containerId +} + +async function createPlanTask(context, label) { + const source = await readInbound(context, await prepareInbound(context, label)) + const containerId = await createConfirmedContainer(context, source, label) + const candidateResponse = await context.request('GET', + `${SMART}/candidates?bookingId=${containerId}&warehouseId=${source.warehouseId}`) + const candidates = context.apiSuccess(MODULE, `${label} 查询同渠道候选货物`, candidateResponse, '', OPTIONS) + const candidate = Array.isArray(candidates) + ? candidates.find((item) => id(item.inboundId) === source.inboundId && Number(item.availablePackageCount) > 0) + : 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 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 pushResponse = await post(context, `${SMART}/plans/${plan.id}/push-preload`, + { expectedVersion: Number(plan.version) }, `${label}-push`) + plan = context.apiSuccess(MODULE, `${label} 推送预装`, pushResponse, '', OPTIONS) + + const detailResponse = await context.request('GET', `${CONTAINERS}/${containerId}`) + const container = context.apiSuccess(MODULE, `${label} 读取柜次`, detailResponse, '', OPTIONS) + const containerNo = `MAN-${suffix(context, label)}`.slice(0, 64) + const transportResponse = await context.request('PATCH', `${CONTAINERS}/${containerId}/transport`, { + containerNo, voyageNo: `V-${suffix(context, label)}`.slice(0, 64), version: Number(container.version), + reason: `${context.marker} 人工状态样本`, + }) + context.apiSuccess(MODULE, `${label} 写入真实集装箱号并开放 PDA`, transportResponse, '', OPTIONS) + const taskResponse = await context.request('GET', + `${PDA}/tasks?loadingPlanId=${plan.id}&warehouseId=${source.warehouseId}`) + const task = context.apiSuccess(MODULE, `${label} 读取 PDA 任务`, taskResponse, '', OPTIONS) + if (!task?.id || !task?.items?.[0]?.id) throw new Error(`${label} PDA 任务未生成`) + return { source, containerId, containerNo, plan, task } +} + +async function claim(context, chain, label) { + const response = await context.request('POST', `${PDA}/tasks/${chain.task.id}/claim`, { + expectedAssignmentVersion: Number(chain.task?.assignment?.version || 0), + operationId: operationId(context, `${label}-claim`), + }) + chain.task = context.apiSuccess(MODULE, `${label} 领取 PDA 任务`, response, '', OPTIONS) + return chain +} + +async function pickAll(context, chain, label) { + const snapshotResponse = await context.request('GET', + `${PDA}/tasks/${chain.task.id}/inventory-snapshot?warehouseId=${chain.source.warehouseId}`) + const snapshot = context.apiSuccess(MODULE, `${label} 获取 PDA 库存快照`, snapshotResponse, '', OPTIONS) + const response = await post(context, `${PDA}/tasks/${chain.task.id}/batch`, { + warehouseId: chain.source.warehouseId, loadingPlanId: id(chain.task.loadingPlanId), + containerBookingId: chain.containerId, containerNo: chain.task.currentContainerNo || null, + expectedVersion: Number(chain.task.version), inventorySnapshotToken: snapshot.snapshotToken, + items: chain.task.items.map((item) => ({ itemId: id(item.id), action: 'PICK', + pickedPackageCount: Number(item.plannedPackageCount), finalPickedPackageCount: null, + releaseMode: 'MANUAL', locationIds: [], version: Number(item.version), reason: null })), + }, `${label}-batch`) + chain.task = context.apiSuccess(MODULE, `${label} 完成 PDA 拣货`, response, '', OPTIONS) + return chain +} + +async function complete(context, chain, label) { + const response = await context.request('POST', `${PDA}/tasks/${chain.task.id}/complete`, { + expectedTaskVersion: Number(chain.task.version), operationId: operationId(context, `${label}-complete`), + remark: `${context.marker} 人工完成状态样本`, + }) + chain.task = context.apiSuccess(MODULE, `${label} 人工确认装柜完成`, response, '', OPTIONS) + return chain +} + +async function verifyContainer(context, status, fixture) { + const response = await context.request('GET', `${CONTAINERS}?status=${status}&page=1&size=100`) + const page = context.apiSuccess(MODULE, `${status} 货柜可按列表状态筛选`, response, '', OPTIONS) + context.assert(MODULE, `${status} 货柜样本保留`, response, + records(page).some((item) => id(item.id) === fixture.id && item.planStatus === status), + `id=${fixture.id}`, OPTIONS) +} + +async function verifyPlan(context, status, fixture) { + const response = await context.request('GET', + `${LOADING}/loading-plans?status=${status}&warehouseId=${fixture.warehouseId}&page=1&size=100`) + const page = context.apiSuccess(MODULE, `${status} 预装清单可筛选`, response, '', OPTIONS) + context.assert(MODULE, `${status} 预装清单样本保留`, response, + records(page).some((item) => id(item.planId) === fixture.id && item.status === status), + `planId=${fixture.id}`, OPTIONS) + const detailResponse = await context.request('GET', + `${LOADING}/loading-plans/${fixture.id}?warehouseId=${fixture.warehouseId}`) + const detail = context.apiSuccess(MODULE, `${status} 预装清单详情可打开`, detailResponse, '', OPTIONS) + context.assert(MODULE, `${status} 预装清单详情身份正确`, detailResponse, + id(detail?.summary?.planId) === fixture.id && detail?.summary?.status === status, + `planId=${fixture.id}, actual=${detail?.summary?.status || 'missing'}`, OPTIONS) +} + +async function verifyTask(context, status, fixture) { + const response = await context.request('GET', + `${PDA}/tasks/${fixture.id}?warehouseId=${fixture.warehouseId}`) + const task = context.apiSuccess(MODULE, `${status} PDA 任务可打开`, response, '', OPTIONS) + context.assert(MODULE, `${status} PDA 任务样本保留`, response, + id(task?.id) === fixture.id && task?.workflowStatus === status, + `taskId=${fixture.id}, actual=${task?.workflowStatus || 'missing'}`, OPTIONS) +} + +function fixture(value, status, detailPath, extra = {}) { + return { id: id(value), status, detailPath, ...extra } +} + +export async function run(context, matrix = context.createdResources.manualStateMatrix) { + matrix.pages.containerLoading = { requiredStatuses: CONTAINER_STATUSES, fixtures: {} } + matrix.pages.preloadPlans = { requiredStatuses: PRELOAD_STATUSES, fixtures: {} } + matrix.pages.loadingLists = { requiredStatuses: ['COMPLETED'], fixtures: {} } + matrix.pages.pdaTasks = { requiredStatuses: PDA_STATUSES, fixtures: {} } + + const warehouseId = '193000000000200001' + const waiting = await createWaitingContainer(context, warehouseId, 'WAIT') + matrix.pages.containerLoading.fixtures.WAIT_LOADING = fixture(waiting.id, 'WAIT_LOADING', + `/container-loading?containerId=${id(waiting.id)}`) + + const editing = await createPlanTask(context, 'EDIT') + const inProgress = await claim(context, await createPlanTask(context, 'PROGRESS'), 'PROGRESS') + const awaiting = await pickAll(context, + await claim(context, await createPlanTask(context, 'REVIEW'), 'REVIEW'), 'REVIEW') + const completed = await complete(context, await pickAll(context, + await claim(context, await createPlanTask(context, 'DONE'), 'DONE'), 'DONE'), 'DONE') + + const chains = { EDITING: editing, LOADING: inProgress, COMPLETED: completed } + for (const [status, chain] of Object.entries(chains)) { + matrix.pages.containerLoading.fixtures[status] = fixture(chain.containerId, status, + `/container-loading/${chain.containerId}/smart-loading`, { planId: id(chain.plan.id) }) + } + for (const [status, chain] of Object.entries({ EDITING: editing, LOADING: inProgress })) { + matrix.pages.preloadPlans.fixtures[status] = fixture(chain.plan.id, status, + `/loading-plans/${chain.plan.id}?warehouseId=${chain.source.warehouseId}`, + { warehouseId: chain.source.warehouseId }) + } + matrix.pages.loadingLists.fixtures.COMPLETED = fixture(completed.plan.id, 'COMPLETED', + `/loading-lists?planId=${completed.plan.id}&warehouseId=${completed.source.warehouseId}`, + { warehouseId: completed.source.warehouseId }) + + const taskChains = { UNASSIGNED: editing, IN_PROGRESS: inProgress, AWAITING_REVIEW: awaiting, COMPLETED: completed } + for (const [status, chain] of Object.entries(taskChains)) { + matrix.pages.pdaTasks.fixtures[status] = fixture(chain.task.id, status, + `/pages/picking/picking?taskId=${chain.task.id}&warehouseId=${chain.source.warehouseId}`, + { warehouseId: chain.source.warehouseId, loadingPlanId: id(chain.plan.id) }) + } + + for (const [status, value] of Object.entries(matrix.pages.containerLoading.fixtures)) + await verifyContainer(context, status, value) + for (const [status, value] of Object.entries(matrix.pages.preloadPlans.fixtures)) + await verifyPlan(context, status, value) + for (const [status, value] of Object.entries(matrix.pages.pdaTasks.fixtures)) + await verifyTask(context, status, value) + + const listFixture = matrix.pages.loadingLists.fixtures.COMPLETED + const listResponse = await context.request('GET', + `${LOADING}/loading-lists?warehouseId=${listFixture.warehouseId}&page=1&size=100`) + const list = context.apiSuccess(MODULE, 'COMPLETED 装柜清单可从列表打开', listResponse, '', OPTIONS) + context.assert(MODULE, 'COMPLETED 装柜清单样本保留', listResponse, + records(list).some((item) => id(item.planId) === listFixture.id && item.status === 'COMPLETED'), + `planId=${listFixture.id}`, OPTIONS) + const listDetailResponse = await context.request('GET', + `${LOADING}/loading-lists/${listFixture.id}?warehouseId=${listFixture.warehouseId}`) + const listDetail = context.apiSuccess(MODULE, 'COMPLETED 装柜清单详情可打开', listDetailResponse, '', OPTIONS) + context.assert(MODULE, 'COMPLETED 装柜清单详情身份正确', listDetailResponse, + id(listDetail?.summary?.planId) === listFixture.id && listDetail?.summary?.status === 'COMPLETED', + `planId=${listFixture.id}, actual=${listDetail?.summary?.status || 'missing'}`, OPTIONS) +} diff --git a/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs new file mode 100644 index 000000000..ba3b92639 --- /dev/null +++ b/scripts/admin-test-env-modules/manual-state-matrix-contract.test.mjs @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') +const source = readFileSync(new URL('./manual-state-matrix.mjs', import.meta.url), 'utf8') + +test('default full acceptance includes the retained manual state matrix after lifecycle modules', () => { + assert.match(entry, /'manual-state-matrix'/) + assert.ok(entry.indexOf("'phase3-order-splitting'") < entry.indexOf("'manual-state-matrix'")) + assert.ok(entry.indexOf("'manual-state-matrix'") < entry.indexOf("'finance-full-fixtures'")) +}) + +test('matrix delegates bounded groups and asserts every declared status has a retained id', () => { + for (const group of ['order-inbound-status-fixtures', 'manual-state-loading-pda', + 'manual-state-ocean-trucking']) assert.match(source, new RegExp(group)) + assert.doesNotMatch(source, /manual-state-customs-dispatch/) + assert.match(source, /createdResources\.manualStateMatrix/) + assert.match(source, /requiredStatuses/) + assert.match(source, /fixtures\?\.\[status\]\?\.id/) +}) diff --git a/scripts/admin-test-env-modules/manual-state-matrix.mjs b/scripts/admin-test-env-modules/manual-state-matrix.mjs new file mode 100644 index 000000000..b9d4ee486 --- /dev/null +++ b/scripts/admin-test-env-modules/manual-state-matrix.mjs @@ -0,0 +1,29 @@ +const MODULE = 'manual-state-matrix' +const OPTIONS = { scenario: 'manual-clickable-list-state-matrix', deploymentBlocker: true } + +const GROUPS = [ + 'order-inbound-status-fixtures', + 'manual-state-loading-pda', + 'manual-state-ocean-trucking', +] + +function rows(value) { return Array.isArray(value) ? value : [] } + +export async function run(context) { + const matrix = { marker: context.marker, generatedAt: new Date().toISOString(), pages: {} } + context.createdResources.manualStateMatrix = matrix + + for (const name of GROUPS) { + const extension = await import(new URL(`./${name}.mjs`, import.meta.url)) + if (typeof extension.run !== 'function') throw new Error(`${name} 必须导出 async function run(context)`) + await extension.run(context, matrix) + } + + const pages = Object.entries(matrix.pages) + const missing = pages.flatMap(([page, value]) => rows(value?.requiredStatuses) + .filter((status) => !value?.fixtures?.[status]?.id) + .map((status) => `${page}:${status}`)) + context.assert(MODULE, '人工验收列表状态数据全部生成并保留', null, + pages.length > 0 && missing.length === 0, + `pages=${pages.length}, missing=${missing.join(',') || 'none'}`, OPTIONS) +} diff --git a/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs new file mode 100644 index 000000000..bd1ce3870 --- /dev/null +++ b/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const source = readFileSync(new URL('./manual-state-ocean-trucking.mjs', import.meta.url), 'utf8') + +test('retains every ocean booking and truck appointment list status through real APIs', () => { + for (const status of ['DRAFT','SUBMITTED','CONFIRMED','RELEASED','FAILED','CHANGE_PENDING','CANCELLED', + 'PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED', + 'COMPLETED','RESCHEDULED','EXCEPTION']) assert.match(source, new RegExp(`['"]${status}['"]`)) + assert.match(source, /change-request/) + assert.match(source, /\/reschedule\?plannedLoadingAt=/) + assert.match(source, /page=1&size=100&status=/) + assert.doesNotMatch(source, /DELETE/) +}) diff --git a/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs b/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs new file mode 100644 index 000000000..3c5328b1e --- /dev/null +++ b/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs @@ -0,0 +1,158 @@ +const MODULE = 'manual-state-ocean-trucking' +const OPTIONS = { scenario: 'manual-ocean-trucking-state-matrix', deploymentBlocker: true } +const OCEAN_BASE = '/api/platform/v1/first-leg/ocean-bookings' +const TRUCK_BASE = '/api/platform/v1/truck-appointments' +const OCEAN_STATUSES = ['DRAFT', 'SUBMITTED', 'CONFIRMED', 'RELEASED', 'FAILED', 'CHANGE_PENDING', 'CANCELLED'] +const TRUCK_STATUSES = ['DRAFT', 'PENDING_DISPATCH', 'DISPATCHED', 'PICKUP_IN_PROGRESS', 'ARRIVED_WAREHOUSE', + 'LOADING', 'DEPARTED', 'COMPLETED', 'RESCHEDULED', 'EXCEPTION', 'CANCELLED'] + +function id(value) { return value == null ? '' : String(value) } +function code(response) { return String(response?.body?.code ?? 'missing') } +function data(response) { return response?.http === 200 && code(response) === '200' ? response.body.data : null } +function key(context, value) { return `${context.marker}-manual-${value}-${crypto.randomUUID()}`.slice(0, 128) } +function suffix(context, index) { return `${context.marker.replace(/\D/g, '').slice(-8)}${String(index).padStart(2, '0')}` } + +async function post(context, path, body, operation) { + return context.request('POST', path, body, context.token, { 'Idempotency-Key': key(context, operation) }) +} + +function oceanPayload(context, template, index) { + const value = suffix(context, index); const source = template.oceanBooking + return { expectedVersion: 0, data: { + customerId: source.customerId, customerName: source.customerName, + channelCode: source.channelCode, channelName: source.channelName, + warehouseId: source.warehouseId, warehouseName: source.warehouseName, + billOfLadingNo: `MAN-BL-${value}`, shippingCompanyId: source.shippingCompanyId, + shippingCompanyName: source.shippingCompanyName, bookingAgentId: source.bookingAgentId, + bookingAgentName: source.bookingAgentName, vesselName: `人工验收轮-${value}`, voyageNo: `V${value}`, + departurePortCode: source.departurePortCode, departurePortName: source.departurePortName, + destinationPortCode: source.destinationPortCode, destinationPortName: source.destinationPortName, + placeOfDelivery: source.placeOfDelivery, serviceTerm: source.serviceTerm, freightTerm: source.freightTerm, + estimatedSailingAt: '2026-09-15T08:00:00', estimatedArrivalAt: '2026-10-15T08:00:00', + remark: `${context.marker} 人工列表状态 ${index}`, + parties: template.parties.map(({ partyType, partyName, address, contactName, contactPhone }) => + ({ partyType, partyName, address, contactName, contactPhone })), + containerRequirements: template.containerRequirements.map(({ containerType, containerKind, capacityCbm, maxWeightKg, quantity }) => + ({ containerType, containerKind, capacityCbm, maxWeightKg, quantity })), + } } +} + +async function createOcean(context, template, index) { + const response = await post(context, OCEAN_BASE, oceanPayload(context, template, index), `ocean-create-${index}`) + const value = context.apiSuccess(MODULE, `创建订舱状态样本 ${index}`, response, '', OPTIONS) + if (!value?.oceanBooking?.id) throw new Error(`创建订舱样本 ${index} 失败`) + return value +} + +async function oceanAction(context, value, endpoint, body = {}) { + const response = await post(context, `${OCEAN_BASE}/${id(value.oceanBooking.id)}/${endpoint}`, + { expectedVersion: Number(value.oceanBooking.version), reason: `${context.marker}-${endpoint}`, ...body }, `ocean-${endpoint}`) + const next = context.apiSuccess(MODULE, `订舱推进 ${endpoint}`, response, `id=${id(value.oceanBooking.id)}`, OPTIONS) + if (!next?.oceanBooking?.id) throw new Error(`订舱 ${id(value.oceanBooking.id)} 推进 ${endpoint} 失败`) + return next +} + +async function buildOcean(context, template, status, index) { + let value = await createOcean(context, template, index) + if (status === 'DRAFT') return value + value = await oceanAction(context, value, 'submit') + if (status === 'SUBMITTED') return value + if (status === 'FAILED') return oceanAction(context, value, 'fail') + if (status === 'CANCELLED') return oceanAction(context, value, 'cancel') + value = await oceanAction(context, value, 'confirm') + if (status === 'CONFIRMED') return value + if (status === 'CHANGE_PENDING') return oceanAction(context, value, 'change-request', { + vesselName: `变更轮-${suffix(context, index)}`, voyageNo: `CV${suffix(context, index)}`, + estimatedSailingAt: '2026-09-16T08:00:00', estimatedArrivalAt: '2026-10-16T08:00:00' }) + if (status === 'RELEASED') return oceanAction(context, value, 'release', { + billOfLadingNo: `REL-BL-${suffix(context, index)}`, vesselName: `放舱轮-${suffix(context, index)}`, + voyageNo: `RV${suffix(context, index)}`, estimatedSailingAt: '2026-09-16T08:00:00', + estimatedArrivalAt: '2026-10-16T08:00:00' }) + throw new Error(`unsupported ocean status ${status}`) +} + +function truckPayload(context, oceanBookingId, index) { + const value = suffix(context, index) + return { outboundOrderId: null, oceanBookingId, containerBookingId: null, loadingPlanId: null, + warehouseId: '193000000000200001', customerId: '10001', customerName: 'IT 测试客户', + carrierName: `人工验收车队-${value}`, fleetContact: '人工验收联系人', fleetPhone: '13800000004', + driverName: `人工司机-${value}`, driverPhone: '13800000005', vehicleNo: `粤B${value.slice(-6)}`, + storeName: '人工验收仓', storeContact: '仓库联系人', storePhone: '13800000006', + storeAddress: '深圳市南山区人工验收地址', storeCity: '深圳市', storeZipcode: '518000', + packageCount: 10, grossWeightKg: 100, volumeCbm: 2.5, palletCount: 1, poNo: `PO-${value}`, + plannedLoadingAt: '2026-09-16T08:00:00', transitMinutes: 120, payableAmount: 100, + receivableAmount: 150, currency: 'RMB', remark: `${context.marker} 人工列表状态 ${index}`, expectedVersion: 0 } +} + +async function createTruck(context, oceanBookingId, index) { + const response = await context.request('POST', TRUCK_BASE, truckPayload(context, oceanBookingId, index)) + const value = context.apiSuccess(MODULE, `创建拖车状态样本 ${index}`, response, '', OPTIONS) + if (!value?.id) throw new Error(`创建拖车样本 ${index} 失败`) + return value +} + +async function truckAction(context, value, endpoint) { + const response = await post(context, `${TRUCK_BASE}/${id(value.id)}/${endpoint}`, + { expectedVersion: Number(value.version), reason: `${context.marker}-${endpoint}` }, `truck-${endpoint}`) + const next = context.apiSuccess(MODULE, `拖车推进 ${endpoint}`, response, `id=${id(value.id)}`, OPTIONS) + if (!next?.id) throw new Error(`拖车 ${id(value.id)} 推进 ${endpoint} 失败`) + return next +} + +async function buildTruck(context, oceanBookingId, status, index) { + let value = await createTruck(context, oceanBookingId, index) + if (status === 'DRAFT') return value + if (status === 'RESCHEDULED') { + const response = await post(context, + `${TRUCK_BASE}/${id(value.id)}/reschedule?plannedLoadingAt=2026-09-18T08%3A00%3A00`, + { expectedVersion: Number(value.version), reason: `${context.marker}-reschedule` }, 'truck-reschedule') + return context.apiSuccess(MODULE, '拖车推进 reschedule', response, '', OPTIONS) + } + if (status === 'EXCEPTION') return truckAction(context, value, 'exception') + if (status === 'CANCELLED') return truckAction(context, value, 'cancel') + const chain = [['submit', 'PENDING_DISPATCH'], ['dispatch', 'DISPATCHED'], ['pickup', 'PICKUP_IN_PROGRESS'], + ['arrive', 'ARRIVED_WAREHOUSE'], ['load', 'LOADING'], ['depart', 'DEPARTED'], ['complete', 'COMPLETED']] + for (const [endpoint, target] of chain) { + value = await truckAction(context, value, endpoint) + if (target === status) return value + } + throw new Error(`unsupported truck status ${status}`) +} + +async function verify(context, base, status, resourceId) { + const response = await context.request('GET', `${base}?page=1&size=100&status=${status}`) + const page = data(response) + context.assert(MODULE, `${status} 样本可从列表筛选并打开`, response, + Array.isArray(page?.records) && page.records.some((item) => id(item.id) === resourceId), + `status=${status}, id=${resourceId}`, OPTIONS) +} + +export async function run(context, matrix = context.createdResources.manualStateMatrix) { + const sourceOceanId = id(context.createdResources?.oceanBooking?.id) + if (!sourceOceanId) throw new Error('缺少现有订舱生命周期模块生成的模板资源') + const templateResponse = await context.request('GET', `${OCEAN_BASE}/${sourceOceanId}`) + const template = context.apiSuccess(MODULE, '读取订舱状态矩阵模板', templateResponse, '', OPTIONS) + if (!template?.oceanBooking || !template?.parties?.length || !template?.containerRequirements?.length) { + throw new Error('订舱模板缺少渠道、仓库、参与方或箱型需求') + } + matrix.pages.oceanBookings = { requiredStatuses: OCEAN_STATUSES, fixtures: {} } + for (const [index, status] of OCEAN_STATUSES.entries()) { + const value = await buildOcean(context, template, status, index) + const resourceId = id(value?.oceanBooking?.id); const actual = value?.oceanBooking?.status + matrix.pages.oceanBookings.fixtures[status] = { id: resourceId, status: actual, detailPath: `/ocean-bookings/${resourceId}` } + context.assert(MODULE, `订舱保留 ${status}`, null, resourceId && actual === status, + `id=${resourceId || 'missing'}, actual=${actual || 'missing'}`, OPTIONS) + await verify(context, OCEAN_BASE, status, resourceId) + } + + const releasedOceanId = matrix.pages.oceanBookings.fixtures.RELEASED.id + matrix.pages.truckAppointments = { requiredStatuses: TRUCK_STATUSES, fixtures: {} } + for (const [index, status] of TRUCK_STATUSES.entries()) { + const value = await buildTruck(context, releasedOceanId, status, index) + const resourceId = id(value?.id) + matrix.pages.truckAppointments.fixtures[status] = { id: resourceId, status: value?.status, detailPath: `/truck-appointments/${resourceId}` } + context.assert(MODULE, `拖车保留 ${status}`, null, resourceId && value?.status === status, + `id=${resourceId || 'missing'}, actual=${value?.status || 'missing'}`, OPTIONS) + await verify(context, TRUCK_BASE, status, resourceId) + } +} diff --git a/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs new file mode 100644 index 000000000..f972cc06f --- /dev/null +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs @@ -0,0 +1,62 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const source = fs.readFileSync(path.join(here, 'order-inbound-status-fixtures.mjs'), 'utf8') + +test('为头程订单列表八个真实状态分别创建独立样本', () => { + for (const status of ['DRAFT', 'SUBMITTED', 'REJECTED', 'APPROVED', 'ACCEPTED', 'PROCESSING', 'COMPLETED', 'CANCELLED']) { + assert.match(source, new RegExp(`\\['${status}', \\[`)) + assert.match(source, new RegExp(`result\\.orders\\[target\\]`)) + } + assert.match(source, /status=\$\{status\}/) + assert.match(source, /筛选结果包含人工样本/) +}) + +test('所有写入均走订单生命周期 HTTP API 且不清理保留数据', () => { + assert.match(source, /\/api\/merchant\/v1\/orders\/first-leg/) + assert.match(source, /\/api\/platform\/v1\/orders\/first-leg/) + assert.match(source, /'Idempotency-Key': key\(context,/) + assert.doesNotMatch(source, /\b(?:INSERT|UPDATE|DELETE|TRUNCATE)\b/i) + assert.doesNotMatch(source, /cleanup|remove|unlink/i) +}) + +test('入库通过真实订单测量链保留四个测量态和 INBOUNDED', () => { + assert.match(source, /result\.inbounds\.WAIT_MEASURE/) + assert.match(source, /processStatus: 'WAIT_MEASURE'/) + for (const status of ['MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED', 'INBOUNDED']) { + assert.match(source, new RegExp(`'${status}'`)) + } + assert.match(source, /\.\/inbound-measurement\.mjs/) + assert.match(source, /path\.replace\('\/approve', '\/reject'\)/) +}) + +test('入库不可独立保留状态显式阻断', () => { + for (const status of ['WAIT_PRICING', 'FEE_CONFIRMING', 'INBOUNDING', 'VOIDED']) { + assert.match(source, new RegExp(`${status}:`)) + } + assert.match(source, /AutomaticInboundCompletionService 在同一调用内立即从 INBOUNDING 推进 INBOUNDED/) + assert.match(source, /没有作废入库单 API/) + assert.match(source, /context\.assert\(MODULE, `入库列表保留 \$\{status\}`/) +}) + +test('统一订单声明合法可达状态并显式阻断废弃或无 API 状态', () => { + assert.match(source, /result\.unifiedOrders\.PENDING_REVIEW/) + assert.match(source, /result\.unifiedOrders\.WAIT_MEASURE/) + for (const status of ['INBOUNDED', 'WAIT_SPLIT', 'LOADING', 'LOADED', 'DELIVERED']) { + assert.match(source, new RegExp(`'${status}'`)) + } + assert.match(source, /WAIT_SPLIT 标记为历史废弃状态/) + assert.match(source, /没有完成派送并写 DELIVERED 的公开业务 API/) +}) + +test('按总门禁格式写入 matrix.pages 的 requiredStatuses 和 fixtures', () => { + assert.match(source, /run\(context, matrix = context\.createdResources\.manualStateMatrix\)/) + for (const page of ['firstLegOrders', 'inbounds', 'unifiedOrders']) { + assert.match(source, new RegExp(`matrix\\.pages\\.${page} = \\{ requiredStatuses:`)) + } + assert.match(source, /detailPath: `\/orders\/\$\{orderId\}`/) +}) diff --git a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs new file mode 100644 index 000000000..f241959cd --- /dev/null +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs @@ -0,0 +1,300 @@ +const MODULE = 'order-inbound-status-fixtures' +const OPTIONS = { scenario: 'manual-order-inbound-status-matrix', deploymentBlocker: true } +const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' +const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!' + +const ORDER_TARGETS = [ + ['DRAFT', []], + ['SUBMITTED', ['submit']], + ['REJECTED', ['submit', 'reject']], + ['APPROVED', ['submit', 'approve']], + ['ACCEPTED', ['submit', 'approve', 'accept']], + ['PROCESSING', ['submit', 'approve', 'accept', 'start']], + ['COMPLETED', ['submit', 'approve', 'accept', 'start', 'complete']], + ['CANCELLED', ['cancel']], +] +const INBOUND_FILTER_STATUSES = [ + 'WAIT_MEASURE', 'MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED', 'WAIT_PRICING', + 'FEE_CONFIRMING', 'INBOUNDING', 'INBOUNDED', 'VOIDED', +] +const UNIFIED_FILTER_STATUSES = [ + 'PENDING_REVIEW', 'WAIT_MEASURE', 'INBOUNDED', 'WAIT_SPLIT', 'LOADING', 'LOADED', 'DELIVERED', +] + +function id(value) { return value == null ? '' : String(value) } +function rows(value) { return Array.isArray(value?.records) ? value.records : [] } +function ok(response) { return response?.http === 200 && String(response?.body?.code) === '200' } +function data(response) { return response?.body?.data } +function key(context, operation) { return `${context.marker}-manual-${operation}`.slice(0, 128) } +function query(value) { return encodeURIComponent(String(value)) } +function wait(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)) } + +async function merchantRequest(context, method, path, body, token, headers = {}) { + const requestHeaders = { Accept: 'application/json', Authorization: `Bearer ${token}`, ...headers } + const payload = body === undefined ? undefined : JSON.stringify(body) + if (payload !== undefined) requestHeaders['Content-Type'] = 'application/json; charset=utf-8' + try { + const response = await fetch(`${context.options.gateway}${path}`, { + method, headers: requestHeaders, body: payload, signal: AbortSignal.timeout(30_000), + }) + const content = await response.text() + let parsed = null + try { parsed = content ? JSON.parse(content) : null } catch { /* Keep bounded raw evidence. */ } + return { method, path, http: response.status, body: parsed, raw: parsed ? null : content.slice(0, 500), + requestId: parsed?.requestId || response.headers.get('x-request-id'), + traceId: parsed?.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 merchantGet(context, path, token) { + return merchantRequest(context, 'GET', path, undefined, token) +} + +async function loadFixtureInputs(context, token) { + const profileResponse = await merchantGet(context, '/api/merchant/v1/iam/tenant/profile', token) + const profile = context.apiSuccess(MODULE, '读取 IT 测试客户资料', profileResponse) + const domesticResponse = await merchantGet(context, + '/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC', token) + const domestic = rows(context.apiSuccess(MODULE, '读取国内收货仓', domesticResponse))[0] + const destinationResponse = await merchantGet(context, + '/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=FBA', token) + const destination = rows(context.apiSuccess(MODULE, '读取 FBA 目的仓', destinationResponse))[0] + if (!profile?.id || !domestic?.id || !destination?.id) throw new Error('状态样本缺少客户或仓库基础数据') + const channelResponse = await merchantGet(context, + `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${query(domestic.id)}`, token) + const channels = context.apiSuccess(MODULE, '读取可下单渠道', channelResponse) + const channel = Array.isArray(channels) + ? channels.find((item) => Array.isArray(item.pricingTaxModes) && item.pricingTaxModes.length > 0) + : null + if (!channel) throw new Error('状态样本缺少带计价税制的可用渠道') + return { tenantId: id(profile.id), customerName: profile.tenantName || 'IT 测试客户', domestic, destination, channel } +} + +function orderPayload(context, fixture, target) { + const sample = `${context.marker}-${target}` + return { + customerId: null, customerName: fixture.customerName, transportMode: 'SEA', tradeTerm: 'FOB', + 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', + receivingWarehouseId: fixture.domestic.id, receivingWarehouseName: fixture.domestic.warehouseName, + channelCode: fixture.channel.channelCode, channelName: fixture.channel.channelName, + carrierId: null, carrierName: null, taxMode: 'BUY_DECLARATION', + pricingTaxMode: fixture.channel.pricingTaxModes[0], containerMode: 'LCL', customsInspection: false, + destinationType: 'FBA', amazonWarehouseCode: fixture.destination.warehouseCode, + shipmentId: `MANUAL-${target}-${context.marker}`.slice(0, 64), referenceId: `STATUS-${target}-${context.marker}`.slice(0, 64), + sender: { companyName: fixture.customerName, contactName: 'IT 测试联系人', phone: '13800138000', + email: 'it-test@example.com', countryCode: 'CN', province: '广东省', city: '深圳市', district: '南山区', + addressLine: `${sample} 测试地址`, postalCode: '518000' }, + receiver: null, + 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), + unitsPerPackage: 10, totalQuantity: 10, netWeightKg: 9, unitDeclaredValue: 10, + material: '塑料', usage: '人工验收', model: `MODEL-${target}`, brand: 'IT-TEST' }], + } +} + +async function createOrder(context, token, fixture, target) { + const response = await merchantRequest(context, 'POST', '/api/merchant/v1/orders/first-leg', + orderPayload(context, fixture, target), token, { 'Idempotency-Key': key(context, `${target}-create`) }) + const orderId = id(context.apiSuccess(MODULE, `创建 ${target} 订单状态样本`, response)) + if (!orderId) throw new Error(`${target} 状态样本未返回订单 ID`) + return orderId +} + +async function transition(context, token, fixture, orderId, target, action) { + const merchantPath = `/api/merchant/v1/orders/first-leg/${query(orderId)}` + if (action === 'submit') { + const response = await merchantRequest(context, 'POST', `${merchantPath}/submit`, undefined, token, + { 'Idempotency-Key': key(context, `${target}-submit`) }) + context.apiSuccess(MODULE, `${target} 样本提交订单`, response) + return + } + const platformPath = `/api/platform/v1/orders/first-leg/${query(orderId)}/${action}?tenantId=${query(fixture.tenantId)}` + const body = action === 'reject' || action === 'cancel' ? { reason: `${context.marker} 保留 ${target} 人工样本` } : undefined + const response = await context.request('POST', platformPath, body) + context.apiSuccess(MODULE, `${target} 样本执行 ${action}`, response) +} + +async function readOrder(context, fixture, orderId) { + const response = await context.request('GET', + `/api/platform/v1/orders/first-leg/${query(orderId)}?tenantId=${query(fixture.tenantId)}`) + return { response, detail: context.apiSuccess(MODULE, '回读订单状态样本', response) } +} + +async function waitForInbound(context, fixture, orderId) { + const path = `/api/platform/v1/orders/${query(orderId)}?tenantId=${query(fixture.tenantId)}` + for (let attempt = 0; attempt < 20; attempt += 1) { + const response = await context.request('GET', path) + const summary = data(response)?.summary + if (ok(response) && summary?.inboundId) return { response, summary } + if (attempt < 19) await wait(500) + } + return { response: null, summary: null } +} + +async function prepareInboundStatus(context, target) { + const originalMarker = context.marker + const originalRequest = context.request + const fixtureMarker = `${originalMarker}-IN-${target}` + const stop = Symbol(target) + const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement', 'inboundWarehouseResources'] + const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound'] + const createdSnapshot = new Map(createdKeys.map((name) => [name, + { present: Object.hasOwn(context.createdResources, name), value: context.createdResources[name] }])) + const contextSnapshot = new Map(contextKeys.map((name) => [name, + { present: Object.hasOwn(context.context, name), value: context.context[name] }])) + try { + context.marker = fixtureMarker + const orders = await import(new URL('./orders.mjs', import.meta.url)) + await orders.run(context) + const order = { ...context.createdResources.orders } + const inboundId = id(order.inboundId) + const tenantId = id(order.tenantId) + if (!inboundId || !tenantId) throw new Error(`${target} 未生成独立待测量入库单`) + + context.request = async (method, path, body, token, headers) => { + const isSubmit = path.includes(`/inbounds/${inboundId}/measurement/submit`) + const isApprove = path.includes(`/inbounds/${inboundId}/measurements/approve`) + if (target === 'MEASURING' && isSubmit) throw stop + if (target === 'MEASURE_PENDING' && isApprove) throw stop + if (target === 'MEASURE_REJECTED' && isApprove) { + const response = await originalRequest.call(context, method, path.replace('/approve', '/reject'), + { ...body, reason: `${fixtureMarker} 保留审核驳回样本` }, token, headers) + if (!ok(response)) throw new Error(`MEASURE_REJECTED 审核驳回失败: HTTP=${response?.http}, code=${response?.body?.code}`) + throw stop + } + return originalRequest.call(context, method, path, body, token, headers) + } + try { + const measurement = await import(new URL('./inbound-measurement.mjs', import.meta.url)) + await measurement.run(context) + } catch (error) { + if (error !== stop) throw error + } finally { + context.request = originalRequest + } + + const response = await originalRequest.call(context, 'GET', + `/api/platform/inbounds/${query(inboundId)}?tenantId=${query(tenantId)}`) + const detail = context.apiSuccess(MODULE, `回读 ${target} 独立入库单`, response, '', OPTIONS) + context.assert(MODULE, `${target} 入库状态样本可从详情打开`, response, + detail?.summary?.processStatus === target, `inboundId=${inboundId}, actual=${detail?.summary?.processStatus || 'missing'}`, OPTIONS) + return { inboundId, orderId: id(order.orderId), tenantId, status: target, + inboundCode: detail?.summary?.inboundCode || null } + } finally { + context.request = originalRequest + context.marker = originalMarker + for (const [name, snapshot] of createdSnapshot) { + if (snapshot.present) context.createdResources[name] = snapshot.value + else Reflect.deleteProperty(context.createdResources, name) + } + for (const [name, snapshot] of contextSnapshot) { + if (snapshot.present) context.context[name] = snapshot.value + else Reflect.deleteProperty(context.context, name) + } + } +} + +export async function run(context, matrix = context.createdResources.manualStateMatrix) { + const loginResponse = await context.request('POST', '/api/auth/login', + { username: MERCHANT_USERNAME, password: MERCHANT_PASSWORD }, '') + const token = context.apiSuccess(MODULE, 'IT 测试客户管理员登录', loginResponse)?.accessToken + if (!token) throw new Error('客户管理员登录未返回 accessToken') + const fixture = await loadFixtureInputs(context, token) + const result = { marker: context.marker, tenantId: fixture.tenantId, orders: {}, inbounds: {}, unifiedOrders: {} } + matrix.pages.firstLegOrders = { requiredStatuses: ORDER_TARGETS.map(([status]) => status), fixtures: {} } + matrix.pages.inbounds = { requiredStatuses: INBOUND_FILTER_STATUSES, fixtures: {} } + matrix.pages.unifiedOrders = { requiredStatuses: UNIFIED_FILTER_STATUSES, fixtures: {} } + + for (const [target, actions] of ORDER_TARGETS) { + const orderId = await createOrder(context, token, fixture, target) + for (const action of actions) await transition(context, token, fixture, orderId, target, action) + const { response, detail } = await readOrder(context, fixture, orderId) + context.assert(MODULE, `${target} 订单状态样本可从详情打开`, response, + id(detail?.order?.id) === orderId && detail?.order?.status === target, + `orderId=${orderId}, status=${detail?.order?.status ?? 'missing'}`) + result.orders[target] = { orderId, orderNo: detail?.order?.orderNo || null, status: target, + listPath: `/admin/first-leg-orders?status=${target}` } + matrix.pages.firstLegOrders.fixtures[target] = { + id: orderId, status: target, detailPath: `/orders/${orderId}`, + } + + if (actions.includes('approve')) { + const inbound = await waitForInbound(context, fixture, orderId) + const inboundId = id(inbound.summary?.inboundId) + context.assert(MODULE, `${target} 订单生成待测量入库单`, inbound.response, Boolean(inboundId), + `orderId=${orderId}, inboundId=${inboundId || 'missing'}`) + if (inboundId && !result.inbounds.WAIT_MEASURE) result.inbounds.WAIT_MEASURE = { + inboundId, inboundCode: inbound.summary?.inboundCode || null, orderId, processStatus: 'WAIT_MEASURE', + listPath: '/admin/inbounds?processStatus=WAIT_MEASURE', + } + } + } + + for (const [status, sample] of Object.entries(result.orders)) { + const response = await context.request('GET', + `/api/platform/v1/orders/first-leg?tenantId=${query(fixture.tenantId)}&status=${status}&keyword=${query(sample.orderNo)}&page=1&size=20`) + const page = context.apiSuccess(MODULE, `${status} 订单可按列表状态筛选`, response) + context.assert(MODULE, `${status} 筛选结果包含人工样本`, response, + rows(page).some((item) => id(item.id) === sample.orderId), `orderId=${sample.orderId}, total=${page?.total ?? 'n/a'}`) + } + + const waitMeasure = result.inbounds.WAIT_MEASURE + if (waitMeasure) matrix.pages.inbounds.fixtures.WAIT_MEASURE = { + id: waitMeasure.inboundId, status: 'WAIT_MEASURE', detailPath: `/orders/${waitMeasure.orderId}`, + } + + const submitted = result.orders.SUBMITTED + result.unifiedOrders.PENDING_REVIEW = { orderId: submitted.orderId, orderNo: submitted.orderNo, + mainStatus: 'PENDING_REVIEW', listPath: '/admin/unified-orders?mainStatus=PENDING_REVIEW' } + matrix.pages.unifiedOrders.fixtures.PENDING_REVIEW = { + id: submitted.orderId, status: 'PENDING_REVIEW', detailPath: `/orders/${submitted.orderId}`, + } + if (waitMeasure) result.unifiedOrders.WAIT_MEASURE = { orderId: waitMeasure.orderId, + inboundId: waitMeasure.inboundId, mainStatus: 'WAIT_MEASURE', listPath: '/admin/unified-orders?mainStatus=WAIT_MEASURE' } + if (waitMeasure) matrix.pages.unifiedOrders.fixtures.WAIT_MEASURE = { + id: waitMeasure.orderId, status: 'WAIT_MEASURE', detailPath: `/orders/${waitMeasure.orderId}`, + } + + for (const status of ['MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED', 'INBOUNDED']) { + const sample = await prepareInboundStatus(context, status) + result.inbounds[status] = sample + matrix.pages.inbounds.fixtures[status] = { + id: sample.inboundId, status, detailPath: `/orders/${sample.orderId}`, + } + if (status === 'INBOUNDED') { + result.unifiedOrders.INBOUNDED = sample + matrix.pages.unifiedOrders.fixtures.INBOUNDED = { + id: sample.orderId, status, detailPath: `/orders/${sample.orderId}`, + } + } + } + + const blockers = { + WAIT_PRICING: '当前审核通过 API 直接写 INBOUNDED;仅异常放行开关路径可写 WAIT_PRICING', + FEE_CONFIRMING: '需要先取得可匹配的真实计价快照并停在费用确认前', + INBOUNDING: 'AutomaticInboundCompletionService 在同一调用内立即从 INBOUNDING 推进 INBOUNDED,无可暂停 API', + VOIDED: 'InboundProcessStatus 声明了 VOIDED,但当前 Controller/Service 没有作废入库单 API', + } + for (const status of INBOUND_FILTER_STATUSES) if (!matrix.pages.inbounds.fixtures[status]) { + context.assert(MODULE, `入库列表保留 ${status}`, null, false, blockers[status], OPTIONS) + } + const unifiedBlockers = { + WAIT_SPLIT: '服务端已将 WAIT_SPLIT 标记为历史废弃状态,查询时统一归一为 INBOUNDED,不能再写入或返回', + LOADING: '需要分柜、排柜及 PDA 专项独立样本', + LOADED: '需要完成装柜证据和 PDA 门禁的专项独立样本', + DELIVERED: '当前后端没有完成派送并写 DELIVERED 的公开业务 API', + } + for (const status of UNIFIED_FILTER_STATUSES) if (!matrix.pages.unifiedOrders.fixtures[status]) { + context.assert(MODULE, `统一订单列表保留 ${status}`, null, false, unifiedBlockers[status], OPTIONS) + } + + context.addStep(MODULE, '登记订单/入库人工状态矩阵', null, true, + `orders=${Object.keys(result.orders).length}, inbounds=${Object.keys(result.inbounds).length}`, OPTIONS) +} diff --git a/scripts/backend-full-regression.ps1 b/scripts/backend-full-regression.ps1 index f633af92b..49dbbb562 100644 --- a/scripts/backend-full-regression.ps1 +++ b/scripts/backend-full-regression.ps1 @@ -14,12 +14,21 @@ $requiredTests = @( "firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java", "firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java", "firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java", - "order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java" + "order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java", + "iam/src/test/java/com/baiyue/iam/BaiyueIamApplicationContractTest.java", + "iam/src/test/java/com/baiyue/iam/notification/NotificationOpenApiContractTest.java", + "iam/src/test/java/com/baiyue/iam/notification/NotificationServiceTest.java", + "iam/src/test/java/com/baiyue/iam/notification/NotificationTemplateRendererTest.java", + "iam/src/test/java/com/baiyue/iam/notification/NotificationSqlContractTest.java", + "iam/src/test/java/com/baiyue/iam/notification/NotificationBusinessEventConsumerTest.java", + "iam/src/test/java/com/baiyue/iam/notification/NotificationSchedulerRecoveryTest.java", + "iam/src/test/java/com/baiyue/iam/notification/NotificationConfigurationContractTest.java", + "gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java" ) -$mavenArgs = @("-f", $pom, "-pl", "order,firstleg", "-am", "test") +$mavenArgs = @("-f", $pom, "-pl", "order,firstleg,iam,gateway", "-am", "test") Write-Host "Backend regression root: $repoRoot" -Write-Host "Scope: order + firstleg Maven reactor (includes all module tests)" +Write-Host "Scope: order + firstleg + iam + gateway Maven reactor (includes all module tests)" Write-Host "Database/API access: none" if (-not (Test-Path -LiteralPath $pom)) {