优化
This commit is contained in:
@@ -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) {}
|
||||
}
|
||||
+38
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+38
@@ -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}
|
||||
|
||||
+13
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user