This commit is contained in:
2026-08-12 20:36:11 +08:00
parent 0380c0553b
commit 0ed1531683
26 changed files with 1690 additions and 15 deletions
+8
View File
@@ -43,6 +43,14 @@
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
- 全量验证:`mvn -DskipTests validate`、`mvn test`;单服务优先 `mvn -pl <module> -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`
@@ -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;
@@ -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> 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) {}
}
@@ -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<PageResult<ContainerBookingEntity>> 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<BusinessDocument> 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<BusinessDocument> 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<BusinessDocument> 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));
}
}
@@ -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<BusinessDocument> 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<BusinessDocument> 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<BusinessDocument> 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));
}
}
@@ -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) {
}
@@ -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;
}
@@ -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,
@@ -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<ItemRow> items = mapper.selectItems(row.getPlanId());
List<Customer> customers = items.stream()
@@ -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<String> 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<PreloadSummary> 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<String, Object> 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<String, Object> 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 = "修改预装集装箱号与航次")
@@ -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 ;
@@ -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$$
@@ -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:
@@ -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}
@@ -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<String, Object> components = stringMap(document.get("components"));
Map<String, Object> 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");
@@ -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;
}
}
+1 -1
View File
@@ -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',
@@ -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/)
})
@@ -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)
}
@@ -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/)
})
@@ -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)
}
@@ -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/)
})
@@ -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)
}
}
@@ -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\}`/)
})
@@ -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)
}
+12 -3
View File
@@ -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)) {