Compare commits

...
141 changed files with 2503 additions and 1597 deletions
+7 -3
View File
@@ -108,14 +108,14 @@
## 数据库访问与中文代码注释(所有服务及公共模块强制)
- 本节适用于全部后端服务和公共模块;新增、修改的方法必须执行,触及的存量代码同步整改。登记本约束不代表存量代码已全部完成迁移和注释补齐。
- 本节适用于全部后端服务和公共模块;新增代码遵守下列规范,存量分层仅在影响本次正确性或用户明确要求时整改,不因局部修改迁移整条链路;以上为目标规范,不代表存量均已达标。
- 生产代码的关系型数据库读写统一经 MyBatis/MyBatis-Plus Mapper 执行;Controller、Service、定时任务、消费者、编号生成器和公共基础组件不得直接使用 `JdbcTemplate`、`NamedParameterJdbcTemplate`、原生 JDBC/Connection/Statement 或其他通道执行 SQL。仅改成 Repository 包装 JDBC 不算整改,SQL 必须进入 Mapper;无数据库职责的模块不得为满足本条新增数据库访问。
- 正常业务数据库访问路径统一为 `Controller → Service → Repository → Mapper`,四层职责明确,不得省略 Repository,也不得由 Controller 直接调用 Repository/Mapper,或由 Service 直接调用 Mapper。定时任务、消费者等非 HTTP 入口从 Service 进入,同样经 Repository → Mapper 完成持久化访问。
- Controller 负责请求接收、参数校验、入口权限和响应转换;Service 负责业务规则、编排及业务事务;Repository 负责领域数据访问封装、查询组合与持久化模型转换,对 Service 提供有业务含义的数据操作;Mapper 负责 SQL 执行及数据库记录映射。Repository 不承载业务状态流转,不直接用 JDBC 执行 SQL;Service 不继承 MyBatis-Plus `ServiceImpl` 等方式绕过 Repository。
- 复杂查询、批量处理、原子计数、锁定读取、Outbox 和数据库监控查询同样经 Repository → Mapper,不以 SQL 特殊或实现方便为由跳层。公共模块需要数据库能力时,按现有模块边界定义 Repository/持久化接口,由所属服务的 Repository → Mapper 实现,禁止借此跨服务读库。
- 迁移数据库操作须保留客户隔离条件、事务传播、行锁、幂等和并发语义;`LAST_INSERT_ID()` 等连接级操作必须在同一事务连接完成,并明确关闭相关读取缓存,不能仅机械搬移 SQL。Mapper 注册及扫描仍遵守下文统一规范。
- 测试代码可用 `JdbcTemplate` 创建隔离测试表、准备数据及核对结果;被测生产路径仍必须调用实际 Mapper,不得把测试造数做法带入生产。数据库基线和迁移脚本按现有部署契约管理。
- 每个手写方法及构造器均须添加简短中文注释,通常 1—3 行说明业务用途;接口方法和 Mapper 方法优先使用中文 Javadoc。覆盖方法同样须有简短中文说明,实现中的关键规则仍须就地注释;自动生成的方法不手工逐个补注释。
- 本次新增或修改的手写方法及构造器须有简短中文业务注释,通常 1—3 行;接口与 Mapper 方法优先用中文 Javadoc,覆盖方法同样适用,关键规则就地说明。不补齐未修改或自动生成方法的注释。
- 核心业务、复杂 SQL、状态流转、资金与库存变更、权限隔离、事务、锁、幂等、并发、重试和补偿逻辑,须在关键代码前增加适量中文注释,解释业务规则、处理原因、约束边界及失败影响;不逐行翻译代码,不添加无信息量或与实现不符的注释。
- 新增和修改的代码说明注释统一使用中文;必要的技术标识、协议名、类名可保留原文,第三方许可证和生成标记保持原样。修改方法时同步更新相关存量英文或过期说明;审查时同时核对 Controller → Service → Repository → Mapper 分层及中文方法、核心逻辑注释。
@@ -132,7 +132,11 @@
- 数据库只访问所属服务库。模块表结构位于 `<module>/src/main/resources/db/`;开发环境按最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql` 收敛,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作结构来源和追溯,部署说明见 `deploy/sql/README.md`;生产升级另按明确发布规则处理。
- 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。
- 通用配置在 `config/nacos/test/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
- Java 签名变更的最低编译门禁是 `mvn -pl <module> -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl <module> -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
- 后端本地验证默认使用 `scripts/backend-check.ps1`,必须显式指定 `-Modules`,不传模块直接拒绝;命令及参数见 `scripts/README.md` 的“后端分层检查”。入口自动经 `local-check.ps1` 限流,`-DryRun` 只检查选择范围,不代表测试通过。
- 开发迭代使用 `-Mode Focused -Tests <精确测试类名>`,覆盖修改行为及直接调用点;Java 签名变化至少使用 `-Mode Compile` 编译生产与测试源码(等价于 `mvn -pl <modules> -am test-compile`)。单服务行为变更收尾使用默认 `Module` 模式执行该服务及上游依赖测试;跨服务 DTO、接口或事件变化必须显式列入生产者和受影响消费者。`-am` 只补上游构建依赖,不自动包含下游服务或 HTTP/事件消费者;不能以单类测试代替模块收尾。
- 仅注释/文档调整不触发 Java 回归;脚本调整执行受影响脚本测试和登记合同。API、Mapper、AGENTS 等静态合同仍按各自触发条件执行。纯重构也应覆盖受影响行为,不能只用编译宣称回归通过。`-Dmaven.test.skip=true` 跳过测试编译,不得作为签名变更交付验证。
- 全仓回归只在以下情况触发:根 POM/统一依赖版本/聚合拓扑/测试基础配置变化;公共认证、序列化、基础模型等影响广泛且不能可靠限定消费者;跨多域批次集成验收;用户明确要求全量回归。使用 `scripts/backend-full-regression.ps1`,保留当前 CI 全量验证。普通单服务任务、单次提交或对话收尾不自动触发全仓;已明确消费者的公共或跨服务变更按受影响模块验证。同批同一相关输入只跑一次,输入变化后补跑受影响范围,触及上述全量条件再重跑全量。
- 不以缩短耗时为由删除金额、库存、权限隔离、状态流转、幂等和并发测试;冗余测试须根据重复场景及维护成本单独评估。不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
- 同一批 `AGENTS.md` 索引修改完成后从后端根统一运行一次 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,不访问 HTTP、数据库或部署环境。
- Mapper 注册静态合同为 `scripts/mapper-scan-contract.mjs`、`mapper-scan-contract.test.mjs`:自动发现聚合模块、生产 `*Mapper` 接口、服务启动类和分散扫描配置,不启动 Spring、不连接数据库;遗漏注解、扫描根漂移或重新引入子域扫描时直接失败并输出类与文件路径。
- 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。
+8 -3
View File
@@ -23,11 +23,16 @@
## 本地构建
```powershell
mvn -DskipTests validate
node --test scripts/java-topology-contract.test.mjs
mvn test
# 日常按受影响模块检查;跨服务变更显式列入消费者。
.\scripts\backend-check.ps1 -Modules order -Mode Compile
.\scripts\backend-check.ps1 -Modules order
# 根构建/拓扑变化等全量触发条件见 AGENTS.md。
.\scripts\local-check.ps1 -Command @('node', '--test', 'scripts/java-topology-contract.test.mjs')
.\scripts\backend-full-regression.ps1
```
开发中可通过 `backend-check.ps1 -Mode Focused -Modules <模块> -Tests <精确测试类名>` 快速反馈;参数、DryRun 和结果边界见 [后端分层检查](scripts/README.md#后端分层检查)。单服务修改收尾执行模块回归,不默认执行上面的全仓命令。
GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务制品打包。Maven `verify` 会为执行过测试且产生覆盖率数据的模块生成 `target/site/jacoco/index.html` 与 `jacoco.xml`;当前只生成报告,不设置覆盖率阈值,也不改变测试选择。Jenkins 的 `java-builder` 尚未在仓库中声明 Node 运行时,因此暂不重复执行该 Node 合同,以 GitLab 门禁和上述本地命令为准。
2026-07-30 订单与客户前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。
@@ -40,8 +40,14 @@ public class GlobalExceptionHandler {
@Autowired(required = false)
private MeterRegistry meterRegistry;
/** 系统类业务失败必须记录链路和异常,普通业务校验仍只返回统一响应。 */
@ExceptionHandler(BizException.class)
public ResponseEntity<ApiResponse<Void>> handleBusiness(BizException exception, HttpServletRequest request) {
if (exception.getResultCode().httpStatus().is5xxServerError()) {
log.error("系统业务请求失败 method={} path={} code={} requestId={} traceId={}",
request.getMethod(), request.getRequestURI(), exception.getResultCode().code(),
CorrelationContext.requestId(request), CorrelationContext.traceId(request), exception);
}
return failure(exception.getResultCode(), exception.getMessage(), request);
}
@@ -5,6 +5,9 @@ import com.baiyue.common.core.exception.BizException;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.core.MethodParameter;
import org.springframework.validation.BeanPropertyBindingResult;
@@ -39,6 +42,37 @@ class GlobalExceptionHandlerTest {
assertThat(response.getBody().requestId()).isEqualTo("r1");
}
/** 系统类业务失败留下路径、关联标识和根因,但不打印请求参数或令牌。 */
@Test
@ExtendWith(OutputCaptureExtension.class)
void systemBusinessFailureLogsCorrelationAndCause(CapturedOutput output) {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/platform/v1/orders/status-counts");
request.setAttribute(CorrelationContext.REQUEST_ID, "status-count-request");
request.setAttribute(CorrelationContext.TRACE_ID, "status-count-trace");
request.setQueryString("keyword=private-query");
request.addHeader("X-Internal-Service-Token", "private-service-token");
BizException exception = new BizException(ResultCode.SERVICE_UNAVAILABLE, "统计暂不可用");
exception.initCause(new IllegalStateException("缺少尾程来源"));
var response = handler.handleBusiness(exception, request);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody().code()).isEqualTo("S0503");
assertThat(response.getBody().message()).isEqualTo("统计暂不可用");
assertThat(output.getOut()).contains("ERROR", "GET", "/api/platform/v1/orders/status-counts",
"S0503", "status-count-request", "status-count-trace", "缺少尾程来源")
.doesNotContain("private-query", "private-service-token");
}
/** 预期的业务冲突不应升级为系统错误日志。 */
@Test
@ExtendWith(OutputCaptureExtension.class)
void normalBusinessFailureDoesNotLogSystemError(CapturedOutput output) {
handler.handleBusiness(new BizException(ResultCode.CONFLICT, "normal-business-conflict"),
new MockHttpServletRequest());
assertThat(output.getAll()).doesNotContain("系统业务请求失败", "normal-business-conflict");
}
@Test
void invalidQueryParameterTypeMapsToBadRequest() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest();
+2 -1
View File
@@ -5,7 +5,8 @@ spring:
password: ${FINANCE_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:}
baiyue.finance.recharge.qr-code-url: ${FINANCE_RECHARGE_QR_CODE_URL:}
# TEST 流程验收复用 Client 的示例图片,图片标注不可付款且不含真实收款信息。
baiyue.finance.recharge.qr-code-url: ${FINANCE_RECHARGE_QR_CODE_URL:/customer-recharge-qr.svg}
baiyue.finance.wallet.default-credit-limit: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:0.00}
baiyue.event-delivery.operations-token: ${EVENT_OPERATIONS_TOKEN:}
baiyue.event-delivery.connect-timeout-ms: ${EVENT_CONNECT_TIMEOUT_MS:3000}
@@ -4874,7 +4874,7 @@ CREATE TABLE ll_fleet_pickup_history (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史';
-- 自有业务仅保存原排车的归属证据和附加运营信息,不复制履约主单。
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_ownership (
CREATE TABLE ll_fleet_owned_business_ownership (
dispatch_id BIGINT NOT NULL COMMENT '原排车内部主键',
resource_mode VARCHAR(16) NOT NULL COMMENT '原排车资源模式MASTER或EXTERNAL',
vehicle_id BIGINT NULL COMMENT '真实车辆档案主键,临时外配为空',
@@ -4892,7 +4892,7 @@ CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_ownership (
OR (resource_mode='EXTERNAL' AND vehicle_id IS NULL AND vehicle_record_version IS NULL AND vehicle_ownership='DISPATCHED'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车装车完成时的车辆归属证据';
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_record (
CREATE TABLE ll_fleet_owned_business_record (
id BIGINT NOT NULL COMMENT '附加运营记录内部主键',
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
record_version BIGINT NOT NULL COMMENT '附加运营记录并发版本,独立于原排车版本',
@@ -4917,7 +4917,7 @@ CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_record (
CONSTRAINT ck_owned_business_fuel CHECK (fuel_consumption_litres_per_100km IS NULL OR fuel_consumption_litres_per_100km>=0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营信息';
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_command (
CREATE TABLE ll_fleet_owned_business_command (
id BIGINT NOT NULL COMMENT '附加运营命令内部主键',
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
operator_id VARCHAR(64) NOT NULL COMMENT '原命令操作人标识',
@@ -4938,7 +4938,7 @@ CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_command (
CONSTRAINT ck_owned_business_command_status CHECK (status IN ('PENDING','SUCCEEDED','REJECTED','UNKNOWN_RESULT'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车附加运营写命令及可恢复结果';
CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history (
CREATE TABLE ll_fleet_owned_business_history (
id BIGINT NOT NULL COMMENT '附加运营历史内部主键',
dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键',
record_version BIGINT NOT NULL COMMENT '成功写入后的附加记录版本',
@@ -11,6 +11,7 @@ enum BusinessFileCategory {
FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_FUEL_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
FLEET_VEHICLE_RECORD_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
@@ -78,7 +79,7 @@ enum BusinessFileCategory {
&& tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "排车运营照片必须归属平台");
}
if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE)
if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE)
&& tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "排车车辆及司机证据必须归属平台");
}
@@ -4,9 +4,9 @@ info:
version: 1.0.0
description: >-
复用 InternalBusinessFileController 的现有通用内部入口,不新增公开文件路由。
两类证据均固定平台 tenantId=0,businessId 为排车内部正数 Long ID 的十进制表示,
三类证据均固定平台 tenantId=0,businessId 为排车内部正数 Long ID 的十进制表示,
不使用车辆 ID、司机 ID、车牌或客户 ID。车辆与司机证据分别使用
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_EVIDENCE,不可互换。
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE,不可互换。司机本人照片DRIVER_PHOTO仅支持JPG/PNG;其他两类沿PDF/JPG/PNG。
File 校验内部服务令牌、平台归属、正数业务 ID、文件非空、最大 10MiB(10485760 字节,包含边界)、
PDF/JPG/PNG MIME 及对应内容签名。元数据和下载按 tenantId、businessType、businessId、fileId
联合查询 ACTIVE 且未删除文件,错车或错类别均视为归属不匹配,下载前完成归属校验。
@@ -126,7 +126,7 @@ components:
description: 正数Long的十进制字符串,值不得超过9223372036854775807
DispatchEvidenceType:
type: string
enum: [OVERSEAS_DISPATCH_VEHICLE_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_EVIDENCE]
enum: [OVERSEAS_DISPATCH_VEHICLE_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE]
MetadataEnvelope:
type: object
required: [code, data]
@@ -30,6 +30,7 @@ import static org.mockito.Mockito.*;
class DispatchEvidenceFileTest {
private static final String VEHICLE = "OVERSEAS_DISPATCH_VEHICLE_EVIDENCE";
private static final String PHOTO = "OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE";
private static final String DRIVER = "OVERSEAS_DISPATCH_DRIVER_EVIDENCE";
private static final int MAX_BYTES = 10 * 1024 * 1024;
@TempDir Path root;
@@ -40,9 +41,9 @@ class DispatchEvidenceFileTest {
FileObjectEntity.class);
}
/** 两类证据只接受平台与有效排车内部 ID,非法输入不得访问数据库或磁盘。 */
/** 三类证据只接受平台与有效排车内部 ID,非法输入不得访问数据库或磁盘。 */
@ParameterizedTest
@ValueSource(strings = {VEHICLE, DRIVER})
@ValueSource(strings = {VEHICLE, DRIVER, PHOTO})
void requiresPlatformAndPositiveDispatchIdBeforeAccess(String type) {
var files = mock(FileObjectMapper.class);
var storage = mock(BusinessFileStorage.class);
@@ -64,6 +65,18 @@ class DispatchEvidenceFileTest {
verifyNoInteractions(files, storage);
}
/** 司机本人照片使用独立平台类别,只保存真实图片,拒绝PDF和伪造图片。 */
@org.junit.jupiter.api.Test void driverPhotoAcceptsOnlyRealImages() throws Exception {
var storage=storage();var category=BusinessFileCategory.require(PHOTO);
for(var format:new String[]{"jpeg","png"}) {
var upload=new MockMultipartFile("file","driver."+format,"image/"+format,image(format));
var saved=storage.store(0,"71",upload,category);
assertThat(storage.read(saved.objectKey())).isEqualTo(upload.getBytes());
}
assertThatThrownBy(()->storage.store(0,"71",pdf(),category)).isInstanceOf(BizException.class);
assertThatThrownBy(()->storage.store(0,"71",new MockMultipartFile("file","driver.png","image/png",pdf().getBytes()),category)).isInstanceOf(BizException.class);
}
/** 真实 PDF、JPG、PNG 均可写入并保持内容;上传不以扩展名代替 MIME 和签名。 */
@ParameterizedTest
@ValueSource(strings = {VEHICLE, DRIVER})
@@ -149,7 +162,7 @@ class DispatchEvidenceFileTest {
/** 元数据和下载均逐列带全复合键;错车、错类别、错文件不得读取存储内容。 */
@ParameterizedTest
@ValueSource(strings = {VEHICLE, DRIVER})
@ValueSource(strings = {VEHICLE, DRIVER, PHOTO})
void metadataAndDownloadEnforceEveryScopeKeyBeforeReading(String type) throws Exception {
var files = mock(FileObjectMapper.class);
var storage = mock(BusinessFileStorage.class);
@@ -37,7 +37,7 @@ public final class FinanceSettlementDtos {
BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount, String currency,
String reviewStatus, String sourceStatus,
boolean statementGenerated) {}
public record SettlementTransaction(long transactionId, String transactionType, LocalDate transactionDate,
public record SettlementTransaction(long transactionId, String transactionNo, String transactionType, LocalDate transactionDate,
BigDecimal amount, String method, String referenceNo,
List<String> voucherFileIds, long createdBy, String createdByName,
LocalDateTime createdAt,
@@ -0,0 +1,43 @@
package com.baiyue.finance.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
import java.util.Map;
/** 结算详情只读SQL;子查询均保留所属客户和原结算范围。 */
@Mapper
public interface FinanceSettlementDetailMapper {
String SUMMARY_SELECT =
"SELECT s.id,s.tenant_id,s.settlement_no,s.direction,s.customer_name_snapshot,s.supplier_id," +
"s.supplier_name_snapshot,s.reconciliation_statement_id,b.bill_no reconciliation_statement_no," +
"s.business_period,(SELECT GROUP_CONCAT(DISTINCT fs.fee_item_name ORDER BY fs.fee_item_name SEPARATOR '、') " +
"FROM fin_settlement_line sl JOIN fin_fee_source fs ON fs.id=sl.fee_source_id " +
"WHERE sl.tenant_id=s.tenant_id AND sl.settlement_id=s.id) fee_item_summary," +
"s.currency_code,s.total_amount,s.paid_amount,s.outstanding_amount,s.generated_date,s.due_date," +
"s.payment_status,s.status,s.created_by,(SELECT a.operator_name_snapshot FROM fin_audit_log a " +
"WHERE a.tenant_id=s.tenant_id AND a.resource_type='FIN_SETTLEMENT' AND a.resource_id=s.id " +
"AND a.action='CREATE' ORDER BY a.created_at,a.id LIMIT 1) created_by_name,s.created_at,s.updated_at," +
"CASE WHEN s.direction='RECEIVABLE' THEN (SELECT MAX(r.created_at) FROM fin_receipt_bill rb " +
"JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id " +
"WHERE rb.tenant_id=s.tenant_id AND rb.bill_id=s.reconciliation_statement_id AND r.status='CONFIRMED') " +
"ELSE (SELECT MAX(p.created_at) FROM fin_settlement_payment p WHERE p.tenant_id=s.tenant_id " +
"AND p.settlement_id=s.id) END last_payment_at,s.version FROM fin_settlement s " +
"LEFT JOIN fin_bill b ON b.tenant_id=s.tenant_id AND b.id=s.reconciliation_statement_id ";
/** 读取平台结算主单及来源摘要。 */
@Select(SUMMARY_SELECT + "WHERE s.id=#{settlementId}")
List<Map<String, Object>> summary(@Param("settlementId") long settlementId);
/** 读取该客户该结算的费用明细。 */
@Select("SELECT f.id fee_source_id,f.source_type,f.source_fee_id,f.source_fee_version,f.direction,f.customer_id,f.order_id,f.inbound_id,f.container_id,f.fee_item_name,f.amount,f.currency_code,f.review_status,f.status,f.source_snapshot FROM fin_settlement_line sl JOIN fin_fee_source f ON f.id=sl.fee_source_id WHERE sl.tenant_id=#{customerId} AND sl.settlement_id=#{settlementId} ORDER BY sl.id")
List<Map<String, Object>> lines(@Param("customerId") long customerId, @Param("settlementId") long settlementId);
/** 读取该客户付款流水及落库业务号,不能用ID替代。 */
@Select("SELECT id,transaction_no,transaction_type,transaction_date,amount,method,reference_no,voucher_file_ids,created_by,created_by_name,created_at,remark FROM fin_settlement_payment WHERE tenant_id=#{customerId} AND settlement_id=#{settlementId} ORDER BY created_at,id")
List<Map<String, Object>> payments(@Param("customerId") long customerId, @Param("settlementId") long settlementId);
/** 读取对账单已确认收款;收款凭证号不是PAY业务号。 */
@Select("SELECT r.id,'RECEIPT' transaction_type,DATE(r.received_at) transaction_date,rb.applied_amount amount,r.payment_method method,r.voucher_no reference_no,r.voucher_file_ids,r.created_by,r.created_by_name,r.created_at,r.remark FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id WHERE rb.tenant_id=#{customerId} AND rb.bill_id=#{statementId} AND r.status='CONFIRMED' ORDER BY r.created_at,r.id")
List<Map<String, Object>> receipts(@Param("customerId") long customerId, @Param("statementId") long statementId);
/** 读取该客户结算修订历史。 */
@Select("SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log WHERE tenant_id=#{customerId} AND resource_type='FIN_SETTLEMENT' AND resource_id=#{settlementId} ORDER BY created_at,id")
List<Map<String, Object>> audits(@Param("customerId") long customerId, @Param("settlementId") long settlementId);
}
@@ -0,0 +1,29 @@
package com.baiyue.finance.repository;
import com.baiyue.finance.mapper.FinanceSettlementDetailMapper;
import org.springframework.stereotype.Repository;
import org.springframework.util.LinkedCaseInsensitiveMap;
import java.util.List;
import java.util.Map;
/** 结算详情查询封装,列名大小写差异不改变已有字段映射语义。 */
@Repository
public class FinanceSettlementDetailRepository {
private final FinanceSettlementDetailMapper mapper;
/** 注入结算详情持久化入口。 */
public FinanceSettlementDetailRepository(FinanceSettlementDetailMapper mapper) { this.mapper = mapper; }
/** 保留原数据库列名不区分大小写的读取语义。 */
private List<Map<String, Object>> rows(List<Map<String, Object>> rows) {
return rows.stream().map(row -> { Map<String, Object> copy = new LinkedCaseInsensitiveMap<>(); copy.putAll(row); return copy; }).toList();
}
/** 读取平台结算主单及来源摘要。 */
public List<Map<String, Object>> summary(long settlementId) { return rows(mapper.summary(settlementId)); }
/** 读取该客户该结算的费用明细。 */
public List<Map<String, Object>> lines(long customerId, long settlementId) { return rows(mapper.lines(customerId, settlementId)); }
/** 读取该客户付款流水及落库业务号,不能用ID替代。 */
public List<Map<String, Object>> payments(long customerId, long settlementId) { return rows(mapper.payments(customerId, settlementId)); }
/** 读取对账单已确认收款;收款凭证号不是PAY业务号。 */
public List<Map<String, Object>> receipts(long customerId, long statementId) { return rows(mapper.receipts(customerId, statementId)); }
/** 读取该客户结算修订历史。 */
public List<Map<String, Object>> audits(long customerId, long settlementId) { return rows(mapper.audits(customerId, settlementId)); }
}
@@ -12,6 +12,7 @@ import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceEditRequest;
import com.baiyue.finance.client.FirstlegFeeSourceClient;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import feign.FeignException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
@@ -437,7 +438,14 @@ public class FinancePhase1OperationService {
private static Long nullableLong(ResultSet rs, String column) throws SQLException { long value = rs.getLong(column); return rs.wasNull() ? null : value; }
private JsonNode parse(String json) { try { return objectMapper.readTree(json); } catch (Exception e) { throw error("FIN-DATA-500", "费用来源快照损坏"); } }
private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw error("FIN-DATA-500", "财务数据序列化失败"); } }
private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw error("FIN-DATA-500", "幂等请求哈希失败"); } }
/** 固定映射键序,使同一请求在服务重启前后使用相同摘要。 */
private String hash(Object value) {
try {
byte[] canonical = objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
.writeValueAsBytes(value);
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical));
} catch (Exception failure) { throw error("FIN-DATA-500", "幂等请求哈希失败"); }
}
private static String text(JsonNode node, String field) { JsonNode value = node.path(field); return value.isMissingNode() || value.isNull() ? null : value.asText(); }
private static Long jsonLong(JsonNode node, String field) {
JsonNode value = node.path(field);
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
@@ -306,7 +307,14 @@ public class FinanceService {
default -> throw new IllegalArgumentException("未知财务幂等操作: " + operation);
};
}
private String hash(Object request) { try { byte[] bytes = java.security.MessageDigest.getInstance("SHA-256").digest(json(request).getBytes(java.nio.charset.StandardCharsets.UTF_8)); return java.util.HexFormat.of().formatHex(bytes); } catch (Exception ex) { throw new IllegalStateException("请求哈希失败", ex); } }
/** 固定请求映射键序,避免服务重启后拒绝同一财务命令。 */
private String hash(Object request) {
try {
byte[] canonical = objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
.writeValueAsBytes(request == null ? List.of() : request);
return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(canonical));
} catch (Exception ex) { throw new IllegalStateException("请求哈希失败", ex); }
}
private static void requireCustomer(long tenantId) { if (tenantId <= 0) throw bad("客户范围无效"); }
private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST, message); }
@@ -45,23 +45,9 @@ import java.util.UUID;
public class FinanceSettlementService {
private static final String CNY = "CNY";
private static final Set<String> PAYMENT_METHODS = Set.of("BANK_TRANSFER", "CASH", "OTHER");
private static final String SETTLEMENT_SUMMARY_SELECT =
"SELECT s.id,s.tenant_id,s.settlement_no,s.direction,s.customer_name_snapshot,s.supplier_id," +
"s.supplier_name_snapshot,s.reconciliation_statement_id,b.bill_no reconciliation_statement_no," +
"s.business_period,(SELECT GROUP_CONCAT(DISTINCT fs.fee_item_name ORDER BY fs.fee_item_name SEPARATOR '、') " +
"FROM fin_settlement_line sl JOIN fin_fee_source fs ON fs.id=sl.fee_source_id " +
"WHERE sl.tenant_id=s.tenant_id AND sl.settlement_id=s.id) fee_item_summary," +
"s.currency_code,s.total_amount,s.paid_amount,s.outstanding_amount,s.generated_date,s.due_date," +
"s.payment_status,s.status,s.created_by,(SELECT a.operator_name_snapshot FROM fin_audit_log a " +
"WHERE a.tenant_id=s.tenant_id AND a.resource_type='FIN_SETTLEMENT' AND a.resource_id=s.id " +
"AND a.action='CREATE' ORDER BY a.created_at,a.id LIMIT 1) created_by_name,s.created_at,s.updated_at," +
"CASE WHEN s.direction='RECEIVABLE' THEN (SELECT MAX(r.created_at) FROM fin_receipt_bill rb " +
"JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id " +
"WHERE rb.tenant_id=s.tenant_id AND rb.bill_id=s.reconciliation_statement_id AND r.status='CONFIRMED') " +
"ELSE (SELECT MAX(p.created_at) FROM fin_settlement_payment p WHERE p.tenant_id=s.tenant_id " +
"AND p.settlement_id=s.id) END last_payment_at,s.version FROM fin_settlement s " +
"LEFT JOIN fin_bill b ON b.tenant_id=s.tenant_id AND b.id=s.reconciliation_statement_id ";
private static final String SETTLEMENT_SUMMARY_SELECT = com.baiyue.finance.mapper.FinanceSettlementDetailMapper.SUMMARY_SELECT;
private final JdbcTemplate jdbc;
private final com.baiyue.finance.repository.FinanceSettlementDetailRepository details;
private final ObjectMapper objectMapper;
private final FinanceVoucherValidator voucherValidator;
private final FinanceSupplierValidator supplierValidator;
@@ -71,8 +57,10 @@ public class FinanceSettlementService {
@Autowired
public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator,
FinanceSupplierValidator supplierValidator,
FinanceDocumentNumberGenerator documentNumbers) {
FinanceDocumentNumberGenerator documentNumbers,
com.baiyue.finance.repository.FinanceSettlementDetailRepository details) {
this.jdbc = jdbc;
this.details = details;
this.objectMapper = objectMapper;
this.voucherValidator = voucherValidator;
this.supplierValidator = supplierValidator;
@@ -183,33 +171,21 @@ public class FinanceSettlementService {
return getSettlement(settlementId);
}
/** 读取结算及其业务流水号;全部详情数据访问经Repository和Mapper。 */
public SettlementDetail getSettlement(long settlementId) {
if (settlementId <= 0) throw error("FIN_404", "结算单不存在");
Map<String, Object> row = one(
SETTLEMENT_SUMMARY_SELECT + "WHERE s.id=?",
"结算单不存在", settlementId);
SettlementSummary summary = summary(row);
List<SettlementLine> lines = jdbc.queryForList(
"SELECT f.id fee_source_id,f.source_type,f.source_fee_id,f.source_fee_version,f.direction,f.customer_id," +
"f.order_id,f.inbound_id,f.container_id,f.fee_item_name,f.amount,f.currency_code,f.review_status,f.status,f.source_snapshot " +
"FROM fin_settlement_line sl JOIN fin_fee_source f ON f.id=sl.fee_source_id " +
"WHERE sl.tenant_id=? AND sl.settlement_id=? ORDER BY sl.id", summary.customerId(), settlementId)
List<Map<String, Object>> records = details.summary(settlementId);
if (records.isEmpty()) throw error("FIN_404", "结算单不存在");
SettlementSummary summary = summary(records.get(0));
List<SettlementLine> lines = details.lines(summary.customerId(), settlementId)
.stream().map(source -> line(source, summary)).toList();
List<SettlementTransaction> transactions = new ArrayList<>(jdbc.queryForList(
"SELECT id,transaction_type,transaction_date,amount,method,reference_no,voucher_file_ids,created_by,created_by_name,created_at,remark " +
"FROM fin_settlement_payment WHERE tenant_id=? AND settlement_id=? ORDER BY created_at,id",
summary.customerId(), settlementId).stream().map(this::transaction).toList());
List<SettlementTransaction> transactions = new ArrayList<>(details.payments(summary.customerId(), settlementId)
.stream().map(this::transaction).toList());
if ("RECEIVABLE".equals(summary.direction()) && summary.reconciliationStatementId() != null) {
transactions.addAll(jdbc.queryForList(
"SELECT r.id,'RECEIPT' transaction_type,DATE(r.received_at) transaction_date,rb.applied_amount amount,r.payment_method method,r.voucher_no reference_no,r.voucher_file_ids,r.created_by,r.created_by_name,r.created_at,r.remark " +
"FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id " +
"WHERE rb.tenant_id=? AND rb.bill_id=? AND r.status='CONFIRMED' ORDER BY r.created_at,r.id",
summary.customerId(), summary.reconciliationStatementId()).stream().map(this::transaction).toList());
transactions.addAll(details.receipts(summary.customerId(), summary.reconciliationStatementId())
.stream().map(this::transaction).toList());
}
List<Map<String, Object>> audits = jdbc.queryForList(
"SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log " +
"WHERE tenant_id=? AND resource_type='FIN_SETTLEMENT' AND resource_id=? ORDER BY created_at,id",
summary.customerId(), settlementId);
List<Map<String, Object>> audits = details.audits(summary.customerId(), settlementId);
List<SettlementRevision> revisions = new ArrayList<>();
for (int index = 0; index < audits.size(); index++) revisions.add(revision(index + 1, audits.get(index)));
return new SettlementDetail(summary, lines, transactions, List.copyOf(revisions));
@@ -477,8 +453,9 @@ public class FinanceSettlementService {
text(row, "status"), true);
}
/** 返回真实付款业务号;未生成PAY号的收款记录保持为空,不回退成内部ID。 */
private SettlementTransaction transaction(Map<String, Object> row) {
return new SettlementTransaction(number(row, "id").longValue(), text(row, "transaction_type"),
return new SettlementTransaction(number(row, "id").longValue(), text(row, "transaction_no"), text(row, "transaction_type"),
date(row, "transaction_date"), decimal(row, "amount"), text(row, "method"), text(row, "reference_no"),
stringList(text(row, "voucher_file_ids")), number(row, "created_by").longValue(),
text(row, "created_by_name"),
@@ -14,6 +14,7 @@ import com.baiyue.finance.dto.FinanceWalletDtos.WalletHoldActionRequest;
import com.baiyue.finance.dto.FinanceWalletDtos.WalletLedgerEntry;
import com.baiyue.finance.dto.FinanceWalletDtos.WalletPage;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.ResponseEntity;
@@ -694,8 +695,9 @@ public class FinanceWalletService {
timestamp(rs.getTimestamp("reviewed_at")));
}
/** 摘要固定映射键序,保证充值和资金指令跨进程重放稳定。 */
private String hash(Object value) {
try { return sha256(objectMapper.writeValueAsBytes(value)); }
try { return sha256(objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS).writeValueAsBytes(value)); }
catch (Exception exception) { throw error("FIN_500", "请求摘要生成失败"); }
}
private String fileHash(MultipartFile file) {
@@ -10,6 +10,7 @@ import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationLine;
import com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.slf4j.MDC;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
@@ -521,9 +522,13 @@ public class ReconciliationStatementService {
catch (Exception exception) { throw error("FIN_001", "财务数据序列化失败"); }
}
/** 固定映射键序,使同一请求在服务重启前后使用相同摘要。 */
private String hash(Object value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); }
catch (Exception exception) { throw error("FIN_001", "幂等请求哈希失败"); }
try {
byte[] canonical = objectMapper.writer().with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
.writeValueAsBytes(value);
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(canonical));
} catch (Exception failure) { throw error("FIN_001", "幂等请求哈希失败"); }
}
private static LocalDateTime localTime(Object value) {
@@ -93,7 +93,7 @@ class FinancePlatformListContractTest {
assertRecordFields(com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision.class,
"operatorId", "operatorName");
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementTransaction.class,
"createdBy", "createdByName");
"createdBy", "createdByName", "transactionNo");
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementRevision.class,
"operatorId", "operatorName");
assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary.class,
@@ -236,8 +236,8 @@ class FinanceSettlementBehaviorTest {
eq("SETTLEMENT_PAYMENT_CREATE"), eq("pay-key"))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(88L))).thenReturn(List.of(after));
when(jdbc.queryForList(startsWith("SELECT f.id fee_source_id"), eq(10L), eq(88L))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,transaction_type"), eq(10L), eq(88L))).thenReturn(List.of(Map.ofEntries(
Map.entry("id", 501L), Map.entry("transaction_type", "PAYMENT"),
when(jdbc.queryForList(startsWith("SELECT id,transaction_no,transaction_type"), eq(10L), eq(88L))).thenReturn(List.of(Map.ofEntries(
Map.entry("id", 501L), Map.entry("transaction_no", "PAY202610210001"), Map.entry("transaction_type", "PAYMENT"),
Map.entry("transaction_date", Date.valueOf(LocalDate.of(2026, 8, 11))),
Map.entry("amount", new BigDecimal("40.00")), Map.entry("method", "BANK_TRANSFER"),
Map.entry("reference_no", "BANK-1"), Map.entry("voucher_file_ids", "[\"91\"]"),
@@ -258,6 +258,7 @@ class FinanceSettlementBehaviorTest {
assertThat(result.transactions()).singleElement().extracting(transaction -> transaction.transactionType())
.isEqualTo("PAYMENT");
assertThat(result.transactions().get(0).createdByName()).isEqualTo("platform_admin");
assertThat(result.transactions().get(0).transactionNo()).isEqualTo("PAYMENT".equals(result.transactions().get(0).transactionType()) ? "PAY202610210001" : null);
verify(jdbc).update(startsWith("UPDATE fin_settlement SET paid_amount"),
any(), any(), any(), any(), any(), any(), any(), any());
}
@@ -522,6 +523,7 @@ class FinanceSettlementBehaviorTest {
.isEqualTo("RECEIPT");
assertThat(result.transactions().get(0).amount()).isEqualByComparingTo("40.00");
assertThat(result.transactions().get(0).createdByName()).isEqualTo("platform_admin");
assertThat(result.transactions().get(0).transactionNo()).isEqualTo("PAYMENT".equals(result.transactions().get(0).transactionType()) ? "PAY202610210001" : null);
}
@Test
@@ -623,8 +625,15 @@ class FinanceSettlementBehaviorTest {
org.mockito.Mockito.lenient().when(documentNumbers.next("AR")).thenReturn("AR202608180001");
org.mockito.Mockito.lenient().when(documentNumbers.next("AP")).thenReturn("AP202608180001");
org.mockito.Mockito.lenient().when(documentNumbers.next("PAY")).thenReturn("PAY202608180001");
// 既有业务测试保留JDBC替身,新详情路径通过真实Repository和Mapper接口适配执行。
var mapper = org.mockito.Mockito.mock(com.baiyue.finance.mapper.FinanceSettlementDetailMapper.class, invocation -> {
var select = invocation.getMethod().getAnnotation(org.apache.ibatis.annotations.Select.class);
if (select == null) return RETURNS_DEFAULTS.answer(invocation);
String sql = String.join(" ", select.value()).replaceAll("#\\{[a-zA-Z]+\\}", "?");
return jdbc.queryForList(sql, invocation.getArguments());
});
return new FinanceSettlementService(jdbc, new ObjectMapper().findAndRegisterModules(),
new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers);
new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers, new com.baiyue.finance.repository.FinanceSettlementDetailRepository(mapper));
}
private static FinanceSupplierValidator supplierValidator() {
@@ -657,7 +666,7 @@ class FinanceSettlementBehaviorTest {
when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(88L))).thenReturn(List.of(settlement));
long customerId = ((Number) settlement.get("tenant_id")).longValue();
when(jdbc.queryForList(startsWith("SELECT f.id fee_source_id"), eq(customerId), eq(88L))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,transaction_type"), eq(customerId), eq(88L))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT id,transaction_no,transaction_type"), eq(customerId), eq(88L))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT action,reason"), eq(customerId), eq(88L))).thenReturn(List.of());
}
@@ -0,0 +1,36 @@
package com.baiyue.finance;
import com.baiyue.finance.mapper.FinanceSettlementDetailMapper;
import com.baiyue.finance.repository.FinanceSettlementDetailRepository;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import static org.assertj.core.api.Assertions.assertThat;
class FinanceSettlementDetailMapperTest {
/** 真实执行付款查询,业务号必须来自本客户本结算的流水,空号不以其他字段替代。 */
@Test
void paymentNumbersRemainScopedToCustomerAndSettlement() {
var source = new DriverManagerDataSource("jdbc:h2:mem:settlement-number;MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "");
var jdbc = new JdbcTemplate(source);
jdbc.execute("CREATE TABLE fin_settlement_payment (id BIGINT PRIMARY KEY,tenant_id BIGINT,settlement_id BIGINT,transaction_no VARCHAR(32),transaction_type VARCHAR(32),transaction_date DATE,amount DECIMAL(18,2),method VARCHAR(32),reference_no VARCHAR(64),voucher_file_ids VARCHAR(255),created_by BIGINT,created_by_name VARCHAR(64),created_at TIMESTAMP,remark VARCHAR(255))");
jdbc.update("INSERT INTO fin_settlement_payment (id,tenant_id,settlement_id,transaction_no,transaction_type,reference_no) VALUES (1,10,20,'PAY202610210001','PAYMENT','BANK-1'),(2,11,20,'PAY202610210002','PAYMENT','BANK-2'),(3,10,21,'PAY202610210003','PAYMENT','BANK-3'),(4,10,22,NULL,'PAYMENT','BANK-4')");
var configuration = new Configuration(new Environment("test", new JdbcTransactionFactory(), source));
configuration.addMapper(FinanceSettlementDetailMapper.class);
try (var session = new SqlSessionFactoryBuilder().build(configuration).openSession()) {
var repository = new FinanceSettlementDetailRepository(session.getMapper(FinanceSettlementDetailMapper.class));
var rows = repository.payments(10, 20);
assertThat(rows).hasSize(1);
assertThat(rows.get(0).get("transaction_no")).isEqualTo("PAY202610210001");
assertThat(rows.get(0).get("reference_no")).isEqualTo("BANK-1");
assertThat(repository.payments(11, 21)).isEmpty();
assertThat(repository.payments(10, 22)).hasSize(1);
assertThat(repository.payments(10, 22).get(0).get("transaction_no")).isNull();
}
}
}
@@ -2,7 +2,12 @@ package com.baiyue.finance;
import com.baiyue.finance.service.FinanceSettlementService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import com.baiyue.finance.service.FinanceWalletService;
import com.baiyue.finance.service.FinancePhase1OperationService;
import com.baiyue.finance.service.FinanceService;
import com.baiyue.finance.service.ReconciliationStatementService;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.LinkedHashMap;
@@ -12,9 +17,12 @@ import static org.assertj.core.api.Assertions.assertThat;
class FinanceSettlementRequestHashTest {
/** 模拟不同 JVM 的 Map 遍历顺序;同一付款请求必须得到相同摘要。 */
@Test
void equivalentRequestsIgnoreMapIterationOrderButDetectChangedPayment() {
var service = new FinanceSettlementService(null, new ObjectMapper(), null, null, null);
@ParameterizedTest
@ValueSource(classes = {FinanceSettlementService.class, FinanceWalletService.class,
FinancePhase1OperationService.class, FinanceService.class, ReconciliationStatementService.class})
void equivalentRequestsIgnoreMapIterationOrderButDetectChangedPayment(Class<?> serviceType) {
Object service = org.mockito.Mockito.mock(serviceType, org.mockito.Mockito.CALLS_REAL_METHODS);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
Map<String, Object> body = new LinkedHashMap<>();
body.put("amount", 190); body.put("expectedVersion", 0);
Map<String, Object> first = new LinkedHashMap<>();
@@ -67,10 +67,11 @@ public class PlatformColoadGroupController {
idempotencyKey, user.userId(), user.username()));
}
/** 按入仓码返回同柜组业务编号及当前有效成员,保留平台只读权限边界。 */
@GetMapping("/by-inbound-code/{inboundCode}")
@PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')")
@Operation(summary = "按入仓码查询同柜组业务摘要",
description = "仅返回业务状态、有效成员数及去重后的全部成员入仓码;内部组ID和组号不作为页面识别键。")
description = "返回真实业务组号、业务状态、有效成员数及去重后的全部成员入仓码;不返回内部组ID。")
public ApiResponse<BusinessSummary> byInboundCode(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable @Size(min = 1, max = 40) String inboundCode) {
@@ -71,6 +71,7 @@ public final class ColoadGroupModels {
/** 页面只读业务摘要;内部组标识不作为用户识别键。 */
public record BusinessSummary(
String groupNo,
String status,
int memberCount,
List<BusinessMember> members
@@ -108,7 +108,7 @@ public interface ColoadGroupMapper {
@Param("inboundId") long inboundId);
@Select("""
SELECT DISTINCT group_row.id groupId,group_row.group_status groupStatus
SELECT DISTINCT group_row.id groupId,group_row.group_no groupNo,group_row.group_status groupStatus
FROM fl_inbound_order requested
JOIN fl_coload_group_member member
ON member.tenant_id=requested.tenant_id AND member.inbound_id=requested.id
@@ -0,0 +1,20 @@
package com.baiyue.firstleg.loading.coload.persistence;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 平台同柜组摘要的只读数据访问,保留原入仓片段和组状态范围。 */
@Repository
public class ColoadGroupQueryRepository {
private final ColoadGroupMapper mapper;
/** 注入同服务持久化入口。 */
public ColoadGroupQueryRepository(ColoadGroupMapper mapper) { this.mapper = mapper; }
/** 按业务入仓码查询当前组及其真实业务组号。 */
public List<ColoadPersistenceRows.InboundGroupLookupRow> groupsByInboundCode(String code) {
return mapper.groupsByInboundCode(code);
}
/** 读取被冻结的成员,交由服务核对缺失和重复片段。 */
public List<ColoadPersistenceRows.MemberCodeRow> effectiveMemberCodes(long groupId) {
return mapper.effectiveMemberCodes(groupId);
}
}
@@ -59,6 +59,7 @@ public final class ColoadPersistenceRows {
@Setter
public static class InboundGroupLookupRow {
private Long groupId;
private String groupNo;
private String groupStatus;
}
@@ -47,6 +47,8 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
@Resource
private ColoadGroupMapper mapper;
@Resource
private com.baiyue.firstleg.loading.coload.persistence.ColoadGroupQueryRepository queryRepository;
@Resource private PendingReviewOrderClient pendingReviewOrders;
@Resource private OrderCargoClient orderCargoClient;
@Resource private InboundOrderCreationService inboundOrderCreationService;
@@ -118,10 +120,11 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
return requiredSnapshot(groupId);
}
/** 以入仓码读取同柜组业务号及全部有效成员,不返回内部组ID。 */
@Transactional(readOnly = true)
public BusinessSummary queryByInboundCode(String inboundCode) {
String code = normalizedInboundCode(inboundCode);
var groups = mapper.groupsByInboundCode(code).stream()
var groups = queryRepository.groupsByInboundCode(code).stream()
.filter(row -> row.getGroupId() != null && CURRENT_GROUP_STATUSES.contains(row.getGroupStatus()))
.toList();
long distinctGroups = groups.stream().map(row -> row.getGroupId()).distinct().limit(2).count();
@@ -131,7 +134,7 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
if (distinctGroups > 1) throw conflict("入仓码关联多个有效同柜不可拆组");
long groupId = groups.get(0).getGroupId();
String status = groups.get(0).getGroupStatus();
var memberRows = mapper.effectiveMemberCodes(groupId);
var memberRows = queryRepository.effectiveMemberCodes(groupId);
if (memberRows.isEmpty()) throw conflict("同柜不可拆组不存在有效入仓片段");
if (memberRows.stream().anyMatch(row -> row.getMemberId() == null || row.getInboundId() == null
|| trim(row.getInboundCode()) == null)) {
@@ -142,7 +145,7 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort {
throw conflict("同柜不可拆组存在重复入仓片段业务号");
}
List<BusinessMember> members = inboundCodes.stream().sorted().map(BusinessMember::new).toList();
return new BusinessSummary(status, members.size(), members);
return new BusinessSummary(groups.get(0).getGroupNo(), status, members.size(), members);
}
private long requiredCurrentGroupId(String inboundCode) {
@@ -24,7 +24,7 @@ public class WorkflowTabService {
var facts=repository.facts(batch.stream().map(InboundOrderEntity::getId).toList());
// 确认页签复用已推送费用版本。
for(var fee:repository.feeConfirmationRows(facts.stream().map(WorkflowFacts.Fact::inboundId).toList())) {
if(facts.stream().noneMatch(f->f.tenantId()==fee.tenantId() && f.orderId()==fee.orderId())) throw unavailable();
if(facts.stream().noneMatch(f->f.tenantId()==fee.tenantId() && f.orderId()==fee.orderId())) throw unavailable("费用确认事实不属于本批入仓来源");
result.putIfAbsent(fee.orderId()+":"+fee.mainStatus(),fee);
}
var sources=facts.stream().filter(f->f.departed() || f.clearanceUploaded()).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList();
@@ -32,19 +32,24 @@ public class WorkflowTabService {
if(!sources.isEmpty()) {
try {
var response=lastleg.read(token,new WorkflowFacts.Request(sources));
if(response==null || !response.successful() || response.data()==null) throw unavailable();
if(response==null || !response.successful() || response.data()==null) throw unavailable(
"尾程事实响应无效 code="+(response==null?null:response.code())
+" requestId="+(response==null?null:response.requestId())+" traceId="+(response==null?null:response.traceId()));
var allowed=new HashSet<>(sources);
for(var fact:response.data()) {
if(fact==null || !allowed.contains(new WorkflowFacts.Source(fact.tenantId(),fact.inboundId()))) throw unavailable();
if(fact==null || !allowed.contains(new WorkflowFacts.Source(fact.tenantId(),fact.inboundId()))) throw unavailable("尾程事实包含空记录或本批以外的客户/入仓来源");
overseas.merge(fact.tenantId()+":"+fact.inboundId(),fact,(a,b)->new WorkflowFacts.Overseas(a.tenantId(),a.inboundId(),a.outboundExists()||b.outboundExists(),a.pendingOutbound()||b.pendingOutbound(),a.delivering()||b.delivering(),a.unloaded()&&b.unloaded()));
}
if(overseas.size()!=sources.size()) throw unavailable();
} catch(BizException e) { throw e; } catch(Exception e) { throw unavailable(); }
if(overseas.size()!=sources.size()) throw unavailable("尾程事实缺少来源 expected="+sources.size()+" actual="+overseas.size()
+" missing="+sources.stream().filter(s->!overseas.containsKey(s.tenantId()+":"+s.inboundId())).limit(20).toList());
} catch(BizException e) { throw e; } catch(Exception e) {
throw (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试").initCause(e);
}
}
// SQL排除仅作汇总的父票;保留承接实际装柜数量的来源票及独立余量票。
for(var fact:facts) {
var inbound=expected.get(fact.inboundId());
if(inbound==null || !Objects.equals(inbound.getTenantId(),fact.tenantId()) || !Objects.equals(inbound.getOrderId(),fact.orderId())) throw unavailable();
if(inbound==null || !Objects.equals(inbound.getTenantId(),fact.tenantId()) || !Objects.equals(inbound.getOrderId(),fact.orderId())) throw unavailable("头程事实与入仓单归属不一致 inboundId="+fact.inboundId());
var o=overseas.get(fact.tenantId()+":"+fact.inboundId());
for(String key:classify(fact,inbound.getProcessStatus(),o)) result.putIfAbsent(fact.orderId()+":"+key,new StatusRow(fact.tenantId(),fact.orderId(),key));
}
@@ -77,5 +82,9 @@ public class WorkflowTabService {
return keys;
}
/** 下游缺失或归属异常不能生成部分数量。 */
private static BizException unavailable() { return new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试"); }
private static BizException unavailable(String reason) {
// 诊断信息只进入服务日志,公开响应保持稳定,不暴露内部来源标识。
return (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试")
.initCause(new IllegalStateException(reason));
}
}
@@ -45,8 +45,8 @@ paths:
summary: 按入仓码查询同柜组业务摘要
description: >-
需要 platform:firstleg:smart-loading:list。使用任一有效成员的全局唯一 inboundCode 查询;
仅返回组业务状态、有效成员数和全部唯一成员入仓码。任一冻结成员缺少有效 inboundCode 时失败关闭;
页面不持有或展示 groupId、groupNo、orderId,也不得从技术ID或订单号推导入仓码。
返回真实业务组号 groupNo、组业务状态、有效成员数和全部唯一成员入仓码。任一冻结成员缺少有效 inboundCode 时失败关闭;
页面不持有或展示 groupId、orderId,也不得从技术ID或订单号推导入仓码。
parameters:
- name: inboundCode
in: path
@@ -147,8 +147,9 @@ components:
ColoadGroupBusinessSummary:
type: object
additionalProperties: false
required: [status, memberCount, members]
required: [groupNo, status, memberCount, members]
properties:
groupNo: {type: string, pattern: "^CLG[0-9]{12}$", description: 真实同柜业务组号;与创建及重放结果一致}
status: {type: string, enum: [ACTIVE, LOCKED, CONSUMED]}
memberCount: {type: integer, minimum: 1}
members:
@@ -55,7 +55,7 @@ class ColoadGroupOpenApiContractTest {
.doesNotContain("destinationCode")
.doesNotContain("expectedVersion")
.doesNotContain("platform:firstleg:coload");
assertThat(summaryProperties).containsOnlyKeys("status", "memberCount", "members");
assertThat(summaryProperties).containsOnlyKeys("groupNo", "status", "memberCount", "members");
assertThat(memberProperties).containsOnlyKeys("inboundCode");
}
}
@@ -37,7 +37,7 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig({ColoadGroupService.class, ColoadGroupServiceTest.Numbers.class})
@SpringJUnitConfig({ColoadGroupService.class, ColoadGroupServiceTest.Numbers.class, com.baiyue.firstleg.loading.coload.persistence.ColoadGroupQueryRepository.class})
class ColoadGroupServiceTest {
@org.springframework.context.annotation.Configuration
static class Numbers {
@@ -71,6 +71,7 @@ class ColoadGroupServiceTest {
var result = service.queryByInboundCode(" IN-A ");
assertThat(result.groupNo()).isEqualTo("CLG202610210001");
assertThat(result.status()).isEqualTo("ACTIVE");
assertThat(result.memberCount()).isEqualTo(2);
assertThat(result.members()).extracting("inboundCode").containsExactly("IN-A", "IN-B");
@@ -574,6 +575,7 @@ class ColoadGroupServiceTest {
private static InboundGroupLookupRow lookup(long groupId, String status) {
InboundGroupLookupRow value = new InboundGroupLookupRow();
value.setGroupId(groupId);
value.setGroupNo("CLG202610210001");
value.setGroupStatus(status);
return value;
}
@@ -86,7 +86,8 @@ class ColoadGroupSqlContractTest {
.contains("requested.inbound_code=#{inboundCode}")
.contains("requested.deleted=0")
.contains("'ACTIVE','LOCKED','CONSUMED'")
.doesNotContain("group_no", "order_id=#{");
.contains("group_row.group_no groupNo")
.doesNotContain("order_id=#{");
assertThat(memberSql)
.contains("member.id memberId", "member.inbound_id inboundId", "inbound.inbound_code inboundCode")
.contains("LEFT JOIN fl_inbound_order inbound")
@@ -25,12 +25,15 @@ class WorkflowBoundaryTest {
inbound.setOrderId(101L);inbound.setProcessStatus("LOADED");
when(repository.facts(List.of(1L))).thenReturn(List.of(fact()));
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of()));
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class)
.hasMessage("订单业务页签事实暂不可用,请稍后重试")
.hasRootCauseMessage("尾程事实缺少来源 expected=1 actual=0 missing=[Source[tenantId=11, inboundId=1]]");
when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of(
new WorkflowFacts.Overseas(22,1,true,true,false,true))));
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
when(downstream.read(any(),any())).thenThrow(new IllegalStateException("unavailable"));
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class);
var failure=new IllegalStateException("unavailable");
when(downstream.read(any(),any())).thenThrow(failure);
assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class).hasCause(failure);
}
/** 未记录离港时,报关完成即可待清关;上传清关资料后也必须查询精确海外来源。 */
+3 -3
View File
@@ -17,7 +17,7 @@
拼车余位发布查 `fleet/trip/FleetCapacityOfferService.java`;整车建单与准备查 `dispatch/controller/DispatchAggregateController.java` 和 `dispatch/service/DispatchAggregateService.java`,本土业务接线查 `fleet/business/`;既有外接登记衔接合同为 `fleet-preparation-v1.yaml`,排车流程按 `../../docs/海外仓业务/排车列表统一作业改造契约.md`。
原排车装车后运营视图分别查 `fleet/ownedbusiness/`、`fleet/outsourcedbusiness/` 与各自 `fleet-*-business-v1.yaml`;自有/外配按同事务捕获的车辆归属,不按货源区分。两入口可信作用域及权限独立,外配仅整车承运金额、逐目的仓到达和五装车照片;复用四张附加表,不修改原履约/库存/财务状态。共同业务规则见 `../../docs/海外仓业务/自有业务首版开发契约.md`、`../../docs/海外仓业务/外配业务首版开发契约.md`,实际发布及HTTP只查部署主契约。
原排车装车后运营视图分别查 `fleet/ownedbusiness/`、`fleet/outsourcedbusiness/` 与各自 `fleet-*-business-v1.yaml`;自有业务按无实际拼车货物、外配任务按已实际接入需求池货物分类;车辆归属仍读取装车快照,完整判定唯一见外配业务契约。两入口可信作用域及权限独立,外配仅整车承运金额、逐目的仓到达和五装车照片;复用四张附加表,不修改原履约/库存/财务状态。共同业务规则见 `../../docs/海外仓业务/自有业务首版开发契约.md`、`../../docs/海外仓业务/外配业务首版开发契约.md`,实际发布及HTTP只查部署主契约。
事件重试:`controller/EventOperationsController.java`,内部 `/internal/lastleg/event-operations/{entryId}` 查询、`/retry` 写入;仅卸柜、派送、暂扣、资金及费用事件,以 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务;脚本 `scripts/event-outbox-operations.mjs`。
@@ -57,7 +57,7 @@
| PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java` |
| 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java` |
| 平台排车查询、整车四块详情、审核与实载 | `dispatch/controller/DispatchQueryController.java`、`DispatchAggregateController.java`;公开契约`dispatch/port/`,业务规则见`../../docs/海外仓业务/排车列表统一作业改造契约.md` |
| 草稿、双端装发与派送安排 | `dispatch/controller/SmartDispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`;平台维护`DispatchController.java`,派送组合`delivery/controller/DeliveryTransferController.java` |
| 草稿、双端装车发运 | `dispatch/controller/SmartDispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`;平台维护`DispatchController.java`;自动发运编排`dispatch/service/DispatchLoadingDepartureService.java` |
| 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java` |
| 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java` |
| 一件代发接收、SKU/箱唛库存 | `dropship/controller/DropshipOrderController.java` |
@@ -77,7 +77,7 @@
| 整车调度与理货审核 | `dispatch/controller/DispatchAggregateController.java` → `dispatch/service/DispatchAggregateService.java` 的提交及两级审核;同一排车先调度审核、再理货审核,通过后才允许真实扫码。资源、货物或路线关键变更使原依据失效;零扫码显式退回后重新审核,不复用旧客户子单审核状态 |
| 外接询价、确认占位及取消 | `fleet/trip/PlatformFleetBookingController.java`、`FleetBookingService.java`、`FleetBookingReservations.java`;客户从`fleet/customer/`读取IAM有效主档与资格,询价不占位,确认后与自有货计划共同校验逐段容量。约定金额不代表应收/实收,`FleetBookingIncomingGateway`由`transfer/fleet/FleetIncomingAdapter`实现来货登记/实收确认,排车关联通过`FleetDispatchGateway.sourceCargo`核对实收来源 |
| 拣货前计划容量与逐段余位 | `FleetTripExecutionService.capacityPlan/confirmCapacityPlan`、`/fleet/trips/{tripNo}/capacity-plan`;保存自有货的计划值,不检查拣货完成、不推进审核或扣库存;`planValid`校验当前货物hash,不把计划值当实测值,资源/计划/关联变更后须重新核对 |
| 实物装载、逐段容量与双端整车发车 | `dispatch/pda/controller/DispatchPdaController.java` → `dispatch/service/DispatchAggregateService.java`(装车)、`DispatchDepartureService.java`(发车);平台与PDA均使用本端`dispatches/{dispatchNo}/loading-complete`及`/depart`,公共DTO在`dispatch/port/`,已有单写入唯一`recordVersion`。装车前须真实PDA拣货核平,发车前须全车派送安排完整;装车及发车不重复执行扫码已完成的库存SHIP,Fleet容量和资源资格继续由真实提供者复核 |
| 实物装载、逐段容量与双端整车发车 | 两端`dispatches/{dispatchNo}/loading-complete`统一调用`dispatch/service/DispatchLoadingDepartureService.java`,同一事务复用实载确认、派送单准备及发车,成功直接`DELIVERING`;请求携带唯一`recordVersion`,原键恢复最终结果。公开手工安排派送、`/depart`入口已移除;内部`AFTER_LOADING`和步骤命令只用于事务编排。真实PDA拣货须核平,不重复库存SHIP,Fleet容量和资源资格仍复核;未知费用不补零、不阻塞发运,真实已明确外接报价沿原费用来源防重 |
| 车次货物结案、车辆和司机分别释放 | `fleet/trip/FleetTripCompletionService.java`、`FleetCompletionModels.java`;平台 `/fleet/trips/{tripNo}/completion`、`/complete`、`/resources/release`,独立complete/release权限。结案核对所有派送终态与实发数量;实际任务结束时间/地点及车辆结束状态单独记录,不以结案或收款自动释放资源 |
| 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java` |
| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql` |
+4 -4
View File
@@ -85,8 +85,8 @@
- 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。
- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。
- 自有业务运营:`fleet/ownedbusiness/`、`fleet-owned-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/自有业务首版开发契约.md`;原排车装车后三阶段按装车时自有车辆归属展示,仅补金额、逐目的地到达、里程、油耗与照片。原详情只经`dispatch.port.DispatchDetailReader`读取,原装车事务经`OwnedBusinessOwnershipCapture`捕获归属,不另建履约流程。
- 外配业务运营:`fleet/outsourcedbusiness/`、`fleet-outsourced-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/外配业务首版开发契约.md`;原排车装车后三阶段按冻结DISPATCHED归属展示,包括正式MASTER/EXTERNAL两种资源模式。只登记整车承运金额、逐目的仓到达和五装车照片,无里程/油耗或成本拆分。内部可信作用域复用四表,入口权限与自有严格分开,不引入第二履约模型。
- 自有业务运营:`fleet/ownedbusiness/`、`fleet-owned-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/自有业务首版开发契约.md`;原排车装车后三阶段按无实际拼车货物展示,车辆归属独立,仅补金额、逐目的地到达、里程、油耗与照片。原详情只经`dispatch.port.DispatchDetailReader`读取,原装车事务经`OwnedBusinessOwnershipCapture`捕获归属,不另建履约流程。
- 外配业务运营:`fleet/outsourcedbusiness/`、`fleet-outsourced-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/外配业务首版开发契约.md`;原排车装车后三阶段按实际接入需求池拼车货物展示,车辆归属独立,包含MASTER/EXTERNAL两种资源模式。只登记整车承运金额、逐目的仓到达和五装车照片,无里程/油耗或成本拆分。内部可信作用域复用四表,入口权限与自有严格分开,不引入第二履约模型。
- 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。
- 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。
@@ -95,9 +95,9 @@
- 海外仓全流程连续覆盖尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、转运、废弃、采购、本土卡车、本土散板、排车查询/智能预排、排车编辑/取消/发车、PDA 拣货与出库、一件代发、转派送、客户派送单、异常退回和客户回访。
- 服务端正式入口包括 `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`delivery/controller/OutboundPdaController.java`、`delivery/controller/MerchantDeliveryController.java`、`exceptionorder/` 与 `customervisit/`;设备页面是否可用以海外 PDA 正式路由和能力开关为准,未由 PDA 承载的操作由 Admin/Client 完成。
- 平台一件代发出库候选由 `/outbound-orders/one-click-dropship/candidates` 统一返回已完成入库且仍有可用箱唛、SKU 汇总和库存仓位绑定的托盘明细;查询不强制先传 `customerId`,未传时跨客户展示供页面分页筛选,创建时必须从所选候选解析为唯一客户和唯一海外仓,禁止一张出库单跨客户或跨仓。创建后出库单按 `WAIT_PICK → PICKING → PICKED → OUTBOUNDED` 推进;海外 PDA 扫描托盘码时通过整托命令原子拣取该托盘在当前任务内全部剩余箱件和 SKU,任一库存明细失败须整托回滚,逐箱拣货入口继续保留。
- 排车主流程以工作区 `docs/海外仓业务/排车列表统一作业改造契约.md` 为正式入口:一张排车单代表一趟车,装车完成后、实际发车前由后台安排派送,按客户与目的仓拆分派送单。`DispatchPdaWorkflowService` 按公开 `status`、`stage`、`recordVersion` 和 `actions` 返回作业事实与资格,Admin/PDA 共用服务端规则;BOL 与签收资料归拆分后的派送单,费用按其来源去重,不以旧转派送阶段或前端推算代替资格校验。
- 排车主流程以工作区 `docs/海外仓业务/排车列表统一作业改造契约.md` 为正式入口:一张排车单代表一趟车;Admin/PDA确认`loading-complete`时,同一事务自动按客户与目的仓生成派送单并发车,成功直接进入`DELIVERING`。公开手工安排派送及`/depart`入口已删除;内部`AFTER_LOADING`仅是同事务中间态。`DispatchPdaWorkflowService` 按公开 `status`、`stage`、`recordVersion` 和 `actions` 返回作业事实与资格,Admin/PDA 共用服务端规则;BOL 与签收资料归拆分后的派送单,费用按其来源去重,不以旧转派送阶段或前端推算代替资格校验。
- 排车主单允许同一起运仓的跨客户货物同车,每条来源保留客户、库存与费用归属;不再要求用户先建独立车次再关联多张客户排车。Fleet 的资源、司机证照、容量、历史与附加能力继续沿 `fleet/trip/` 定位;计划或资源变化对审核、实载确认的影响按正式契约及服务端结果核对,已拣货事实不能因改派或取消被直接抹除,实际发车后不得伪造资源释放。
- 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积必须来自实际确认,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车、安排派送和发车不重复执行已完成的库存扣减。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。
- 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积必须来自实际确认,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车发运编排不重复执行已完成的库存扣减;费用未明确时保持未计费,不要求理货员补费用,也不填零,真实已明确外接报价仍按原来源防重发布。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。
- 车次结案只消费派送事实:实签须signedAt/signedPackages,实退须原来源入账流水/RETURN_COMPLETED;实签加本趟实退等于原实发且余量为零。拒收、待退回、取消不算完成,时间取最后实际终态。结案不改客户状态/费用、不释放占用;车/司机分别记录实际结束时间地点,车辆状态与释放同事务。过期证照不阻止旧任务结束、不恢复新任务资格;原键重放不影响后续车次。
- 已签收后另发起退货保留原运输的签收事实,后续退货不重开原车次、不被车次结案改写;未签收货物仍按实际退回完成核对。`DeliveryDocumentService.save` 首次保存签收资料触发完成时要求关联出库已OUTBOUNDED,未出库可以准备BOL/装车资料但不能提前完成派送。
- 排车执行与 `delivery/` Controller 一同注册;平台排车查询和智能预排入口分别沿 `lastleg.dispatch-query-api.enabled`、`lastleg.smart-dispatch-api.enabled` 配置定位,启用现状以配置和实际路由核对。平台查询按全局排车号及来源归属加载跨客户同车明细,客户接口保持本客户隔离;预排创建逐项校验同一起运仓及各来源客户,不能强制整张排车只含一个客户。草稿托盘锁仍按操作人与草稿标识持有,释放、失效及转正式锁由服务端原子处理。
@@ -37,25 +37,6 @@ public class DeliveryTransferController {
return ApiResponse.success(dispatchDelivery.preview(actor(user),dispatchNo).groups());
}
/** 一次快照提供全部客户目的地、费用证据及既有安排的完整数组。 */
@GetMapping("/dispatches/{dispatchNo}/delivery-arrangement")
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')")
public ApiResponse<ArrangementPreview> arrangement(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
requirePlatform(user);
return ApiResponse.success(dispatchDelivery.preview(actor(user),dispatchNo));
}
/** 平台一次安排本车全部客户,身份只从认证上下文获取。 */
@PostMapping("/dispatches/{dispatchNo}/transfer-delivery")
@PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_TRANSFER_DELIVERY + "')")
@Operation(summary = "按真实客户生成出库单并按客户目的地生成派送单")
public ApiResponse<CommandResult> transfer(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo,
@Valid @RequestBody ArrangeDelivery request) {
requirePlatform(user);
return ApiResponse.success(dispatchDelivery.arrange(actor(user),dispatchNo,request));
}
@PostMapping("/outbound-orders/one-click-dropship")
@PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')")
@Operation(summary = "Create one-click dropship outbound and push a PDA picking task")
@@ -126,6 +126,51 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
var input=inputs.get(group.key());var coverage=fees.coverage(group,input,actor.operatorId(),external.get(group.customerId()));
fees.validate(group,input,coverage,external.get(group.customerId()));coverages.put(group.key(),coverage);
}
var fact=createDocuments(context,actor,request.idempotencyKey(),businessHash,groups,inputs,external,coverages);
return execution.recordArrangement(context,actor,identity,fact,false);
}
/** 装车父事务内自动生成全车单据;未知派送资料和未确认费用保持未登记。 */
@Override
@Transactional(propagation=Propagation.MANDATORY)
public ArrangementFact prepareForDeparture(Context context,Actor actor,String commandKey) {
if(actor==null || blank(actor.operatorId()) || blank(commandKey) || commandKey.length()>255)
throw conflict("缺少装车操作身份或原请求标识");
if(context==null || context.status()!=Status.ACTIVE || context.stage()!=Stage.AFTER_LOADING
|| !context.pickingReconciled() || context.loadingCompletedAt()==null || blank(context.loadingCompletionHash())
|| blank(context.cargoHash()) || context.cargo()==null || context.cargo().isEmpty())
throw conflict("请先完成真实拣货核平及装车确认");
if(actor.mode()==ActorMode.PDA && !Objects.equals(actor.boundWarehouseId(),context.warehouseId()))
throw conflict("排车不属于当前绑定仓");
var saved=repository.arrangement(context.dispatchId(),true);
if(saved!=null) {
if(!Objects.equals(saved.getOriginalCommandKey(),commandKey)
|| !Objects.equals(saved.getCargoHash(),context.cargoHash())
|| !Objects.equals(saved.getLoadingCompletionHash(),context.loadingCompletionHash()))
throw conflict("本车已有不同装车命令生成的派送事实");
var facts=departureFacts(context,true);
if(!facts.depart().allowed())throw conflict(facts.depart().blockReason());
return fact(saved);
}
// 不能把缺少安排事实的孤立单据补建成另一套;全部客户在同一事务中创建。
if(!repository.outbounds(context.dispatchId(),true).isEmpty() || !repository.deliveries(context.dispatchId(),true).isEmpty())
throw conflict("已存在关联单据但缺少完整安排事实,请核对");
var groups=grouping.groups(context);var external=fees.external(context);
var inputs=new HashMap<String,ArrangeGroup>();
for(var group:groups) {
String carrier=context.resources()==null || context.resources().vehicle()==null?null:context.resources().vehicle().carrierName();
inputs.put(group.key(),new ArrangeGroup(group.key(),group.cargoHash(),null,null,null,carrier,null,null,
null,null,null,null,null,external.get(group.customerId()).snapshotHash(),null,List.of(),List.of()));
}
String businessHash=grouping.hash(Arrays.asList(context.dispatchNo(),context.cargoHash(),context.loadingCompletionHash(),
groups.stream().map(g->inputs.get(g.key())).toList()));
return createDocuments(context,actor,commandKey,businessHash,groups,inputs,external,Map.of());
}
/** 自动装车与内部安排复用同一建单、来源锁及真实外接报价发布路径,不推定原费覆盖。 */
private ArrangementFact createDocuments(Context context,Actor actor,String commandKey,String businessHash,List<Group> groups,
Map<String,ArrangeGroup> inputs,Map<String,com.baiyue.lastleg.fleet.trip.FleetBookingFreight.Preview> external,
Map<String,DeliveryFeeCoverageModels.Evidence> coverages) {
var now=Instant.now();String key="DLA"+IdWorker.getId();var outbounds=new LinkedHashMap<String,OutboundOrder>();
for (var customer:groups.stream().map(Group::customerId).distinct().sorted().toList()) {
var cargo=context.cargo().stream().filter(c->customer.equals(c.customerId())).toList();
@@ -169,9 +214,9 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
var arrangement=new DispatchDeliveryArrangement();arrangement.setId(IdWorker.getId());arrangement.setDispatchOrderId(context.dispatchId());
arrangement.setDispatchNo(context.dispatchNo());arrangement.setArrangementKey(key);arrangement.setBusinessHash(businessHash);
arrangement.setCargoHash(context.cargoHash());arrangement.setLoadingCompletionHash(context.loadingCompletionHash());
arrangement.setOriginalCommandKey(request.idempotencyKey());arrangement.setOriginalOperatorId(actor.operatorId());
arrangement.setOriginalCommandKey(commandKey);arrangement.setOriginalOperatorId(actor.operatorId());
arrangement.setResultJson(write(result));arrangement.setCargoJson(write(context.cargo()));arrangement.setCreatedAt(local(now));repository.create(arrangement);
return execution.recordArrangement(context,actor,identity,fact(arrangement),false);
return fact(arrangement);
}
/** 发车先取得全部出库锁,再取得全部派送锁,当前读取不申请来源锁。 */
@@ -281,7 +326,7 @@ public class DispatchDeliveryService implements DispatchDeliveryPort {
var destination=group.destination();d.setDeliveryGroupKey(grouping.documentGroupKey(destination));d.setDestinationType(destination.type());
d.setDestinationWarehouseId(destination.warehouseId());d.setDestinationWarehouseCode(destination.warehouseCode());d.setDestinationWarehouseName(destination.warehouseName());
d.setDeliveryAddress(destination.address());d.setPostalCode(destination.postalCode());d.setContactName(destination.contactName());d.setContactPhone(destination.contactPhone());
d.setStatus("WAIT_DELIVERY");d.setVersion(0L);d.setDeliveryMethod(input.deliveryMethod());d.setPlannedDeliveryAt(local(input.plannedDeliveryAt()));
d.setStatus("WAIT_DELIVERY");d.setVersion(0L);d.setDeliveryMethod(input.deliveryMethod());d.setPlannedDeliveryAt(input.plannedDeliveryAt()==null?null:local(input.plannedDeliveryAt()));
d.setWaybillNo(input.waybillNo());d.setCarrierName(input.carrierName());d.setPickupMethod(input.pickupMethod());d.setLoadingType(input.loadingType());
d.setVehicleType(input.vehicleType());d.setVehicleSize(input.vehicleSize());d.setAppointmentAccount(input.appointmentAccount());d.setSellerName(input.sellerName());d.setRemark(input.remark());
if(context.resources()!=null){if(context.resources().vehicle()!=null)d.setVehiclePlate(context.resources().vehicle().plate());
@@ -14,7 +14,7 @@ import org.springframework.web.bind.annotation.*;
@RequestMapping("/api/platform/v1/last-leg/dispatches")
public class DispatchAggregateController {
private final DispatchAggregateService service;
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.service.DispatchDepartureService departure;
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.service.DispatchLoadingDepartureService loadingDeparture;
/** 注入本域应用服务。 */
public DispatchAggregateController(DispatchAggregateService service) { this.service=service; }
@@ -71,21 +71,15 @@ public class DispatchAggregateController {
public ApiResponse<com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.LoadingPreview> loading(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
return ApiResponse.success(service.loading(DispatchActorFactory.platform(user),dispatchNo));
}
/** 显式确认装车,不复用最后扫码或实际发车操作。 */
/** 显式确认装车后自动准备派送并发车,原请求恢复最终派送中结果。 */
@PostMapping("/{dispatchNo}/loading-complete")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:loading-complete')")
public ApiResponse<CommandResult> loadingComplete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.LoadingComplete request) {
return ApiResponse.success(service.loadingComplete(DispatchActorFactory.platform(user),dispatchNo,request));
return ApiResponse.success(loadingDeparture.complete(DispatchActorFactory.platform(user),dispatchNo,request));
}
/** 与PDA共用实际发车服务和公共版本,未完整安排派送不能发车。 */
@PostMapping("/{dispatchNo}/depart")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:depart')")
public ApiResponse<CommandResult> depart(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.Depart request) {
return ApiResponse.success(departure.depart(DispatchActorFactory.platform(user),dispatchNo,request));
}
/** 本车证据先上传,主单引用随后走版本化编辑。 */
/** 本车证据先上传;维护和当前待审权限分别由服务端核验,引用随对应命令保存。 */
@PostMapping(value="/{dispatchNo}/evidence/{kind}",consumes=org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
@PreAuthorize("hasAnyAuthority('platform:overseas-warehouse:dispatch:edit','platform:fleet:trip:dispatch-review','platform:fleet:trip:loading-review')")
public ApiResponse<com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File> uploadEvidence(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,
@PathVariable com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,@RequestPart("file")org.springframework.web.multipart.MultipartFile file) {
return ApiResponse.success(service.uploadEvidence(DispatchActorFactory.platform(user),dispatchNo,kind,file));
@@ -2,9 +2,9 @@ package com.baiyue.lastleg.dispatch.gateway;
import org.springframework.web.multipart.MultipartFile;
/** 临时车辆和司机证据属于本张整车,不能借司机主档或客户文件授权。 */
/** 车辆、司机及审核补充证据属于本张整车,不能借司机主档或客户文件授权。 */
public interface DispatchEvidenceGateway {
enum Kind { VEHICLE, DRIVER }
enum Kind { VEHICLE, DRIVER, DRIVER_PHOTO }
record File(Long fileId,String fileName,String contentType,long sizeBytes) { }
/** 上传只写本车、本类证据。 */
File upload(long dispatchId,Kind kind,String operator,MultipartFile file);
@@ -29,7 +29,7 @@ public class HttpDispatchEvidenceGateway implements DispatchEvidenceGateway {
private static String type(Kind kind) { return "OVERSEAS_DISPATCH_"+Objects.requireNonNull(kind).name()+"_EVIDENCE"; }
/** 本车证据上传完成后仍以File返回元数据核对复合归属。 */
@Override public File upload(long dispatch,Kind kind,String operator,MultipartFile file) {
if(file==null || file.isEmpty() || file.getSize()>MAX || !TYPES.contains(Objects.toString(file.getContentType(),"")))throw invalid();
if(file==null || file.isEmpty() || file.getSize()>MAX || !supportedType(kind,file.getContentType()))throw invalid();
var body=new MultipartBodyBuilder();
try { body.part("file",new ByteArrayResource(file.getBytes()) {
/** 保留用户文件名供审阅,不把路径当业务事实。 */
@@ -80,9 +80,13 @@ public class HttpDispatchEvidenceGateway implements DispatchEvidenceGateway {
var value=envelope.data();
if(!Objects.equals(value.tenantId(),0L) || !type(kind).equals(value.businessType()) || !Long.toString(dispatch).equals(value.businessId())
|| value.fileId()==null || value.fileId()<=0 || id!=null&&!id.equals(value.fileId()) || value.fileName()==null || value.fileName().isBlank()
|| !TYPES.contains(Objects.toString(value.contentType(),"")) || value.sizeBytes()==null || value.sizeBytes()<=0 || value.sizeBytes()>MAX)throw invalid();
|| !supportedType(kind,value.contentType()) || value.sizeBytes()==null || value.sizeBytes()<=0 || value.sizeBytes()>MAX)throw invalid();
return new File(value.fileId(),value.fileName(),value.contentType(),value.sizeBytes());
}
/** 司机本人照片只接受图片;车辆和驾驶证沿现有PDF/图片证据规则。 */
private static boolean supportedType(Kind kind,String contentType) {
return TYPES.contains(Objects.toString(contentType,"")) && (kind!=Kind.DRIVER_PHOTO || !"application/pdf".equals(contentType));
}
/** 无效证据不能作为已核实资料。 */
private static ResponseStatusException invalid(){return new ResponseStatusException(HttpStatus.BAD_REQUEST,"证据须为本车有效PDF、JPG或PNG,最大10MiB");}
/** File明确拒绝归属或文件格式属于业务错误,不能伪装成服务不可用。 */
@@ -26,6 +26,9 @@ public interface DispatchAggregateMapper {
String CURRENT_FILTER = """
<if test='q.status != "ALL"'> AND d.status=#{q.status}</if>
<if test='q.stage != null'> AND d.stage=#{q.stage}</if>
<if test='q.section == "VEHICLE_INFO"'> AND d.stage='BEFORE_LOADING' AND d.review_state IN ('DRAFT','DISPATCH_REJECTED','LOADING_REJECTED')</if>
<if test='q.section == "DISPATCH_REVIEW"'> AND d.stage='BEFORE_LOADING' AND d.review_state='DISPATCH_PENDING'</if>
<if test='q.section == "LOADING_REVIEW"'> AND d.stage='BEFORE_LOADING' AND d.review_state='LOADING_PENDING'</if>
<if test='q.reviewStatus != null and q.section == "DISPATCH_REVIEW"'> AND (
""" + REVIEW_DISPATCH + ")=#{q.reviewStatus}</if>"
+ "<if test='q.reviewStatus != null and q.section == \"LOADING_REVIEW\"'> AND (" + REVIEW_LOADING + ")=#{q.reviewStatus}</if>";
@@ -76,14 +79,15 @@ public interface DispatchAggregateMapper {
@Update("""
UPDATE ll_dispatch_order SET cargo_revision=cargo_revision+1,cargo_hash=#{hash},item_count=#{items},
pallet_count=#{pallets},package_count=#{packages},route=#{route},review_state='DRAFT',
review_basis_hash=NULL,dispatch_review_json=#{pending},loading_review_json=#{pending},
review_basis_hash=NULL,dispatch_review_json=#{dispatchReview},loading_review_json=#{loadingReview},
record_version=record_version+1,updated_at=#{at}
WHERE id=#{id} AND record_version=#{version} AND status='ACTIVE' AND stage='BEFORE_LOADING'
AND picked_package_count=0 AND shipped_package_count=0
""")
int changeCargo(@Param("id") long id, @Param("version") long version, @Param("hash") String hash,
@Param("items") int items, @Param("pallets") int pallets, @Param("packages") int packages,
@Param("route") String route, @Param("pending") String pending, @Param("at") java.time.LocalDateTime at);
@Param("route") String route, @Param("dispatchReview") String dispatchReview, @Param("loadingReview") String loadingReview,
@Param("at") java.time.LocalDateTime at);
/** 父锁内按公共版本保存状态及审核依据,显式清空旧依据而不依赖实体非空更新策略。 */
@Update("""
@@ -20,7 +20,7 @@ public class DispatchPdaController {
@Resource private DispatchPdaQueryService queries;
@Resource private DispatchPickingService picking;
@Resource private DispatchAggregateService workflow;
@Resource private DispatchDepartureService departure;
@Resource private DispatchLoadingDepartureService loadingDeparture;
/** 批量读取当前绑定仓的整车作业。 */
@GetMapping
@@ -36,6 +36,13 @@ public class DispatchPdaController {
return ApiResponse.success(queries.task(DispatchActorFactory.pda(user),dispatchNo));
}
/** 理货审核与后台共用同一轮次、版本和幂等账本,驳回须填写原因。 */
@PostMapping("/{dispatchNo}/loading-review")
public ApiResponse<DispatchContracts.CommandResult> loadingReview(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo,@Valid @RequestBody DispatchCommands.ReviewCommand request) {
return ApiResponse.success(workflow.review(DispatchActorFactory.pda(user),dispatchNo,true,request));
}
/** 共用公共版本和全局请求键执行一次真实库存扫码。 */
@PostMapping("/{dispatchNo}/items/{itemId}/pick")
public ApiResponse<DispatchContracts.CommandResult> pick(@AuthenticationPrincipal AuthenticatedUser user,
@@ -50,18 +57,10 @@ public class DispatchPdaController {
return ApiResponse.success(workflow.loading(DispatchActorFactory.pda(user),dispatchNo));
}
/** 最后一次扫码不代替显式装车确认,双方使用同一正式命令。 */
/** 真实扫码核平后确认装车即发车,两端使用同一事务命令。 */
@PostMapping("/{dispatchNo}/loading-complete")
public ApiResponse<DispatchContracts.CommandResult> loadingComplete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.LoadingComplete request) {
return ApiResponse.success(workflow.loadingComplete(DispatchActorFactory.pda(user),dispatchNo,request));
}
/** 与平台共用实际发车服务,不执行第二次库存扣减。 */
@PostMapping("/{dispatchNo}/depart")
public ApiResponse<DispatchContracts.CommandResult> depart(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String dispatchNo,
@Valid @RequestBody DispatchCommands.Depart request) {
return ApiResponse.success(departure.depart(DispatchActorFactory.pda(user),dispatchNo,request));
return ApiResponse.success(loadingDeparture.complete(DispatchActorFactory.pda(user),dispatchNo,request));
}
/** 原动作权限与绑定仓同时核对未知结果,不按单客户任务恢复。 */
@@ -14,7 +14,7 @@ public interface DispatchWholePdaReadMapper {
/** 精确单号或有界阶段列表均先在SQL限制绑定仓。 */
@Select("<script>SELECT * FROM ll_dispatch_order WHERE overseas_warehouse_id=#{warehouse} AND status='ACTIVE' "
+"<choose><when test='number != null'>AND dispatch_no=#{number} </when><when test='stage != null'>AND stage=#{stage} </when>"
+"<otherwise>AND stage IN('LOADING','AFTER_LOADING','DELIVERING') </otherwise></choose>ORDER BY updated_at DESC,id DESC LIMIT 201</script>")
+"<otherwise>AND (stage IN('LOADING','AFTER_LOADING','DELIVERING') OR (stage='BEFORE_LOADING' AND review_state='LOADING_PENDING')) </otherwise></choose>ORDER BY updated_at DESC,id DESC LIMIT 201</script>")
List<DispatchAggregate> headers(@Param("warehouse")String warehouse,@Param("number")String number,@Param("stage")String stage);
String ROOTS="<foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>";
/** 一次读取选中整车所有真实客户货物。 */
@@ -24,9 +24,6 @@ public final class DispatchApi {
public static final String LOADING_REVIEW = "/{dispatchNo}/loading-review";
public static final String RETURN_TO_REVIEW = "/{dispatchNo}/return-to-review";
public static final String LOADING_COMPLETE = "/{dispatchNo}/loading-complete";
public static final String DEPART = "/{dispatchNo}/depart";
public static final String DELIVERY_ARRANGEMENT = "/{dispatchNo}/delivery-arrangement";
public static final String TRANSFER_DELIVERY = "/{dispatchNo}/transfer-delivery";
/** 端点容器不允许实例化。 */
private DispatchApi() { }
@@ -49,9 +49,9 @@ public final class DispatchCommands {
}
public record ReviewCommand(Long recordVersion, String idempotencyKey, Decision decision,
String reason) {
/** 两级审核都执行相同的必填版本约束。 */
public ReviewCommand { recordVersion = requireVersion(recordVersion); }
String reason, ReviewAttachments attachments) {
/** 两级审核都执行相同的必填版本约束,未传附件表示没有本次补充。 */
public ReviewCommand { recordVersion = requireVersion(recordVersion); attachments = attachments == null ? ReviewAttachments.empty() : attachments; }
}
/** 只有零真实扫码时可退回,保留原单号与审核轮次历史。 */
@@ -41,7 +41,27 @@ public final class DispatchContracts {
String contactName, String contactPhone) { }
public record Review(ReviewStatus status, String reason, String reviewerId, Instant reviewedAt,
long reviewRound, String basisHash, String invalidatedReason) { }
long reviewRound, String basisHash, String invalidatedReason, ReviewAttachments attachments) {
/** 无补充附件时返回明确空集合,旧决定不得从资源快照自动继承。 */
public Review { attachments = attachments == null ? ReviewAttachments.empty() : attachments; }
}
/** 本次审核补充证据按分类归属本车,不修改车辆或司机业务资料。 */
public record ReviewAttachments(List<Long> vehicleFileIds, List<Long> driverPhotoFileIds, List<Long> licenseFileIds) {
/** 每类附件有界、不可变且不允许空身份或重复引用。 */
public ReviewAttachments {
vehicleFileIds = checked(vehicleFileIds); driverPhotoFileIds = checked(driverPhotoFileIds); licenseFileIds = checked(licenseFileIds);
}
/** 未传附件表示本次没有补充证据。 */
public static ReviewAttachments empty() { return new ReviewAttachments(List.of(), List.of(), List.of()); }
/** 只规范引用形状;本车和类别归属由审核服务逐个验证。 */
private static List<Long> checked(List<Long> ids) {
if (ids == null) return List.of();
if (ids.size() > 20 || ids.stream().anyMatch(id -> id == null || id <= 0) || ids.stream().distinct().count() != ids.size())
throw new IllegalArgumentException("每类审核附件最多20个,文件引用须有效且不重复");
return List.copyOf(ids);
}
}
public record Reviews(Review dispatchReview, Review loadingReview) { }
@@ -7,6 +7,9 @@ import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*;
/** 派送域实现的整车安排和单据事实边界,禁止回写排车持久化实体。 */
public interface DispatchDeliveryPort {
/** 装车完成父事务自动准备全车真实客户单据,不要求人工安排或补造费用。 */
ArrangementFact prepareForDeparture(Context context, Actor actor, String commandKey);
/** 一次预览返回同快照下全部客户目的地及费用证据。 */
ArrangementPreview preview(Context context);
@@ -63,7 +63,7 @@ public final class DispatchViews {
List<OrderDetail> orderDetails) { }
public record PdaTask(String taskNo, String dispatchNo, String warehouseId, Status status,
Stage stage, long recordVersion, Reviews reviews, Counts counts,
Stage stage, long recordVersion, Reviews reviews, Resources resources, Counts counts,
boolean pickingReconciled, List<PdaItem> items, Map<Action, Gate> actions) { }
public record PdaItem(Long dispatchItemId, String customerId, String customerName, String palletNo,
@@ -46,8 +46,8 @@ public class DispatchAggregateRepository {
public int removeItem(long itemId) {return items.deleteById(itemId);}
/** 原子更新货量并作废旧审核依据。 */
public int changeCargo(long id, long version, String hash, int itemCount, int pallets, int packages,
String route, String pending, LocalDateTime at) {
return mapper.changeCargo(id, version, hash, itemCount, pallets, packages, route, pending, at);
String route, String dispatchReview, String loadingReview, LocalDateTime at) {
return mapper.changeCargo(id, version, hash, itemCount, pallets, packages, route, dispatchReview, loadingReview, at);
}
/** 追加不可变业务快照。 */
public int history(long id, DispatchAggregate row, String action, String operator, String reason, String snapshot, LocalDateTime at) {
@@ -235,10 +235,13 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
return saveCommand(row,actor,identity,request.reason());
}
/** 上传不修改主单引用;返回本车文件ID后由公共版本编辑命令保存选择。 */
/** 上传只返回本车证据;待审按当前审核权限授权,客服阶段按维护权限授权。 */
public com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File uploadEvidence(Actor actor,String number,
com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,org.springframework.web.multipart.MultipartFile file) {
requirePlatform(actor);var row=required(actor,number,false);requireEditing(row,row.getRecordVersion());
Action permission = ReviewState.DISPATCH_PENDING.name().equals(row.getReviewState()) ? Action.DISPATCH_REVIEW
: ReviewState.LOADING_PENDING.name().equals(row.getReviewState()) ? Action.LOADING_REVIEW : Action.EDIT;
if(!hasPermission(actor,permission))throw new BizException(ResultCode.FORBIDDEN,"无当前阶段附件上传权限");
return evidence.upload(row.getId(),kind,actor.operatorId(),file);
}
/** 列表权限含详情只读;仅冻结MASTER证照走司机归属,其余保持本车附件复合归属。 */
@@ -392,7 +395,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
if(n(row.getPackageCount())<=0 || repository.cargo(row.getId()).isEmpty())throw bad("空排车不能提交审核");
long round=row.getReviewRound()+1;String basis=basis(row);
row.setReviewRound(round);row.setReviewBasisHash(basis);row.setReviewState(ReviewState.DISPATCH_PENDING.name());
row.setDispatchReviewJson(write(new Review(ReviewStatus.PENDING,null,null,null,round,basis,null)));
row.setDispatchReviewJson(write(new Review(ReviewStatus.PENDING,null,null,null,round,basis,null,null)));
row.setLoadingReviewJson(write(pending(round)));
return saveCommand(row,actor,identity,request.remark());
}
@@ -400,7 +403,14 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
/** 调度与理货审核按同一轮次、同一事实摘要串行执行;理货通过才进入装车。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public CommandResult review(Actor actor,String number,boolean loading,DispatchCommands.ReviewCommand request) {
var row=lockPlatform(actor,number);Action action=loading?Action.LOADING_REVIEW:Action.DISPATCH_REVIEW;
Action action=loading?Action.LOADING_REVIEW:Action.DISPATCH_REVIEW;
// PDA仅可处理本绑定仓理货审核,原键重试也先重新核验权限和范围。
DispatchAggregate row;
if(actor!=null && actor.mode()==ActorMode.PDA) {
if(!loading || actor.boundWarehouseId()==null || !hasPermission(actor,action))
throw new BizException(ResultCode.FORBIDDEN,"仅当前绑定仓理货员可执行理货审核");
required(actor,number,false);fleet.lockDispatchShell(number);row=required(actor,number,true);
} else row=lockPlatform(actor,number);
var identity=identity(actor,number,action,request.recordVersion(),request.idempotencyKey(),request);
var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get();
requireEditing(row,request.recordVersion());
@@ -413,14 +423,21 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
var dispatch=reviews(row).dispatchReview();
if(dispatch.status()!=ReviewStatus.APPROVED || dispatch.reviewRound()!=row.getReviewRound() || !Objects.equals(dispatch.basisHash(),row.getReviewBasisHash()))throw conflict("调度审核尚未通过或依据已失效");
}
verifyReviewAttachments(row.getId(),request.attachments());
if(request.decision()==Decision.APPROVED)verifyReviewResources(row);
var reviewed=new Review(request.decision()==Decision.APPROVED?ReviewStatus.APPROVED:ReviewStatus.REJECTED,trim(request.reason()),actor.operatorId(),Instant.now().truncatedTo(ChronoUnit.MILLIS),row.getReviewRound(),row.getReviewBasisHash(),null);
var reviewed=new Review(request.decision()==Decision.APPROVED?ReviewStatus.APPROVED:ReviewStatus.REJECTED,trim(request.reason()),actor.operatorId(),Instant.now().truncatedTo(ChronoUnit.MILLIS),row.getReviewRound(),row.getReviewBasisHash(),null,request.attachments());
if(loading) {
row.setLoadingReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.APPROVED.name():ReviewState.LOADING_REJECTED.name());
if(request.decision()==Decision.APPROVED)row.setStage(Stage.LOADING.name());
} else {
row.setDispatchReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.LOADING_PENDING.name():ReviewState.DISPATCH_REJECTED.name());
row.setLoadingReviewJson(write(request.decision()==Decision.APPROVED?new Review(ReviewStatus.PENDING,null,null,null,row.getReviewRound(),row.getReviewBasisHash(),null):pending(row.getReviewRound())));
row.setLoadingReviewJson(write(request.decision()==Decision.APPROVED?new Review(ReviewStatus.PENDING,null,null,null,row.getReviewRound(),row.getReviewBasisHash(),null,null):pending(row.getReviewRound())));
}
if(request.decision()==Decision.REJECTED) {
// 任一级驳回均回客服处理,旧调度批准不再作为可继续理货的有效依据。
row.setStage(Stage.BEFORE_LOADING.name());row.setReviewBasisHash(null);
if(loading)row.setDispatchReviewJson(write(new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,
row.getReviewRound(),null,"理货审核驳回,请客服修改后重新提交调度审核",reviews(row).dispatchReview().attachments())));
}
return saveCommand(row,actor,identity,request.reason());
}
@@ -517,7 +534,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
List.copyOf(pallets),row.getLoadingConfirmationJson()==null?null:read(row.getLoadingConfirmationJson(),ConfirmedLoad.class),gate,List.copyOf(missing));
}
/** 最后一次扫码只核平,显式装车完成才保存实载并进入装车后。 */
/** 内部实载步骤:扫码核平后保存装载证据,中间装车后状态由外层同事务继续推进发车。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public CommandResult loadingComplete(Actor actor,String number,DispatchCommands.LoadingComplete request) {
required(actor,number,false);fleet.lockDispatchShell(number);var row=required(actor,number,true);
@@ -609,9 +626,24 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
/** 作废原因进入两级显式记录,旧审核证据仍保留在不可变历史中。 */
private void invalidate(DispatchAggregate row,String reason) {
row.setReviewState(ReviewState.DRAFT.name());row.setReviewBasisHash(null);
var invalid=new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,row.getReviewRound(),null,reason);
row.setDispatchReviewJson(write(invalid));row.setLoadingReviewJson(write(invalid));
var invalid=new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,row.getReviewRound(),null,reason,null);
var previous=reviews(row);
row.setDispatchReviewJson(write(retainRejection(previous.dispatchReview(),invalid)));
row.setLoadingReviewJson(write(retainRejection(previous.loadingReview(),invalid)));
}
/** 客服改资料期间保留本轮驳回原因;下一轮提交统一重置,不复用批准。 */
private static Review retainRejection(Review previous,Review invalid) {
return previous.status()==ReviewStatus.REJECTED
?new Review(previous.status(),previous.reason(),previous.reviewerId(),previous.reviewedAt(),previous.reviewRound(),null,invalid.invalidatedReason(),previous.attachments())
:new Review(invalid.status(),null,null,null,invalid.reviewRound(),null,invalid.invalidatedReason(),previous.attachments());
}
/** 审核附件逐个验证本车与种类;MASTER档案证照不能冒充本次上传证据。 */
private void verifyReviewAttachments(long dispatch,ReviewAttachments attachments) {
for(var id:attachments.vehicleFileIds())evidence.metadata(dispatch,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,id);
for(var id:attachments.driverPhotoFileIds())evidence.metadata(dispatch,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,id);
for(var id:attachments.licenseFileIds())evidence.metadata(dispatch,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,id);
}
/** 临时取证、有效期和实际主档资格必须在提交及通过时再次核验。 */
void verifyReviewResources(DispatchAggregate row) {
var r=read(row.getResourcesJson(),Resources.class);var v=r.vehicle();var d=r.driver();
@@ -726,7 +758,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
int pallets=(int)all.stream().map(i -> i.getWarehousePalletId()==null ? "PLAN:"+i.getPlanPalletId() : "PALLET:"+i.getWarehousePalletId()).distinct().count();
String route=all.stream().map(this::destinationLabel).filter(Objects::nonNull).distinct().sorted().collect(Collectors.joining(","));
var digest=all.stream().sorted(Comparator.comparing(DispatchItem::getSourceKey)).map(i -> List.of(i.getSourceKey(),i.getSourceVersion(),i.getCustomerId(),i.getPackageCount())).toList();
if(repository.changeCargo(row.getId(),row.getRecordVersion(),hashes.source(digest),all.size(),pallets,packages,route,write(pending(row.getReviewRound())),now())!=1) throw conflict("排车版本已变化,未保存本次货物");
invalidate(row,"排车货物已修改,请客服重新提交审核");
if(repository.changeCargo(row.getId(),row.getRecordVersion(),hashes.source(digest),all.size(),pallets,packages,route,row.getDispatchReviewJson(),row.getLoadingReviewJson(),now())!=1) throw conflict("排车版本已变化,未保存本次货物");
var stops=all.stream().map(i -> new FleetExecutionModels.DispatchStop(destinationLabel(i),i.getDeliveryAddress(),destination(i).key()))
.collect(Collectors.toMap(FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList();
fleet.updateDispatchCargo(row.getDispatchNo(),packages,stops);
@@ -775,12 +808,14 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
// 到仓取货和本土卡车沿运输任务执行,不能进入仓内拣货、扣库存和转派送链。
if (row.getTaskType()!=null && !"DELIVERY".equals(row.getTaskType())) {
var blocked=new EnumMap<Action,Gate>(Action.class);
for(var action:Action.values())blocked.put(action,new Gate(false,"TRANSPORT_TASK","请在排车运输任务中操作"));
for(var action:Action.values())if(action!=Action.ARRANGE_DELIVERY && action!=Action.DEPART)
blocked.put(action,new Gate(false,"TRANSPORT_TASK","请在排车运输任务中操作"));
return Collections.unmodifiableMap(blocked);
}
var gates=new EnumMap<Action,Gate>(Action.class); boolean active=Status.ACTIVE.name().equals(row.getStatus());
Stage stage=Stage.valueOf(row.getStage()); ReviewState review=ReviewState.valueOf(row.getReviewState());
for(var action:Action.values()) {
if(action==Action.ARRANGE_DELIVERY || action==Action.DEPART)continue;
boolean state=switch(action) {
case CREATE -> actor.mode()==ActorMode.PLATFORM;
case EDIT,ADD_CARGO,REMOVE_CARGO,CANCEL -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0;
@@ -799,11 +834,20 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
return Collections.unmodifiableMap(gates);
}
/** 装车发运及原结果恢复都按当前装车权限与PDA绑定身份校验。 */
void requireLoadingPermission(Actor actor) {
if(actor==null || actor.operatorId()==null || !hasPermission(actor,Action.LOADING_COMPLETE))
throw new BizException(ResultCode.FORBIDDEN,"当前无装车完成权限");
}
/** 主体类型和正式动作权限逐项判断,不把list权限等同写权限。 */
private static boolean hasPermission(Actor actor,Action action) {
if(action==Action.ARRANGE_DELIVERY || action==Action.DEPART)return false;
var auth=SecurityContextHolder.getContext().getAuthentication(); if(auth==null || actor==null) return false;
if(actor.mode()==ActorMode.PDA) return Set.of(Action.PICK,Action.LOADING_COMPLETE,Action.DEPART).contains(action)
&& auth.getAuthorities().stream().anyMatch(a->"lastleg:pda:operate".equals(a.getAuthority()));
if(actor.mode()==ActorMode.PDA) return Set.of(Action.LOADING_REVIEW,Action.PICK,Action.LOADING_COMPLETE,Action.DEPART).contains(action)
&& actor.boundWarehouseId()!=null
&& auth.getAuthorities().stream().anyMatch(a->"lastleg:pda:operate".equals(a.getAuthority()))
&& auth.getAuthorities().stream().anyMatch(a->"ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR".equals(a.getAuthority()));
if(action==Action.PICK) return false;
String permission=switch(action) {
case CREATE -> PREFIX+"create"; case CANCEL -> PREFIX+"cancel";
@@ -814,6 +858,9 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
return auth.getAuthorities().stream().anyMatch(a->permission.equals(a.getAuthority()));
}
/** PDA审核读取整车冻结资源,与后台共用相同快照,查询不更改主档。 */
Resources resources(DispatchAggregate row) { return read(row.getResourcesJson(),Resources.class); }
/** 统计按真整车去重并补齐全部零项。 */
private static DispatchViews.Statistics statistics(List<DispatchAggregateMapper.Summary> groups) {
var stages=new EnumMap<Stage,Long>(Stage.class); for(var stage:Stage.values()) stages.put(stage,0L);
@@ -821,9 +868,13 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
for(var group:groups) {
if(Status.CANCELLED.name().equals(group.status())) { cancelled+=group.quantity(); continue; }
active+=group.quantity(); var stage=Stage.valueOf(group.stage()); stages.merge(stage,group.quantity(),Long::sum);
if(stage==Stage.BEFORE_LOADING) { before+=group.quantity(); var review=ReviewState.valueOf(group.reviewState());
dispatch[review==ReviewState.DRAFT?0:review==ReviewState.DISPATCH_PENDING?1:review==ReviewState.DISPATCH_REJECTED?3:2]+=group.quantity();
loading[review==ReviewState.LOADING_PENDING?1:review==ReviewState.LOADING_REJECTED?3:review==ReviewState.APPROVED?2:0]+=group.quantity(); }
if(stage==Stage.BEFORE_LOADING) {
// 三分区表示当前待办,驳回只能计入客服处理,不能留在审核队列。
var review=ReviewState.valueOf(group.reviewState());
if(canSubmitReview(review.name()))before+=group.quantity();
else if(review==ReviewState.DISPATCH_PENDING)dispatch[1]+=group.quantity();
else if(review==ReviewState.LOADING_PENDING)loading[1]+=group.quantity();
}
}
return new DispatchViews.Statistics(active+cancelled,active,cancelled,Collections.unmodifiableMap(stages),new DispatchViews.BeforeLoadingCounts(before,
new DispatchViews.ReviewCounts(dispatch[0],dispatch[1],dispatch[2],dispatch[3]),new DispatchViews.ReviewCounts(loading[0],loading[1],loading[2],loading[3])));
@@ -883,7 +934,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
/** 当前审核记录,不以版本相等代替轮次有效性。 */
Reviews reviews(DispatchAggregate row) { return new Reviews(read(row.getDispatchReviewJson(),Review.class),read(row.getLoadingReviewJson(),Review.class)); }
/** 未提交的审核不伪造待审核。 */
private static Review pending(long round) { return new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,round,null,null); }
private static Review pending(long round) { return new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,round,null,null,null); }
/** 整车真实统计。 */
private static Counts counts(DispatchAggregate row) { return new Counts(n(row.getItemCount()),n(row.getPalletCount()),n(row.getPackageCount()),n(row.getPickedPackageCount()),n(row.getShippedPackageCount())); }
/** 空单永远不能核平为装车完成。 */
@@ -0,0 +1,58 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.dispatch.port.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import java.util.List;
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
/** 两端装车完成即发车:所有中间状态只存在于同一事务,原请求只恢复最终结果。 */
@Service
public class DispatchLoadingDepartureService {
private final DispatchAggregateService workflow;
private final DispatchDeliveryPort delivery;
private final DispatchDepartureService departure;
private final DispatchCommandLedger commands;
private final DispatchCommandHasher hashes;
/** 复用装车、派送和发车的真实规则,不另建库存或运输状态实现。 */
public DispatchLoadingDepartureService(DispatchAggregateService workflow,DispatchDeliveryPort delivery,
DispatchDepartureService departure,DispatchCommandLedger commands,DispatchCommandHasher hashes) {
this.workflow=workflow;this.delivery=delivery;this.departure=departure;this.commands=commands;this.hashes=hashes;
}
/** 原键恢复先于阶段校验;任一步失败均回滚装载、子单、费用、发车与全部账本。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public CommandResult complete(Actor actor,String number,DispatchCommands.LoadingComplete request) {
workflow.requireLoadingPermission(actor);
var context=workflow.lockForArrangement(actor,number);
var identity=new CommandIdentity(Action.LOADING_COMPLETE,request.idempotencyKey(),request.recordVersion(),
hashes.command(actor,Action.LOADING_COMPLETE,number,request.recordVersion(),request));
var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();
// 先取得本车单据锁再申请真实来源锁,后续所有步骤保持同一个整车父锁。
delivery.departureFacts(context,true);
var loaded=workflow.loadingComplete(actor,number,new DispatchCommands.LoadingComplete(request.recordVersion(),
stepKey(number,request.idempotencyKey(),"LOAD"),request.manifestHash(),request.measurements(),request.remark()));
context=workflow.preview(actor,number);
String arrangementKey=stepKey(number,request.idempotencyKey(),"ARRANGE");
var fact=delivery.prepareForDeparture(context,actor,arrangementKey);
var arrangementIdentity=new CommandIdentity(Action.ARRANGE_DELIVERY,arrangementKey,context.recordVersion(),
hashes.source(List.of(number,actor.operatorId(),context.recordVersion(),fact.arrangementBusinessHash())));
var arranged=workflow.recordArrangement(context,actor,arrangementIdentity,fact,false);
var departed=departure.depart(actor,number,new DispatchCommands.Depart(arranged.recordVersion(),
stepKey(number,request.idempotencyKey(),"DEPART"),request.remark()));
if(departed.stage()!=Stage.DELIVERING || !(loaded.result() instanceof DispatchResults.Loaded))
throw new BizException(ResultCode.CONFLICT,"装车发运结果未核平");
var result=new CommandResult(number,Action.LOADING_COMPLETE,request.idempotencyKey(),departed.recordVersion(),
departed.status(),departed.stage(),departed.occurredAt(),Effect.APPLIED,departed.counts(),loaded.result(),false,false);
commands.append(context.dispatchId(),actor,identity,result);return result;
}
/** 固定长度内部键绑定原请求和步骤,不向客户端暴露中间命令作为恢复入口。 */
private String stepKey(String number,String original,String step) {
return "AUTO_"+step+"_"+hashes.source(List.of(number,original,step));
}
}
@@ -89,7 +89,7 @@ public class DispatchPdaQueryService {
else actions.put(Action.PICK,new Gate(false,"PICKING_RECONCILED","本车全部来源已核平,请显式确认装车"));
var counts=new Counts(n(root.getItemCount()),n(root.getPalletCount()),n(root.getPackageCount()),n(root.getPickedPackageCount()),n(root.getShippedPackageCount()));
// 整车作业直接以排车业务号识别;真实客户子taskId只在服务端审计保存。
result.add(new DispatchViews.PdaTask(root.getDispatchNo(),root.getDispatchNo(),root.getOverseasWarehouseId(),Status.valueOf(root.getStatus()),Stage.valueOf(root.getStage()),root.getRecordVersion(),workflow.reviews(root),counts,complete,List.copyOf(detail),Collections.unmodifiableMap(actions)));
result.add(new DispatchViews.PdaTask(root.getDispatchNo(),root.getDispatchNo(),root.getOverseasWarehouseId(),Status.valueOf(root.getStatus()),Stage.valueOf(root.getStage()),root.getRecordVersion(),workflow.reviews(root),workflow.resources(root),counts,complete,List.copyOf(detail),Collections.unmodifiableMap(actions)));
}
return List.copyOf(result);
}
@@ -36,7 +36,7 @@ public class TransportTaskProjection {
row.setOverseasWarehouseCode(trip.getWarehouseCode());row.setOverseasWarehouseName(trip.getWarehouseName());
row.setStatus("ACTIVE");row.setStage("BEFORE_LOADING");row.setRecordVersion(trip.getRecordVersion());row.setCargoRevision(0L);
row.setCargoHash(trip.getRequestHash());row.setReviewState("DRAFT");row.setReviewRound(0L);
var review=new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,0,null,null);
var review=new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,0,null,null,null);
row.setDispatchReviewJson(write(review));row.setLoadingReviewJson(write(review));
resources(row,trip);row.setPlannedDepartureAt(trip.getPlannedDepartureAt());row.setEstimatedArrivalAt(trip.getExpectedDriverEndAt());
row.setRoute(("PICKUP".equals(trip.getTripPurpose())?"到仓取货":"本土卡车")+" · "+items+"单 · "+trip.getWarehouseCode());row.setItemCount(items);row.setPalletCount(pallets);row.setPackageCount(packages);
@@ -7,5 +7,6 @@ public record ContainerArrivalTimeView(
String sourceContainerBookingId,
LocalDateTime estimatedWarehouseArrivalAt,
LocalDateTime actualWarehouseArrivalAt,
int version
int version,
long planCount
) { }
@@ -75,7 +75,7 @@ public class OutsourcedBusinessService {
}
/** 临时车辆合法保留空vehicleId;承运商缺失保留null供页面显示破折号。 */
private Row row(OwnedBusinessModels.Row r,String carrierName) {
if(!"DISPATCHED".equals(r.vehicle().ownership()) || r.photoSummary().fuelCount()!=0)throw conflict("外配运营快照与冻结归属不一致");
if(!java.util.Set.of("OWNED","DISPATCHED").contains(r.vehicle().ownership()) || r.photoSummary().fuelCount()!=0)throw conflict("外配运营快照的车辆归属或照片不合法");
return new Row(r.dispatchNo(),r.stage(),r.plannedDepartureAt(),r.loadingCompletedAt(),r.departedAt(),r.deliveryCompletedAt(),r.origin(),
new Vehicle(r.vehicle().vehicleId(),r.vehicle().plate(),r.vehicle().ownership(),carrierName==null || carrierName.isBlank()?null:carrierName),r.driver(),r.destinations(),
r.actualPalletCount(),operations(r.operations()),r.unitPrice(),r.recordVersion(),r.dispatchRecordVersion(),new PhotoSummary(r.photoSummary().loadingCount()),r.actions(),r.sourceIssues());
@@ -7,8 +7,26 @@ import java.util.List;
/** 在原排车事实之上做自有运营分页,不复制或推进履约模型。 */
@Mapper
public interface OwnedBusinessQueryMapper {
/** 拼车须实收且来源仍在本车;标识按完整字节精确比较,512字节覆盖128字符utf8mb4,避免列排序规则冲突或截断。 */
String POOLED_CARGO="""
EXISTS (SELECT 1 FROM ll_fleet_external_booking b
JOIN ll_warehouse_transfer_order incoming ON CAST(incoming.customer_id AS BINARY(512))=CAST(b.customer_id AS BINARY(512))
AND CAST(incoming.transfer_no AS BINARY(512))=CAST(b.incoming_no AS BINARY(512))
WHERE b.trip_id=d.trip_id AND CAST(b.dispatch_no AS BINARY(512))=CAST(d.dispatch_no AS BINARY(512)) AND b.offer_id IS NOT NULL
AND b.status='BOUND' AND b.receipt_json IS NOT NULL
AND EXISTS (SELECT 1 FROM ll_dispatch_item cargo
JOIN ll_inbound_plan_pallet_allocation allocation ON allocation.pallet_id=cargo.plan_pallet_id
AND allocation.plan_id=cargo.plan_id AND CAST(allocation.customer_id AS BINARY(512))=CAST(cargo.customer_id AS BINARY(512))
JOIN ll_inbound_plan_line line ON line.id=allocation.line_id AND line.plan_id=cargo.plan_id
WHERE cargo.dispatch_order_id=d.id AND CAST(cargo.customer_id AS BINARY(512))=CAST(b.customer_id AS BINARY(512))
AND CAST(line.customer_id AS BINARY(512))=CAST(b.customer_id AS BINARY(512))
AND CAST(line.inbound_code AS BINARY(512))=CAST(incoming.inbound_code AS BINARY(512))
AND cargo.warehouse_pallet_id IS NOT NULL AND cargo.package_count>0))
""";
String SOURCE="""
SELECT d.id,d.dispatch_no,d.status,d.stage,d.record_version,d.overseas_warehouse_id,
SELECT
"""+POOLED_CARGO+" AS pooled_cargo,"+"""
d.id,d.dispatch_no,d.status,d.stage,d.record_version,d.overseas_warehouse_id,
d.overseas_warehouse_code,d.overseas_warehouse_name,d.planned_departure_at,d.loading_completed_at,
d.departed_at,d.delivery_completed_at,d.loading_completion_hash,d.loading_confirmation_json,d.resources_json,
o.vehicle_id AS owned_vehicle_id,o.resource_mode AS owned_resource_mode,
@@ -20,10 +38,12 @@ public interface OwnedBusinessQueryMapper {
String FILTER="""
d.status='ACTIVE' AND d.stage IN ('AFTER_LOADING','DELIVERING','DELIVERED')
AND d.loading_completed_at IS NOT NULL AND d.loading_confirmation_json IS NOT NULL
AND o.vehicle_ownership=#{q.scope.ownership}
AND o.vehicle_ownership IN ('OWNED','DISPATCHED')
AND CAST(o.loading_completion_hash AS BINARY(128))=CAST(d.loading_completion_hash AS BINARY(128))
AND o.loading_completed_at=d.loading_completed_at
AND (o.resource_mode='MASTER' OR (#{q.scope.ownership}='DISPATCHED' AND o.resource_mode='EXTERNAL'))
AND (o.resource_mode='MASTER' OR (o.vehicle_ownership='DISPATCHED' AND o.resource_mode='EXTERNAL'))
AND <if test='!q.scope.pooledCargo'>NOT</if>
"""+POOLED_CARGO+"""
<if test='q.from != null'> AND d.planned_departure_at &gt;= #{q.from}</if>
<if test='q.to != null'> AND d.planned_departure_at &lt; #{q.to}</if>
<if test='q.stage != null'> AND d.stage=#{q.stage}</if>
@@ -14,6 +14,7 @@ public final class OwnedBusinessRows {
private LocalDateTime ownershipCapturedAt,loadingCompletedAt;
}
@Data public static class Source {
private Boolean pooledCargo;
private Long id,recordVersion;
private String dispatchNo,status,stage,overseasWarehouseId,overseasWarehouseCode,overseasWarehouseName;
private String loadingCompletionHash,loadingConfirmationJson,resourcesJson;
@@ -6,13 +6,14 @@ import com.baiyue.common.core.api.ResultCode;
/** 仅服务端固定入口选择的作用域;不得成为浏览器请求字段或权限别名。 */
public enum OwnedBusinessScope {
OWNED("OWNED","platform:fleet:owned-business:"),
OUTSOURCED("DISPATCHED","platform:fleet:outsourced-business:");
private final String ownership,prefix;
/** 车辆归属与入口权限一一固定,不能由请求指定。 */
OwnedBusinessScope(String ownership,String prefix){this.ownership=ownership;this.prefix=prefix;}
/** SQL仅绑定此可信枚举的归属值,不拼接客户端片段。 */
public String getOwnership(){return ownership;}
OWNED(false,"platform:fleet:owned-business:"),
OUTSOURCED(true,"platform:fleet:outsourced-business:");
private final boolean pooledCargo;
private final String prefix;
/** 业务入口按是否实际接入拼车货物分类,与车辆归属独立。 */
OwnedBusinessScope(boolean pooledCargo,String prefix){this.pooledCargo=pooledCargo;this.prefix=prefix;}
/** SQL绑定服务端固定分类,不接受浏览器指定作用域。 */
public boolean isPooledCargo(){return pooledCargo;}
/** 查看权限包含本作用域详情、历史和照片读取。 */
public String listPermission(){return prefix+"list";}
/** 运营写权限独立,外配权限不映射成自有权限。 */
@@ -22,5 +23,5 @@ public enum OwnedBusinessScope {
/** 外配只支持装车五框,服务调用同样不能绕过。 */
public void requireSlot(PhotoSlot slot){if(slot==null || this==OUTSOURCED && !"LOADING".equals(slot.category()))throw new BizException(ResultCode.BAD_REQUEST,"当前业务不支持该照片框");}
/** 不存在只显示当前业务归属,不泄露内部身份。 */
public BizException missing(){return new BizException(ResultCode.NOT_FOUND,"排车单不存在或不属于当前"+(this==OWNED?"自有":"外配")+"业务");}
public BizException missing(){return new BizException(ResultCode.NOT_FOUND,"排车单不存在或不属于当前"+(this==OWNED?"自有业务":"外配任务"));}
}
@@ -68,7 +68,7 @@ public class OwnedBusinessProjection {
return new Row(source.getDispatchNo(),Stage.valueOf(source.getStage()),codec.iso(source.getPlannedDepartureAt()),
codec.iso(source.getLoadingCompletedAt()),codec.iso(source.getDepartedAt()),codec.iso(source.getDeliveryCompletedAt()),
new Origin(source.getOverseasWarehouseId(),source.getOverseasWarehouseCode(),source.getOverseasWarehouseName()),
new Vehicle(resources.vehicle().vehicleId()==null?null:String.valueOf(resources.vehicle().vehicleId()),resources.vehicle().plate(),scope.getOwnership()),
new Vehicle(resources.vehicle().vehicleId()==null?null:String.valueOf(resources.vehicle().vehicleId()),resources.vehicle().plate(),source.getOwnedVehicleOwnership()),
new Driver(driver==null?null:driver.name(),driver==null?null:driver.phone()),List.copyOf(destinations.values()),pallets,ops,price,
record==null?"0":String.valueOf(record.getRecordVersion()),String.valueOf(source.getRecordVersion()),
new PhotoSummary((int)photos.stream().filter(p->"LOADING".equals(p.category())).count(),(int)photos.stream().filter(p->"FUEL".equals(p.category())).count()),
@@ -17,7 +17,7 @@ import java.util.stream.Collectors;
import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*;
import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
/** 自有车辆装车后视图;无归属快照的旧单不在GET时回填。 */
/** 装车后按实际拼车货物分组;车辆归属仅保留原装车证据。 */
@Service
public class OwnedBusinessQueryService {
private final OwnedBusinessRepository repository;
@@ -61,7 +61,7 @@ public class OwnedBusinessQueryService {
/** 对原排车详情做组合,不创建第二份主单或履约状态。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public Detail detail(Actor actor,String number){codec.require(actor,LIST);return detailForWrite(actor,requireOwned(number));}
/** 外配入口只能读取其冻结归属,不能借自有权限取得原详情。 */
/** 外配入口只读实际拼车任务,不能借自有权限取得原详情。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public Detail detail(Actor actor,String number,OwnedBusinessScope scope){codec.require(actor,scope.listPermission());return detailForWrite(actor,requireScoped(number,scope),scope);}
/** 写入已经有独立权限;回执仍由相同真实来源组成。 */
@@ -82,7 +82,7 @@ public class OwnedBusinessQueryService {
if(number==null || number.isBlank() || number.length()>128)throw bad("请指定排车单号");
var source=repository.source(number);if(source==null)throw missing();eligible(source,OwnedBusinessScope.OWNED);return source;
}
/** 共享附加表仍按原装车归属隔离,不接受浏览器业务域参数。 */
/** 共享附加表按实际拼车分类隔离,不接受浏览器业务域参数。 */
public Source requireScoped(String number,OwnedBusinessScope scope) {
if(scope==OwnedBusinessScope.OWNED)return requireOwned(number);
if(number==null || number.isBlank() || number.length()>128)throw bad("请指定排车单号");
@@ -91,13 +91,15 @@ public class OwnedBusinessQueryService {
/** 阶段、真实装车hash及车辆快照须与捕获证据一致。 */
private void eligible(Source source,OwnedBusinessScope scope) {
if(source.getOwnedVehicleOwnership()==null)throw conflict("本单缺少装车时车辆归属证据,不能归入"+(scope==OwnedBusinessScope.OWNED?"自有":"外配")+"业务");
if(!scope.getOwnership().equals(source.getOwnedVehicleOwnership()))throw scope.missing();
if(!Set.of("OWNED","DISPATCHED").contains(source.getOwnedVehicleOwnership()))throw conflict("装车车辆归属不合法");
if(source.getPooledCargo()==null)throw conflict("本单缺少实际拼车分类依据");
if(scope.isPooledCargo()!=source.getPooledCargo())throw scope.missing();
if(!"ACTIVE".equals(source.getStatus()) || source.getStage()==null || !Set.of("AFTER_LOADING","DELIVERING","DELIVERED").contains(source.getStage())
|| source.getLoadingCompletedAt()==null || source.getLoadingConfirmationJson()==null)throw conflict("本单尚无合法装车完成事实");
var resource=codec.read(source.getResourcesJson(),DispatchContracts.Resources.class).vehicle();
boolean master=resource!=null && resource.mode()!=null && "MASTER".equals(resource.mode().name()) && "MASTER".equals(source.getOwnedResourceMode())
&& resource.vehicleId()!=null && Objects.equals(source.getOwnedVehicleId(),resource.vehicleId());
boolean external=scope==OwnedBusinessScope.OUTSOURCED && resource!=null && resource.mode()!=null && "EXTERNAL".equals(resource.mode().name())
boolean external="DISPATCHED".equals(source.getOwnedVehicleOwnership()) && resource!=null && resource.mode()!=null && "EXTERNAL".equals(resource.mode().name())
&& "EXTERNAL".equals(source.getOwnedResourceMode()) && resource.vehicleId()==null && source.getOwnedVehicleId()==null;
if((!master && !external) || !Objects.equals(source.getOwnedLoadingCompletionHash(),source.getLoadingCompletionHash())
|| !Objects.equals(source.getOwnedLoadingCompletedAt(),source.getLoadingCompletedAt()))throw conflict("车辆归属与原装车事实不一致");
@@ -303,7 +303,8 @@ public class FleetBookingService {
private static void requireTrip(FleetTripEntity trip,long version){if("PICKUP".equals(trip.getTripPurpose()))throw conflict("取货车次不能承接出库拼车预订");if(!"PLANNED".equals(trip.getStatus()))throw conflict("仅计划中的车次允许管理外接预订");if(trip.getRecordVersion()!=version)throw conflict("车次已被修改,请刷新");}
private static void requireVersion(FleetBookingEntity row,long version){if(row.getRecordVersion()!=version)throw conflict("外接预订已被修改,请刷新");}
private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception error){throw new IllegalStateException(error);}}
private String hash(Object value){try{ObjectNode node=json.valueToTree(value);node.remove("idempotencyKey");node.remove("tripRecordVersion");return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(node).getBytes(StandardCharsets.UTF_8)));}catch(Exception error){throw new IllegalStateException(error);}}
/** 请求对象排除重放元数据;排车来源键的数组必须保留全部元素及顺序。 */
private String hash(Object value){try{com.fasterxml.jackson.databind.JsonNode node=json.valueToTree(value);if(node instanceof ObjectNode object){object.remove("idempotencyKey");object.remove("tripRecordVersion");}return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(node).getBytes(StandardCharsets.UTF_8)));}catch(Exception error){throw new IllegalStateException(error);}}
private static LocalDateTime now(){return LocalDateTime.now(ZoneOffset.UTC);}
private static String trim(String value){return value==null||value.isBlank()?null:value.trim();}
private static String reason(String value,String fallback){return trim(value)==null?fallback:trim(value);}
@@ -0,0 +1,27 @@
package com.baiyue.lastleg.fleet.trip;
import java.time.LocalDateTime;
import java.util.List;
import org.springframework.stereotype.Repository;
/** 日历只读访问保留真实主单范围,整批读取原货物快照而不逐车查询。 */
@Repository
public class FleetTripCalendarRepository {
public record Source(String dispatchNo, String sourceSnapshotJson) { }
private final FleetTripMapper mapper;
/** 装配现有车次Mapper,日历服务不直接访问SQL。 */
public FleetTripCalendarRepository(FleetTripMapper mapper) { this.mapper = mapper; }
/** 按真实UTC时间窗口读取有唯一整车主单的运输记录。 */
public List<FleetTripCalendarRow> calendar(LocalDateTime from, LocalDateTime to) {
return mapper.calendar(from, to);
}
/** 一次读取指定主单的全部货物快照,空窗口不发送空IN查询。 */
public List<Source> sourceSnapshots(List<String> dispatchNumbers) {
if (dispatchNumbers.isEmpty()) return List.of();
return mapper.calendarSources(dispatchNumbers).stream()
.map(row -> new Source(row.dispatchNo(), row.sourceSnapshotJson())).toList();
}
}
@@ -6,6 +6,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.time.*;
@@ -18,27 +19,29 @@ public class FleetTripCalendarService {
String originWarehouseCode, String originWarehouseName, String originCountryCode,
String originTimezone, String originWarehouseStatus, Instant plannedDepartureAt,
Instant expectedDriverEndAt, Instant departedAt, Instant completedAt,
List<FleetTripModels.Stop> stops, List<String> incompleteReasons) { }
List<FleetTripModels.Stop> stops, List<String> containerNumbers, List<String> incompleteReasons) { }
public record Result(List<Entry> records, List<Entry> unresolvedRecords) { }
private static final Set<String> COUNTRIES = Set.of(Locale.getISOCountries());
private final FleetTripMapper trips;
private final FleetTripCalendarRepository trips;
private final FleetWarehouseClient warehouses;
private final ObjectMapper json;
private final String token;
public FleetTripCalendarService(FleetTripMapper trips, FleetWarehouseClient warehouses, ObjectMapper json,
/** 装配日历持久化入口与真实仓库元数据提供者。 */
public FleetTripCalendarService(FleetTripCalendarRepository trips, FleetWarehouseClient warehouses, ObjectMapper json,
@Value("${baiyue.security.internal-service-token:}") String token) {
this.trips=trips; this.warehouses=warehouses; this.json=json; this.token=token;
}
/** 一次读取真实排车及一次仓库资料查询,按业务地点判定日历归属。 */
@Transactional(readOnly=true)
/** 同一快照批读真实主单和货物,仓库资料只查询一次,保留原业务地点时区。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public Result calendar(Instant from, Instant to) {
if (from==null || to==null || !to.isAfter(from) || Duration.between(from,to).compareTo(Duration.ofDays(44))>0)
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"查询须为不超过44天的UTC时间范围");
var rows=trips.calendar(LocalDateTime.ofInstant(from,ZoneOffset.UTC),LocalDateTime.ofInstant(to,ZoneOffset.UTC));
if (rows.size()>2000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"排车过多,请缩小查询日期范围");
if (rows.isEmpty()) return new Result(List.of(),List.of());
var containers=containerNumbers(rows);
var ids=rows.stream().map(FleetTripEntity::getWarehouseId).filter(Objects::nonNull).filter(id->id>0).distinct().sorted().toList();
var metadata=metadata(ids);
var records=new ArrayList<Entry>(); var unresolved=new ArrayList<Entry>();
@@ -64,12 +67,35 @@ public class FleetTripCalendarService {
var entry=new Entry("DISPATCH",row.getDispatchNo(),row.getStatus(),row.getWarehouseId()==null?null:row.getWarehouseId().toString(),
warehouse==null?row.getWarehouseCode():warehouse.warehouseCode(),warehouse==null?row.getWarehouseName():warehouse.warehouseName(),
country,zone,warehouse==null?null:warehouse.status(),instant(row.getPlannedDepartureAt()),instant(row.getExpectedDriverEndAt()),
instant(row.getDepartedAt()),instant(row.getCompletedAt()),stops,List.copyOf(reasons));
instant(row.getDepartedAt()),instant(row.getCompletedAt()),stops,containers.getOrDefault(row.getDispatchNo(),List.of()),List.copyOf(reasons));
(unresolvedOrigin?unresolved:records).add(entry);
}
return new Result(List.copyOf(records),List.copyOf(unresolved));
}
/** 柜号只取排车建单时正式保存的SourceDisplay.containerNo,不反查或推测其他来源。 */
private Map<String,List<String>> containerNumbers(List<FleetTripCalendarRow> rows) {
var numbers=rows.stream().map(FleetTripCalendarRow::getDispatchNo).distinct().sorted().toList();
var requested=new HashSet<>(numbers);var grouped=new LinkedHashMap<String,LinkedHashSet<String>>();
for(var source:trips.sourceSnapshots(numbers)) {
if(source==null || !requested.contains(source.dispatchNo()) || source.sourceSnapshotJson()==null || source.sourceSnapshotJson().isBlank())
throw sourceUnavailable();
try {
var snapshot=json.reader().with(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
.readTree(source.sourceSnapshotJson());
if(snapshot==null || !snapshot.isObject())throw sourceUnavailable();
var value=snapshot.get("containerNo");
if(value==null || value.isNull())continue;
if(!value.isTextual())throw sourceUnavailable();
var number=value.textValue().strip();
if(!number.isEmpty())grouped.computeIfAbsent(source.dispatchNo(),ignored->new LinkedHashSet<>()).add(number);
} catch(com.fasterxml.jackson.core.JsonProcessingException invalid) { throw sourceUnavailable(); }
}
var result=new LinkedHashMap<String,List<String>>();
grouped.forEach((number,values)->result.put(number,List.copyOf(values)));return Map.copyOf(result);
}
/** 元数据批次必须完整覆盖请求身份,未知地点保持显式未核实。 */
private Map<Long,FleetWarehouseClient.Metadata> metadata(List<Long> ids) {
if (ids.isEmpty()) return Map.of();
try {
@@ -85,6 +111,10 @@ public class FleetTripCalendarService {
return result;
} catch (RuntimeException failure) { throw unavailable(); }
}
/** 持久UTC时间只转换为UTC瞬时,不套用浏览器日期。 */
private static Instant instant(LocalDateTime value) { return value==null?null:value.toInstant(ZoneOffset.UTC); }
/** 仓库服务失败明确报错,不能把未知时区伪装成完整空日历。 */
private static ResponseStatusException unavailable() { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"仓库时区资料暂不可用,请重试"); }
/** 损坏货物快照保留明确错误,不静默丢弃真实排车或柜号。 */
private static ResponseStatusException sourceUnavailable() { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"排车货物柜号资料暂不可用,请重试"); }
}
@@ -19,6 +19,18 @@ public interface FleetTripMapper extends BaseMapper<FleetTripEntity> {
ORDER BY COALESCE(t.departed_at,t.planned_departure_at),d.dispatch_no LIMIT 2001
""")
java.util.List<FleetTripCalendarRow> calendar(@Param("from") java.time.LocalDateTime from,@Param("to") java.time.LocalDateTime to);
record CalendarSource(String dispatchNo, String sourceSnapshotJson) { }
/** 主查询限额之后批量读取原货物快照,不以货物JOIN放大整车记录或截断多柜。 */
@Select("""
<script>
SELECT d.dispatch_no AS dispatchNo,i.source_snapshot_json AS sourceSnapshotJson
FROM ll_dispatch_order d JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id
WHERE d.status='ACTIVE' AND d.dispatch_no IN
<foreach collection='numbers' item='number' open='(' separator=',' close=')'>#{number}</foreach>
ORDER BY d.dispatch_no,i.id
</script>
""")
java.util.List<CalendarSource> calendarSources(@Param("numbers") java.util.List<String> dispatchNumbers);
@Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo} FOR UPDATE")
FleetTripEntity lockByNo(@Param("tripNo") String tripNo);
@Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo}")
@@ -4,62 +4,23 @@ import com.baiyue.lastleg.dto.CustomerOrderProgress;
import org.apache.ibatis.annotations.*;
import java.util.List;
/** Batch read of every actual source, never the representative MIN(order_id) on a shared pallet. */
/** 按客户及不可变来源明细读取进度,不使用混托的代表订单。 */
@Mapper
public interface CustomerOrderProgressMapper {
/** 本客户明细决定归属,真实主阶段和实际发车时间决定待排及在途数量。 */
/** 已排车只统计有效待出库单的来源数量,与平台出库单节点一致;在途仍要求实际发车。 */
@Select("""
<script>
WITH scheduled AS (
SELECT line.order_id,SUM(a.planned_package_count) AS packages
FROM ll_dispatch_order d
JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id
JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=i.plan_pallet_id
JOIN ll_inbound_plan_line line ON line.id=a.line_id AND line.customer_id=i.customer_id
SELECT i.source_order_id AS order_id,SUM(i.package_count) AS packages
FROM ll_outbound_order b
JOIN ll_outbound_pallet_item i ON i.outbound_order_id=b.id AND i.customer_id=b.customer_id
<if test="orderIds != null">
JOIN ll_order planned_source ON planned_source.id=line.order_id AND planned_source.customer_id=#{customerId}
AND planned_source.source_order_id IN <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
JOIN ll_order outbound_source ON outbound_source.id=i.source_order_id AND outbound_source.customer_id=#{customerId}
AND outbound_source.source_order_id IN <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
</if>
WHERE i.customer_id=#{customerId} AND d.status='ACTIVE'
AND d.stage IN ('BEFORE_LOADING','LOADING','AFTER_LOADING')
AND i.warehouse_pallet_id IS NULL
GROUP BY line.order_id
UNION ALL
SELECT b.inbound_order_id AS order_id,SUM(p.available_packages+COALESCE(pk.packages,0)) AS packages
FROM ll_dispatch_order d
JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id
JOIN ll_inventory_position p ON p.customer_id=i.customer_id AND p.pallet_id=i.warehouse_pallet_id
AND p.overseas_warehouse_id=i.overseas_warehouse_id
JOIN ll_inventory_balance b ON b.id=p.balance_id AND b.customer_id=i.customer_id
<if test="orderIds != null">
JOIN ll_order stock_source ON stock_source.id=b.inbound_order_id AND stock_source.customer_id=#{customerId}
AND stock_source.source_order_id IN <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
</if>
LEFT JOIN (SELECT pick.dispatch_item_id,pick.inventory_position_id,SUM(pick.packages) AS packages
FROM ll_dispatch_pda_pick pick
<if test="orderIds != null">
JOIN ll_inventory_position scoped_position ON scoped_position.id=pick.inventory_position_id
AND scoped_position.customer_id=pick.customer_id
JOIN ll_inventory_balance scoped_balance ON scoped_balance.id=scoped_position.balance_id
AND scoped_balance.customer_id=pick.customer_id
JOIN ll_order scoped_order ON scoped_order.id=scoped_balance.inbound_order_id
AND scoped_order.customer_id=pick.customer_id
AND scoped_order.source_order_id IN <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
</if>
WHERE pick.customer_id=#{customerId}
GROUP BY pick.dispatch_item_id,pick.inventory_position_id) pk
ON pk.dispatch_item_id=i.id AND pk.inventory_position_id=p.id
WHERE i.customer_id=#{customerId} AND d.status='ACTIVE'
AND d.stage IN ('BEFORE_LOADING','LOADING','AFTER_LOADING')
AND (i.plan_pallet_id IS NULL OR EXISTS (
SELECT 1 FROM ll_inbound_plan_pallet_allocation a
JOIN ll_inbound_plan_line line ON line.id=a.line_id AND line.customer_id=i.customer_id
<if test="orderIds != null">
JOIN ll_order planned_source ON planned_source.id=line.order_id AND planned_source.customer_id=#{customerId}
AND planned_source.source_order_id IN <foreach collection="orderIds" item="id" open="(" separator="," close=")">#{id}</foreach>
</if>
WHERE a.pallet_id=i.plan_pallet_id AND line.order_id=b.inbound_order_id))
GROUP BY b.inbound_order_id
WHERE i.customer_id=#{customerId}
AND b.status NOT IN ('CANCELLED','CANCELED','OUTBOUNDED')
GROUP BY i.source_order_id
), departed AS (
SELECT a.order_id,SUM(a.packages) AS packages
FROM ll_dispatch_pda_pick_order a
@@ -210,12 +210,19 @@ public interface LastlegOrderMapper extends BaseMapper<LastlegOrder> {
@Select("SELECT * FROM ll_order WHERE source_container_booking_id=#{sourceContainerBookingId} ORDER BY customer_id,id FOR UPDATE")
List<LastlegOrder> lockContainerOrders(@Param("sourceContainerBookingId") String sourceContainerBookingId);
/** 人工时间及计划冻结时间优先;返回计划数量供各页面统一判断默认日期。 */
@Select("<script>SELECT o.source_container_booking_id AS sourceContainerBookingId," +
"CASE WHEN COUNT(o.estimated_warehouse_at)=COUNT(*) AND COUNT(DISTINCT o.estimated_warehouse_at)=1 " +
"AND (MAX(o.arrival_time_version)&gt;0 OR EXISTS(SELECT 1 FROM ll_inbound_plan p " +
"JOIN ll_order po ON po.id=p.source_order_id AND po.customer_id=p.customer_id " +
"WHERE po.source_container_booking_id=o.source_container_booking_id)) " +
"THEN MAX(o.estimated_warehouse_at) ELSE NULL END AS estimatedWarehouseArrivalAt," +
"(SELECT MIN(s.receiving_at) FROM ll_receiving_container_session s " +
"WHERE s.source_container_booking_id=o.source_container_booking_id AND s.receiving_at IS NOT NULL) AS actualWarehouseArrivalAt," +
"CASE WHEN COUNT(DISTINCT o.arrival_time_version)=1 THEN MAX(o.arrival_time_version) ELSE -1 END AS version " +
"CASE WHEN COUNT(DISTINCT o.arrival_time_version)=1 THEN MAX(o.arrival_time_version) ELSE -1 END AS version," +
"(SELECT COUNT(*) FROM ll_inbound_plan p JOIN ll_order po " +
"ON po.id=p.source_order_id AND po.customer_id=p.customer_id " +
"WHERE po.source_container_booking_id=o.source_container_booking_id) AS planCount " +
"FROM ll_order o WHERE o.source_container_booking_id IN " +
"<foreach collection='bookingIds' item='id' open='(' separator=',' close=')'>#{id}</foreach> " +
"GROUP BY o.source_container_booking_id</script>")
@@ -1,8 +1,8 @@
openapi: "3.0.3"
info:
title: "原排车外配车辆运营视图"
title: "原排车拼车任务运营视图"
version: "1.0.0"
description: "仅ACTIVE且装车后/派送中/已完成。按原装车归属DISPATCHED及MASTER/EXTERNAL资源快照读取;无快照不回填。复用四张附加表,不推进排车、不触发库存/财务。承运金额CNY/USD显式登记;单价除以实际物理托数并HALF_UP两位。无里程、油耗及油耗照片。响应遵循NON_NULL:可空属性可省略,省略与null均表示空值,0仍是明确登记值;请求必填项仍以required及DTO校验为准。"
description: "仅ACTIVE且装车后/派送中/已完成。按需求池已实收绑定及本车真实货物组成判定实际拼车。车辆归属允许OWNED/DISPATCHED,MASTER/EXTERNAL只表示资源身份;无快照不回填。复用四张附加表,不推进排车、不触发库存/财务。承运金额CNY/USD显式登记;单价除以实际物理托数并HALF_UP两位。无里程、油耗及油耗照片。响应遵循NON_NULL:可空属性可省略,省略与null均表示空值,0仍是明确登记值;请求必填项仍以required及DTO校验为准。"
security:
- bearerAuth: []
paths:
@@ -182,7 +182,7 @@ components:
properties:
vehicleId: { type: string, nullable: true, description: "原临时EXTERNAL车辆合法为空,档案MASTER核对原捕获ID" }
plate: { type: string, nullable: true }
ownership: { type: string, enum: [DISPATCHED] }
ownership: { type: string, enum: [OWNED, DISPATCHED] }
carrierName: { type: string, nullable: true, description: "只取原车辆快照,缺失显示—;不查当前档案补值" }
PhotoSlot:
type: string
@@ -1,8 +1,8 @@
openapi: "3.0.3"
info:
title: "原排车自有车辆运营视图"
title: "原排车未拼车业务运营视图"
version: "1.0.0"
description: "装车完成时冻结车辆OWNED/DISPATCHED;所有主单事实来自原dispatch。新增数据仅附加运营记录、命令、历史及归属证据。油耗L/100km手工登记,金额CNY/USD不默认、不换汇;零与未登记不同。响应遵循NON_NULL:可空属性可省略,省略与null均表示空值,0仍是明确登记值;请求必填项仍以required及DTO校验为准。"
description: "未实际接入拼车货物的装车后排车;按需求池已实收绑定及本车真实货物组成判定。车辆OWNED/DISPATCHED只表示装车时归属,不决定业务分类;所有主单事实来自原dispatch。新增数据仅附加运营记录、命令、历史及归属证据。油耗L/100km手工登记,金额CNY/USD不默认、不换汇;零与未登记不同。响应遵循NON_NULL:可空属性可省略,省略与null均表示空值,0仍是明确登记值;请求必填项仍以required及DTO校验为准。"
security:
-
bearerAuth: []
@@ -10,7 +10,7 @@ paths:
"/api/platform/v1/last-leg/fleet/owned-business":
get:
operationId: "listOwnedBusiness"
summary: "自有车辆装车后视图及同筛选四项统计"
summary: "未接入拼车货物的装车后视图及同筛选四项统计"
description: "权限:platform:fleet:owned-business:list。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。"
x-permission-code: "platform:fleet:owned-business:list"
responses:
@@ -540,18 +540,19 @@ components:
type: "object"
x-java-record: "OwnedBusinessModels.Vehicle"
required:
- "vehicleId"
- "plate"
- "ownership"
properties:
vehicleId:
type: "string"
nullable: true
plate:
type: "string"
nullable: true
ownership:
type: "string"
enum:
- "OWNED"
- "DISPATCHED"
Driver:
type: "object"
x-java-record: "OwnedBusinessModels.Driver"
@@ -359,7 +359,7 @@ components:
schemas:
TripCalendarEntry:
type: object
required: [sourceType, dispatchNo, status, plannedDepartureAt, expectedDriverEndAt, stops, incompleteReasons]
required: [sourceType, dispatchNo, status, plannedDepartureAt, expectedDriverEndAt, stops, containerNumbers, incompleteReasons]
properties:
sourceType: {type: string, enum: [DISPATCH]}
dispatchNo: {type: string, description: 真实整车主单排车号,用于排车详情路由}
@@ -375,6 +375,11 @@ components:
departedAt: {type: string, format: date-time, nullable: true}
completedAt: {type: string, format: date-time, nullable: true}
stops: {type: array, items: {$ref: '#/components/schemas/Stop'}}
containerNumbers:
type: array
description: 本车全部货物正式来源快照中的真实柜号,去空去重;多客户、多柜保留完整数组,无柜号为空数组,不推测或补造。
items: {type: string}
uniqueItems: true
incompleteReasons:
type: array
items: {type: string, enum: [ORIGIN_WAREHOUSE_UNKNOWN, ORIGIN_COUNTRY_UNKNOWN, ORIGIN_TIMEZONE_UNKNOWN, ROUTE_INCOMPLETE]}
@@ -8,9 +8,10 @@ paths:
summary: Order按认证客户读取排车和实际派送进度
description: >-
只读内部接口,无公网路由。customerId由Order认证上下文提供,所有业务表在Lastleg自身Schema内按客户读取。
正式排车单PENDING_DEPARTURE/PICKING/READY_TO_DEPART按计划或库存来源计算,预排草稿和取消单不计入。
派送必须已实际DEPARTED、出库OUTBOUNDED且对应目的地派送单仍WAIT_DELIVERY/DELIVERING。
混托使用所有库存及不可变拣货来源,不使用代表orderId;数量覆盖不完整或异常时标记partial,不签发派送完成事实。
已排车scheduled按有效待出库单的不可变来源明细及箱数计算,与平台出库单节点一致;仅生成排车单号、审核中、审核通过但未生成出库单均不计入。
取消的出库单及已实际出库的单据不计入scheduled;排车审核、真实拣货核平、装车确认及安排派送仍按原流程执行。
派送必须排车单ACTIVE且处于DELIVERING阶段、具有实际发车时间、出库OUTBOUNDED且对应目的地派送单仍WAIT_DELIVERY/DELIVERING。
混托使用全部不可变出库及拣货来源,不使用代表orderId;数量覆盖不完整或异常时标记partial,不签发派送完成事实。
security: [{internalServiceToken: []}]
requestBody:
required: true
@@ -1345,49 +1345,6 @@ paths:
required: true
schema:
type: string
/api/platform/v1/last-leg/dispatches/{dispatchNo}/transfer-delivery:
post:
summary: 原子安排本车全部客户派送及费用
description: 权限 platform:overseas-warehouse:dispatch:transfer-delivery。
security:
- bearerAuth: []
responses:
'200':
description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
requestId:
type: string
nullable: true
traceId:
type: string
nullable: true
timestamp:
type: string
format: date-time
data:
allOf:
- $ref: '#/components/schemas/DispatchCommandResult'
nullable: true
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DispatchArrangeDelivery'
parameters:
- name: dispatchNo
in: path
required: true
schema:
type: string
/api/platform/v1/last-leg/dispatches/{dispatchNo}/items:
post:
summary: 按真实来源版本向整车加货
@@ -1727,8 +1684,8 @@ paths:
type: string
/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-complete:
post:
summary: PDA核平后确认装车完成
description: 权限 platform:overseas-warehouse:dispatch:loading-complete。
summary: 确认装车完成并自动进入派送中
description: 权限 platform:overseas-warehouse:dispatch:loading-complete。 原请求在同一事务完成实载确认、全车派送单生成及发车;成功action为LOADING_COMPLETE、stage为DELIVERING,result保留真实Loaded装车证据。任一步失败整体回滚,重试原键恢复最终结果,不另行安排派送或确认发车。
security:
- bearerAuth: []
responses:
@@ -1878,43 +1835,6 @@ paths:
required: true
schema:
type: string
/api/platform/v1/last-leg/dispatches/{dispatchNo}/delivery-arrangement:
get:
summary: 读取全车派送分组与费用依据
description: 权限 platform:overseas-warehouse:dispatch:list。
security:
- bearerAuth: []
responses:
'200':
description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
requestId:
type: string
nullable: true
traceId:
type: string
nullable: true
timestamp:
type: string
format: date-time
data:
allOf:
- $ref: '#/components/schemas/DispatchArrangementPreview'
nullable: true
parameters:
- name: dispatchNo
in: path
required: true
schema:
type: string
/api/platform/v1/last-leg/dispatches/{dispatchNo}/commands/{idempotencyKey}:
get:
summary: 恢复本人原动作不可变结果
@@ -1960,7 +1880,7 @@ paths:
/api/platform/v1/last-leg/dispatches/{dispatchNo}/evidence/{kind}:
post:
summary: 上传本趟车辆或司机证据
description: 权限 platform:overseas-warehouse:dispatch:edit。PDF/JPG/PNG,非空且不超过10MiB;平台、本车、类型复合归属。上传后通过版本化EDIT保存引用。
description: 客服阶段使用 platform:overseas-warehouse:dispatch:edit;调度待审/理货待审分别使用 platform:fleet:trip:dispatch-review / platform:fleet:trip:loading-review,无需维护车辆权限。VEHICLE和DRIVER支持PDF/JPG/PNG,DRIVER_PHOTO仅JPG/PNG,非空且不超过10MiB;平台、本车、类型复合归属。审核补充附件随ReviewCommand保存,不修改资源,不重启审核。
security:
- bearerAuth: []
responses:
@@ -2014,6 +1934,7 @@ paths:
enum:
- VEHICLE
- DRIVER
- DRIVER_PHOTO
/api/platform/v1/last-leg/dispatches/{dispatchNo}/evidence/{kind}/{fileId}:
get:
summary: 读取本车证据元数据
@@ -2059,6 +1980,7 @@ paths:
enum:
- VEHICLE
- DRIVER
- DRIVER_PHOTO
- name: fileId
in: path
required: true
@@ -2101,55 +2023,13 @@ paths:
enum:
- VEHICLE
- DRIVER
- DRIVER_PHOTO
- name: fileId
in: path
required: true
schema:
type: string
pattern: ^[1-9][0-9]*$
/api/platform/v1/last-leg/dispatches/{dispatchNo}/depart:
post:
summary: 确认实际整车发车
description: 权限 platform:overseas-warehouse:dispatch:depart。
security:
- bearerAuth: []
responses:
'200':
description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
requestId:
type: string
nullable: true
traceId:
type: string
nullable: true
timestamp:
type: string
format: date-time
data:
allOf:
- $ref: '#/components/schemas/DispatchCommandResult'
nullable: true
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DispatchDepart'
parameters:
- name: dispatchNo
in: path
required: true
schema:
type: string
/api/platform/v1/last-leg/outbound-orders:
get:
summary: 分页查询出库单
@@ -3623,12 +3503,13 @@ components:
remark: { type: string, maxLength: 500, nullable: true }
ContainerArrivalTimeView:
type: object
required: [sourceContainerBookingId, version]
required: [sourceContainerBookingId, version, planCount]
properties:
sourceContainerBookingId: { type: string }
estimatedWarehouseArrivalAt: { type: string, format: date-time, nullable: true }
estimatedWarehouseArrivalAt: { type: string, format: date-time, nullable: true, description: 人工维护或入库计划冻结的整柜时间;未维护未建计划时为空,由页面按到港当地日期加两个自然日展示默认日期 }
actualWarehouseArrivalAt: { type: string, format: date-time, nullable: true, description: 海外 PDA 首次开始收货时间 }
version: { type: integer, minimum: 0 }
version: { type: integer, minimum: -1, description: -1表示柜内时间版本不一致 }
planCount: { type: integer, format: int64, minimum: 0, description: 整柜已有入库计划数量;非零时禁止默认推算及改期 }
ContainerPlanProgressDetail:
type: object
required: [summary, orders]
@@ -4274,7 +4155,10 @@ components:
invalidatedReason:
type: string
nullable: true
attachments:
$ref: '#/components/schemas/DispatchReviewAttachments'
required:
- attachments
- status
- reason
- reviewerId
@@ -5943,6 +5827,47 @@ components:
required:
- recordVersion
- idempotencyKey
DispatchReviewAttachments:
type: object
description: 本次审核补充证据;省略或null为三组空数组。每类最多20个且不得重复;逐个验证本车和类别归属,不接受MASTER档案证照冒充本车审核上传。
x-java-record: DispatchContracts.ReviewAttachments
properties:
vehicleFileIds:
type: array
maxItems: 20
uniqueItems: true
description: 本车VEHICLE证据文件ID。
items:
oneOf:
- type: string
pattern: ^[1-9][0-9]*$
- type: integer
format: int64
minimum: 1
driverPhotoFileIds:
type: array
maxItems: 20
uniqueItems: true
description: 本车DRIVER_PHOTO证据文件ID。
items:
oneOf:
- type: string
pattern: ^[1-9][0-9]*$
- type: integer
format: int64
minimum: 1
licenseFileIds:
type: array
maxItems: 20
uniqueItems: true
description: 本车DRIVER证据文件ID。
items:
oneOf:
- type: string
pattern: ^[1-9][0-9]*$
- type: integer
format: int64
minimum: 1
DispatchReviewCommand:
type: object
x-java-record: DispatchCommands.ReviewCommand
@@ -5963,6 +5888,10 @@ components:
reason:
type: string
nullable: true
attachments:
allOf:
- $ref: '#/components/schemas/DispatchReviewAttachments'
nullable: true
required:
- recordVersion
- idempotencyKey
@@ -6586,6 +6515,10 @@ components:
allOf:
- $ref: '#/components/schemas/DispatchReviews'
nullable: true
resources:
allOf:
- $ref: '#/components/schemas/DispatchResources'
nullable: true
counts:
allOf:
- $ref: '#/components/schemas/DispatchCounts'
@@ -6610,6 +6543,7 @@ components:
- stage
- recordVersion
- reviews
- resources
- counts
- pickingReconciled
- items
@@ -9,7 +9,7 @@ paths:
/api/pda/v1/last-leg/dispatches:
get:
summary: 读取Token绑定仓五阶段整车任务
description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。不接收客户或仓库范围参数;真实客户归属保留在items,整车公共版本及Gate由服务端返回。
description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。不接收客户或仓库范围参数;真实客户归属保留在items,整车公共版本及Gate由服务端返回。未指定阶段时返回待理货审核及装车、装车后、派送中的任务;车辆与司机依据读取resources。
security:
- bearerAuth: []
responses:
@@ -208,10 +208,53 @@ paths:
required: true
schema:
type: string
/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-review:
post:
summary: 理货员审核整车,后台与PDA共用审核轮次
description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。仅绑定仓且调度审核通过的本轮整车可审核;REJECTED 必填 reason 并退回客服处理,APPROVED 才进入 LOADING;公共版本拒绝双端重复审核,原键恢复仍核验本人、绑定仓和理货权限。
security:
- bearerAuth: []
responses:
'200':
description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
requestId:
type: string
nullable: true
traceId:
type: string
nullable: true
timestamp:
type: string
format: date-time
data:
allOf:
- $ref: overseas-business-v1.yaml#/components/schemas/DispatchCommandResult
nullable: true
requestBody:
required: true
content:
application/json:
schema:
$ref: overseas-business-v1.yaml#/components/schemas/DispatchReviewCommand
parameters:
- name: dispatchNo
in: path
required: true
schema:
type: string
/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-complete:
post:
summary: 真实拣货核平后显式确认装车完成
description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。
summary: 确认装车完成并自动进入派送中
description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。 原请求在同一事务完成实载确认、全车派送单生成及发车;成功action为LOADING_COMPLETE、stage为DELIVERING,result保留真实Loaded装车证据。任一步失败整体回滚,重试原键恢复最终结果,不另行安排派送或确认发车。
security:
- bearerAuth: []
responses:
@@ -251,49 +294,6 @@ paths:
required: true
schema:
type: string
/api/pda/v1/last-leg/dispatches/{dispatchNo}/depart:
post:
summary: 完整安排派送后确认实际发车
description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。
security:
- bearerAuth: []
responses:
'200':
description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
requestId:
type: string
nullable: true
traceId:
type: string
nullable: true
timestamp:
type: string
format: date-time
data:
allOf:
- $ref: overseas-business-v1.yaml#/components/schemas/DispatchCommandResult
nullable: true
requestBody:
required: true
content:
application/json:
schema:
$ref: overseas-business-v1.yaml#/components/schemas/DispatchDepart
parameters:
- name: dispatchNo
in: path
required: true
schema:
type: string
/api/pda/v1/last-leg/outbound-tasks:
get:
summary: 查询Token绑定仓的一件代发出库任务
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.calendar;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
@@ -28,12 +29,23 @@ class WarehouseCalendarMapperTest {
}
var config=new MybatisConfiguration(); config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds));
config.addMapper(WarehouseCalendarMapper.class);
config.addMapper(LastlegOrderMapper.class);
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
var rows=session.getMapper(WarehouseCalendarMapper.class).calendar(LocalDateTime.parse("2026-11-01T00:00:00"),LocalDateTime.parse("2026-12-01T00:00:00"),List.of("1"));
assertEquals(3,rows.size());
assertEquals("1",rows.get(0).sourceContainerBookingId()); assertNull(rows.get(0).plannedAt());
assertEquals(2,rows.get(1).version()); assertEquals("2026-11-10T09:00",rows.get(1).plannedAt().toString());
assertEquals(1,rows.get(2).planCount()); assertNotNull(rows.get(2).plannedAt());
// 清关提柜与日历必须读取相同的人工/冻结事实,默认日期由前端统一推算。
var customs=session.getMapper(LastlegOrderMapper.class).findContainerArrivalTimes(List.of("1","2","3"));
assertEquals(3,customs.size());
for(var calendar:rows) {
var arrival=customs.stream().filter(row -> row.sourceContainerBookingId().equals(calendar.sourceContainerBookingId())).findFirst().orElseThrow();
assertEquals(calendar.plannedAt(),arrival.estimatedWarehouseArrivalAt());
assertEquals(calendar.actualAt(),arrival.actualWarehouseArrivalAt());
assertEquals(calendar.version(),arrival.version());
assertEquals(calendar.planCount(),arrival.planCount());
}
}
}
}
@@ -37,6 +37,7 @@ import static org.mockito.Mockito.*;
/** 真实生产Mapper及事务验证任何客户或主单CAS失败时全车原子回滚。 */
class DispatchDeliveryArrangementPersistenceTest {
private JdbcTemplate db;
private org.springframework.transaction.support.TransactionTemplate tx;
private DispatchDeliveryService service;
private final DispatchExecutionPort execution=mock(DispatchExecutionPort.class);
private final DispatchDeliveryFees fees=mock(DispatchDeliveryFees.class);
@@ -49,6 +50,7 @@ class DispatchDeliveryArrangementPersistenceTest {
@BeforeEach void prepare() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:arrangement-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
db=new JdbcTemplate(ds);
tx=new org.springframework.transaction.support.TransactionTemplate(new DataSourceTransactionManager(ds));
for (var type:List.of(OutboundOrder.class,OutboundPalletItem.class,DeliveryOrder.class,LastlegOrder.class,LastlegOrderStatusHistory.class)) {
String columns=Arrays.stream(type.getDeclaredFields()).filter(f->!Modifier.isStatic(f.getModifiers())).map(f->{
String name=f.getName().replaceAll("([A-Z])","_$1").toLowerCase();
@@ -173,6 +175,32 @@ class DispatchDeliveryArrangementPersistenceTest {
assertEquals(6L,frozen.stream().filter(c->c.attributionId()==4).findFirst().orElseThrow().sourceOrigin().productQuantity());
}
/** 自动装车加入父事务,最终主单失败时全部客户单据与来源绑定均回滚。 */
@Test void automaticPreparationSharesLoadingParentTransactionAndRollback() {
assertThrows(IllegalStateException.class,()->tx.execute(status->{
service.prepareForDeparture(context,actor,"LOADING-FAIL");
throw new IllegalStateException("装车最终保存失败");
}));
for(var table:List.of("ll_outbound_order","ll_outbound_pallet_item","ll_delivery_order","ll_dispatch_delivery_arrangement","ll_loading_order_status_history"))
assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM "+table,Integer.class));
assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_order WHERE outbound_order_no IS NULL AND version=0",Integer.class));
var fact=tx.execute(status->service.prepareForDeparture(context,actor,"LOADING-OK"));
assertEquals(2,fact.result().deliveries().size());
assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order WHERE planned_delivery_at IS NULL AND delivery_method IS NULL AND pickup_method IS NULL AND loading_type IS NULL",Integer.class));
assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order WHERE fee_coverage_json IS NULL",Integer.class));
clearInvocations(fees);
assertEquals(fact,tx.execute(status->service.prepareForDeparture(context,actor,"LOADING-OK")));
verifyNoInteractions(fees);
}
/** 外接费用发布失败同样回滚自动生成的全部客户单据,不能留下半车。 */
@Test void automaticExternalFreightFailureRollsBackEveryCustomer() {
doThrow(new IllegalStateException("外接费用发布失败")).when(fees).publish(any(),argThat(g->g.customerId().equals("102")),any(),any(),any(),any(),anyString(),anyString());
assertThrows(IllegalStateException.class,()->tx.execute(status->service.prepareForDeparture(context,actor,"LOADING-FAIL")));
assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order",Integer.class));
assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Integer.class));
}
/** 按真实共享记录构造完整客户目的地请求。 */
private ArrangeDelivery request() { return new ArrangeDelivery(3L,"ARRANGE-1","cargo-hash","loading-hash",grouping.groups(context).stream().map(g->
new ArrangeGroup(g.key(),g.cargoHash(),DEPARTED,"TRUCK",null,null,"WAREHOUSE_PICKUP","PALLET",null,null,null,null,null,
@@ -72,6 +72,38 @@ class DispatchDeliveryServiceTest {
verify(fees,times(3)).publish(any(),any(),any(),any(),isNull(),any(),eq("OP-A"),anyString());
}
/** PDA装车自动生成完整客户目的地,不猜派送计划、不要求人工费用或原费覆盖。 */
@Test void loadingAutomaticallyCreatesDocumentsWithoutInventedInputsOrFees() {
var pda=new Actor("PDA-1",ActorMode.PDA,"1001");
var fact=service.prepareForDeparture(context,pda,"LOADING-1");
assertEquals(2,fact.result().outbounds().size());assertEquals(3,fact.result().deliveries().size());
assertEquals(4,fact.result().recordVersion());
assertTrue(deliveries.stream().allMatch(d->d.getPlannedDeliveryAt()==null && d.getDeliveryMethod()==null
&& d.getPickupMethod()==null && d.getLoadingType()==null));
assertTrue(outbounds.stream().allMatch(o->o.getFeeCoverageJson()==null));
assertTrue(calls.indexOf("SOURCE")>calls.lastIndexOf("DELIVERY"));
verify(fees,never()).validate(any(),any(),any(),any());verify(fees,never()).coverage(any(),any(),any(),any());
var inputs=org.mockito.ArgumentCaptor.forClass(ArrangeGroup.class);
verify(fees,times(3)).publish(eq(context),any(),inputs.capture(),any(),isNull(),any(),eq("PDA-1"),anyString());
assertTrue(inputs.getAllValues().stream().allMatch(i->i.fees().isEmpty() && i.feeCoverage().isEmpty()));
clearInvocations(fees,repository);
assertEquals(fact,service.prepareForDeparture(context,pda,"LOADING-1"));
verifyNoInteractions(fees);verify(repository,never()).create(any(OutboundOrder.class));
assertThrows(org.springframework.web.server.ResponseStatusException.class,
()->service.prepareForDeparture(context,pda,"DIFFERENT-KEY"));
}
/** 自动生成仍拒绝越仓与孤立子单,不为异常半车补造第二套事实。 */
@Test void automaticPreparationRejectsWrongWarehouseAndOrphanDocuments() {
assertThrows(org.springframework.web.server.ResponseStatusException.class,
()->service.prepareForDeparture(context,new Actor("PDA-1",ActorMode.PDA,"OTHER"),"LOADING-1"));
verifyNoInteractions(repository,fees);
outbounds.add(new OutboundOrder());
assertThrows(org.springframework.web.server.ResponseStatusException.class,
()->service.prepareForDeparture(context,actor,"LOADING-1"));
verifyNoInteractions(fees);
}
/** 同实托的两申请分别冻结原货源,出库引用逐次归属且真实客户目的地与履约订单不被替换。 */
@Test void relabelOriginsStayFrozenBehindOutboundAttributionReferences() throws Exception {
var first=new SourceOrigin(10001L,201L,301L,401L,701L,"IN-701","CODE-701",null);
@@ -97,8 +97,6 @@ class PlatformDispatchOutboundContractTest {
String.class, DispatchCommands.Update.class);
Method cancel = DispatchAggregateController.class.getMethod("cancel", AuthenticatedUser.class,
String.class, DispatchCommands.ReasonCommand.class);
Method transfer = DeliveryTransferController.class.getMethod("transfer", AuthenticatedUser.class,
String.class, com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.ArrangeDelivery.class);
Method externalFreight = DeliveryTransferController.class.getMethod("externalFreight", AuthenticatedUser.class, String.class);
assertPermission(externalFreight, OverseasPermissions.DISPATCH_LIST);
Method outboundDetail = DeliveryTransferController.class.getMethod("outboundDetail",
@@ -113,11 +111,12 @@ class PlatformDispatchOutboundContractTest {
assertEquals(Set.of("/{dispatchNo}/cancel"), Set.of(cancelRoute.value()));
assertFalse(cancel.isAnnotationPresent(org.springframework.web.bind.annotation.DeleteMapping.class));
assertTrue(cancel.getParameters()[2].isAnnotationPresent(org.springframework.web.bind.annotation.RequestBody.class));
assertPermission(transfer, OverseasPermissions.DISPATCH_TRANSFER_DELIVERY);
assertTrue(Arrays.stream(DeliveryTransferController.class.getDeclaredMethods())
.noneMatch(method -> Set.of("transfer", "arrangement").contains(method.getName())));
assertPermission(outboundDetail, OverseasPermissions.OUTBOUND_LIST);
assertEquals(2, outboundDetail.getParameterCount());
assertPermission(outboundCancel, OverseasPermissions.OUTBOUND_CANCEL);
for (Method method : Set.of(update, cancel, transfer, externalFreight, outboundDetail, outboundCancel)) {
for (Method method : Set.of(update, cancel, externalFreight, outboundDetail, outboundCancel)) {
assertTrue(Arrays.stream(method.getParameters())
.noneMatch(parameter -> parameter.isAnnotationPresent(RequestParam.class)));
}
@@ -202,16 +201,13 @@ class PlatformDispatchOutboundContractTest {
assertFalse(schema.contains("uk_ll_outbound_no (customer_id,outbound_no)"));
}
/** 当前整车请求按公共版本一次提交全部组,响应保留多客户出库及多目的地派送数组。 */
/** 公开协议撤销人工安排入口,内部完整派送事实仍保留多客户和多目的地数组。 */
@Test
void openApiPublishesOnlyTheFrozenPlatformProtocol() throws Exception {
String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"),
StandardCharsets.UTF_8);
String dispatchAction = slice(yaml, " /api/platform/v1/last-leg/dispatches/{dispatchNo}:",
" /api/platform/v1/last-leg/dispatches/candidates:");
String transfer = slice(yaml,
" /api/platform/v1/last-leg/dispatches/{dispatchNo}/transfer-delivery:",
" /api/platform/v1/last-leg/outbound-orders:");
String outboundDetail = slice(yaml,
" /api/platform/v1/last-leg/outbound-orders/{outboundNo}:",
" /api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel:");
@@ -219,7 +215,7 @@ class PlatformDispatchOutboundContractTest {
" /api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel:",
" /api/platform/v1/last-leg/overseas-procurements:");
for (String action : Set.of(dispatchAction, transfer, outboundDetail, outboundCancel)) {
for (String action : Set.of(dispatchAction, outboundDetail, outboundCancel)) {
assertFalse(action.contains("name: customerId"));
}
assertTrue(outboundDetail.contains("pallets[].palletNo"));
@@ -228,24 +224,13 @@ class PlatformDispatchOutboundContractTest {
assertTrue(yaml.contains("单次 SQL 返回,不逐行补查"));
assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:edit"));
assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:cancel"));
assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:transfer-delivery"));
assertFalse(yaml.contains(" /api/platform/v1/last-leg/dispatches/{dispatchNo}/transfer-delivery:"));
assertFalse(yaml.contains(" /api/platform/v1/last-leg/dispatches/{dispatchNo}/delivery-arrangement:"));
assertTrue(yaml.contains("platform:overseas-warehouse:outbound:list"));
assertTrue(yaml.contains("platform:overseas-warehouse:outbound:create"));
assertTrue(yaml.contains("platform:overseas-warehouse:outbound:cancel"));
java.util.Map<?,?> document = new org.yaml.snakeyaml.Yaml().load(yaml);
java.util.Map<?,?> schemas = (java.util.Map<?,?>)((java.util.Map<?,?>)document.get("components")).get("schemas");
java.util.Map<?,?> arrange = (java.util.Map<?,?>)schemas.get("DispatchArrangeDelivery");
assertNotNull(arrange);
assertTrue(((java.util.List<?>)arrange.get("required")).containsAll(java.util.List.of("recordVersion", "idempotencyKey")));
java.util.Map<?,?> fields = (java.util.Map<?,?>)arrange.get("properties");
assertTrue(fields.keySet().containsAll(java.util.Set.of("recordVersion", "idempotencyKey", "cargoHash", "loadingCompletionHash", "groups")));
assertFalse(fields.containsKey("customerId"));
assertFalse(fields.containsKey("expectedVersion"));
java.util.Map<?,?> groups = (java.util.Map<?,?>)fields.get("groups");
assertEquals("array", groups.get("type"));
assertEquals("#/components/schemas/DispatchArrangeGroup", ((java.util.Map<?,?>)groups.get("items")).get("$ref"));
java.util.Map<?,?> groupFields = (java.util.Map<?,?>)((java.util.Map<?,?>)schemas.get("DispatchArrangeGroup")).get("properties");
assertTrue(groupFields.keySet().containsAll(java.util.Set.of("groupKey", "groupCargoHash", "fees", "feeCoverage")));
java.util.Map<?,?> resultFields = (java.util.Map<?,?>)((java.util.Map<?,?>)schemas.get("DispatchArrangementResult")).get("properties");
assertEquals("array", ((java.util.Map<?,?>)resultFields.get("outbounds")).get("type"));
assertEquals("array", ((java.util.Map<?,?>)resultFields.get("deliveries")).get("type"));
@@ -37,6 +37,14 @@ class HttpDispatchEvidenceGatewayTest {
assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",own),9,DispatchEvidenceGateway.Kind.VEHICLE,3L)).isInstanceOf(ResponseStatusException.class);
assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",own),9,DispatchEvidenceGateway.Kind.DRIVER,4L)).isInstanceOf(ResponseStatusException.class);
}
/** 司机照片使用独立本车类别且禁止PDF,不与驾驶证证据串用。 */
@Test void driverPhotoUsesDistinctKindAndImageOnly() {
var photo=new HttpDispatchEvidenceGateway.Metadata(3L,0L,"OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE","9","driver.png","image/png",128L);
assertThat(HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",photo),9,DispatchEvidenceGateway.Kind.DRIVER_PHOTO,3L).fileId()).isEqualTo(3L);
assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",photo),9,DispatchEvidenceGateway.Kind.DRIVER,3L)).isInstanceOf(ResponseStatusException.class);
var pdf=new HttpDispatchEvidenceGateway.Metadata(3L,0L,"OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE","9","driver.pdf","application/pdf",128L);
assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",pdf),9,DispatchEvidenceGateway.Kind.DRIVER_PHOTO,3L)).isInstanceOf(ResponseStatusException.class);
}
/** 客户、旧主档或缺失媒体类型均拒绝,不因为文件本身存在而放行。 */
@Test void rejectsForeignTenantMasterScopeAndMalformedMetadata() {
for(var value:java.util.List.of(
@@ -340,12 +340,12 @@ public class DispatchAggregatePersistenceTest {
assertSubmissionGate(f,created.dispatchNo(),ReviewState.DRAFT,true);
var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submission-states-1",null)));
assertSubmissionGate(f,created.dispatchNo(),ReviewState.DISPATCH_PENDING,false);
var rejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"submission-states-dispatch-rejected",Decision.REJECTED,"核对调度")));
var rejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"submission-states-dispatch-rejected",Decision.REJECTED,"核对调度",null)));
assertSubmissionGate(f,created.dispatchNo(),ReviewState.DISPATCH_REJECTED,true);
var resubmitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(rejected.recordVersion(),"submission-states-2",null)));
var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(resubmitted.recordVersion(),"submission-states-dispatch-approved",Decision.APPROVED,null)));
var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(resubmitted.recordVersion(),"submission-states-dispatch-approved",Decision.APPROVED,null,null)));
assertSubmissionGate(f,created.dispatchNo(),ReviewState.LOADING_PENDING,false);
var loadingRejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"submission-states-loading-rejected",Decision.REJECTED,"核对装载")));
var loadingRejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"submission-states-loading-rejected",Decision.REJECTED,"核对装载",null)));
assertSubmissionGate(f,created.dispatchNo(),ReviewState.LOADING_REJECTED,true);
f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(loadingRejected.recordVersion(),"submission-states-3",null)));
assertSubmissionGate(f,created.dispatchNo(),ReviewState.DISPATCH_PENDING,false);
@@ -363,13 +363,13 @@ public class DispatchAggregatePersistenceTest {
var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3);
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("review-create",List.of(source))));
var submit=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit-1",null)));
assertThatThrownBy(()->f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(submit.recordVersion(),"wrong-order",Decision.APPROVED,null)))).isInstanceOf(BizException.class);
var rejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submit.recordVersion(),"reject-1",Decision.REJECTED,"补核装载计划")));
assertThatThrownBy(()->f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(submit.recordVersion(),"wrong-order",Decision.APPROVED,null,null)))).isInstanceOf(BizException.class);
var rejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submit.recordVersion(),"reject-1",Decision.REJECTED,"补核装载计划",null)));
var resubmitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(rejected.recordVersion(),"submit-2",null)));
var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(resubmitted.recordVersion(),"dispatch-approved",Decision.APPROVED,null)));
var loading=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"loading-approved",Decision.APPROVED,null)));
var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(resubmitted.recordVersion(),"dispatch-approved",Decision.APPROVED,null,null)));
var loading=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"loading-approved",Decision.APPROVED,null,null)));
assertThat(loading.stage()).isEqualTo(Stage.LOADING);
var replay=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submit.recordVersion(),"reject-1",Decision.REJECTED,"补核装载计划")));
var replay=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submit.recordVersion(),"reject-1",Decision.REJECTED,"补核装载计划",null)));
assertThat(replay.replayed()).isTrue();assertThat(replay.stage()).isEqualTo(Stage.BEFORE_LOADING);assertThat(replay.recordVersion()).isEqualTo(rejected.recordVersion());
var returned=f.tx.execute(s->f.service.returnToReview(f.actor,created.dispatchNo(),new DispatchCommands.ReturnToReview(loading.recordVersion(),"return-zero","重新核实资源")));
assertThat(returned.stage()).isEqualTo(Stage.BEFORE_LOADING);
@@ -419,12 +419,13 @@ public class DispatchAggregatePersistenceTest {
assertThat(f.db.queryForList("SELECT DISTINCT customer_id FROM ll_dispatch_item ORDER BY customer_id",String.class)).containsExactly("101","202");
assertThat(f.db.queryForObject("SELECT COUNT(DISTINCT dispatch_order_id) FROM ll_dispatch_item",Long.class)).isOne();
assertThat(f.db.queryForObject("SELECT package_count FROM ll_fleet_trip_dispatch",Integer.class)).isEqualTo(8);
var query=new DispatchViews.Query(1,20,null,null,null,"101",null,StatusFilter.ACTIVE,Stage.BEFORE_LOADING,BeforeLoadingSection.DISPATCH_REVIEW,ReviewStatus.NOT_SUBMITTED);
var query=new DispatchViews.Query(1,20,null,null,null,"101",null,StatusFilter.ACTIVE,Stage.BEFORE_LOADING,BeforeLoadingSection.VEHICLE_INFO,null);
var page=f.tx.execute(s->f.service.page(f.actor,query));
assertThat(page.total()).isOne(); assertThat(page.records()).hasSize(1);
assertThat(page.records().get(0).counts().packageCount()).isEqualTo(8);
assertThat(page.statistics().activeTotal()).isOne(); assertThat(page.statistics().stageCounts().get(Stage.BEFORE_LOADING)).isOne();
assertThat(page.statistics().beforeLoadingReviewCounts().dispatchReview().notSubmitted()).isOne();
assertThat(page.statistics().beforeLoadingReviewCounts().vehicleInfoTotal()).isOne();
assertThat(page.statistics().beforeLoadingReviewCounts().dispatchReview().notSubmitted()).isZero();
long dispatch=f.db.queryForObject("SELECT id FROM ll_dispatch_order",Long.class);
long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE customer_id='101'",Long.class);
@@ -597,8 +598,8 @@ public class DispatchAggregatePersistenceTest {
/** 通过实际两级流程进入装车,测试不直接伪造通过状态。 */
public DispatchContracts.CommandResult approve(DispatchContracts.CommandResult created) {
var submitted=tx.execute(s->service.submitReview(actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),created.dispatchNo()+"-submit",null)));
var reviewed=tx.execute(s->service.review(actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),created.dispatchNo()+"-dispatch",Decision.APPROVED,null)));
return tx.execute(s->service.review(actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(reviewed.recordVersion(),created.dispatchNo()+"-loading",Decision.APPROVED,null)));
var reviewed=tx.execute(s->service.review(actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),created.dispatchNo()+"-dispatch",Decision.APPROVED,null,null)));
return tx.execute(s->service.review(actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(reviewed.recordVersion(),created.dispatchNo()+"-loading",Decision.APPROVED,null,null)));
}
/** 只为实载端口夹具准备真实扫码/归属记录,不声称运行库存命令。 */
public void picked(long source,String customer,long pallet,int packages) {
@@ -0,0 +1,144 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.mapper.*;
import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.repository.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.AfterEach;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.*;
import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
/** 装完即发车的共同事务、原键最终结果及跨端竞争使用真实根单据和版本。 */
class DispatchLoadingDeparturePersistenceTest {
/** 每例恢复隔离身份,不影响其他服务权限测试。 */
@AfterEach void clearSecurity(){SecurityContextHolder.clearContext();}
/** 一次确认完成全部客户发运;原键重放返回派送中,不再生成子单或执行来源。 */
@Test void loadingCompletesAllCustomersAndReplaysOnlyFinalResult() throws Exception {
authorize();var f=new Fixture();
var result=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete")));
assertThat(result.action()).isEqualTo(Action.LOADING_COMPLETE);assertThat(result.stage()).isEqualTo(Stage.DELIVERING);
assertThat(result.counts().shippedPackageCount()).isEqualTo(8);assertThat(result.result()).isInstanceOf(DispatchResults.Loaded.class);
assertThat(f.f.db.queryForList("SELECT status FROM ll_outbound_order",String.class)).containsExactly("OUTBOUNDED","OUTBOUNDED");
assertThat(f.f.db.queryForList("SELECT status FROM ll_delivery_order",String.class)).containsExactly("DELIVERING","DELIVERING");
assertThat(f.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("IN_TRANSIT");
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isEqualTo(2);
assertThat(f.f.db.queryForObject("SELECT result_json FROM ll_dispatch_command WHERE idempotency_key='complete'",String.class)).contains("DELIVERING");
var replay=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete")));
assertThat(replay.replayed()).isTrue();assertThat(replay.recordVersion()).isEqualTo(result.recordVersion());assertThat(replay.result()).isEqualTo(result.result());
assertThat(f.f.tx.execute(s->f.f.service.command(f.f.actor,f.number,"complete")).stage()).isEqualTo(Stage.DELIVERING);
verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any());
assertThat(f.f.service.actions(f.f.actor,f.f.repository.find(f.number))).doesNotContainKeys(Action.ARRANGE_DELIVERY,Action.DEPART);
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("stale-new")))).hasMessageContaining("版本或审核阶段");
}
/** 任一后续步骤失败都恢复装车中,已提交的真实拣货保持,不留下中间装车或子单。 */
@Test void preparationAndDepartureFailuresRollBackTheEntireLoadingCommand() throws Exception {
authorize();
for(boolean failPreparation:List.of(true,false)) {
var f=new Fixture();
if(failPreparation)doThrow(new IllegalStateException("模拟单据准备失败")).when(f.delivery).prepareForDeparture(any(),any(),anyString());
else doThrow(new IllegalStateException("模拟来源发运失败")).when(f.sourceWorkflow).departWholeSources(any(),any(),any(),any());
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("failed")))).isInstanceOf(IllegalStateException.class);
var row=f.f.repository.find(f.number);assertThat(row.getStage()).isEqualTo("LOADING");assertThat(row.getRecordVersion()).isEqualTo(f.version);
assertThat(row.getLoadingCompletionHash()).isNull();assertThat(row.getArrangementKey()).isNull();assertThat(row.getDepartedAt()).isNull();
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order",Long.class)).isZero();
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Long.class)).isZero();
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE action IN ('LOADING_COMPLETE','ARRANGE_DELIVERY','DEPART')",Long.class)).isZero();
assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isEqualTo(2);
assertThat(f.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED");
}
}
/** PDA保持本仓权限、原键权限复验;不借自动流程绕开真实装载清单。 */
@Test void pdaUsesOnePermissionButRejectsOtherWarehouseAndInvalidEvidence() throws Exception {
authorize();var f=new Fixture();var pda=new Actor("picker",ActorMode.PDA,"9");
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(new Actor("picker",ActorMode.PDA,"99"),f.number,f.request("foreign")))).hasMessageContaining("当前绑定仓");
var bad=new DispatchCommands.LoadingComplete(f.version,"bad-manifest","changed",f.measurements,null);
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(pda,f.number,bad))).hasMessageContaining("真实装载清单已变化");
verify(f.delivery,never()).prepareForDeparture(any(),any(),anyString());
var result=f.f.tx.execute(s->f.service.complete(pda,f.number,f.request("pda")));assertThat(result.stage()).isEqualTo(Stage.DELIVERING);
SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("picker","","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(pda,f.number,f.request("pda")))).hasMessageContaining("装车完成权限");
}
/** 同一版本后台/PDA同时确认只有一条完整链提交,不会产生半车或重复派送。 */
@Test void concurrentAdminAndPdaCompleteOnlyOneDeparture() throws Exception {
authorize();var f=new Fixture();var start=new CountDownLatch(1);var workers=Executors.newFixedThreadPool(2);
try {
var a=workers.submit(()->f.tryComplete(start,f.f.actor,"admin"));
var b=workers.submit(()->f.tryComplete(start,new Actor("picker",ActorMode.PDA,"9"),"pda"));
start.countDown();assertThat(List.of(a.get(25,TimeUnit.SECONDS),b.get(25,TimeUnit.SECONDS))).containsExactlyInAnyOrder(true,false);
} finally {workers.shutdownNow();}
assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("DELIVERING");
verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any());
}
/** 仅配置装车完成及PDA既有角色权限,不授予人工派送、发车权限。 */
private static void authorize(){SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("operator","",
"platform:overseas-warehouse:dispatch:loading-complete","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));}
/** 主流程与发车使用真实SQL,派送提供者受控写入真实子单来验证共同回滚。 */
private static final class Fixture {
final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();
final DispatchDeliveryPort delivery=mock(DispatchDeliveryPort.class);
final DispatchPdaWorkflowService sourceWorkflow=mock(DispatchPdaWorkflowService.class);
final DispatchLoadingDepartureService service;final String number;final long version;final String manifest;
final List<LoadingMeasurement> measurements;
/** 创建两客户货物并走真实两级审核、真实拣货归属,不伪造已装车状态。 */
Fixture() throws Exception {
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("create",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"202",502,"LAX9",5)))));
number=created.dispatchNo();version=f.approve(created).recordVersion();
f.picked(1,"101",501,3);f.picked(2,"202",502,5);f.db.update("UPDATE ll_dispatch_order SET picked_package_count=8");
f.db.execute("ALTER TABLE ll_order ADD detained INT DEFAULT 0");
var preview=f.tx.execute(s->f.service.loading(f.actor,number));manifest=preview.manifestHash();
measurements=preview.pallets().stream().map(p->new LoadingMeasurement(p.warehousePalletId(),1,BigDecimal.ONE,BigDecimal.ONE,p.deliveryIndex())).toList();
when(delivery.prepareForDeparture(any(),any(),anyString())).thenAnswer(call->{
Context c=call.getArgument(0);assertThat(c.stage()).isEqualTo(Stage.AFTER_LOADING);
var os=outbounds("WAIT_OUTBOUND");var ds=deliveries(c,"WAIT_DELIVERY");
for(int n=0;n<os.size();n++) {
var o=os.get(n);var d=ds.get(n);
f.db.update("INSERT INTO ll_outbound_order(id,customer_id,dispatch_order_id,outbound_no,status,package_count)VALUES(?,?,?,?,?,?)",6001+n,o.customerId(),c.dispatchId(),o.outboundNo(),o.status(),o.packageCount());
f.db.update("INSERT INTO ll_delivery_order(id,customer_id,dispatch_order_id,outbound_order_id,outbound_no,delivery_no,status,package_count)VALUES(?,?,?,?,?,?,?,?)",7001+n,o.customerId(),c.dispatchId(),6001+n,o.outboundNo(),d.deliveryNo(),d.status(),d.packageCount());
}
return new ArrangementFact("AUTO-ARR","business-hash",c.cargoHash(),c.loadingCompletionHash(),new ArrangementResult(number,c.recordVersion()+1,"AUTO-ARR",c.cargoHash(),Instant.now(),os,ds));
});
when(delivery.departureFacts(any(),eq(true))).thenAnswer(call->{
Context c=call.getArgument(0);f.db.queryForList("SELECT id FROM ll_outbound_order ORDER BY id FOR UPDATE");f.db.queryForList("SELECT id FROM ll_delivery_order ORDER BY id FOR UPDATE");
return new DepartureFacts(number,"AUTO-ARR",c.cargoHash(),c.loadingCompletionHash(),"business-hash",outbounds("WAIT_OUTBOUND"),deliveries(c,"WAIT_DELIVERY"),c.cargo(),new Gate(true,null,null));
});
when(delivery.markDeparted(any(),any(),any(),anyString())).thenAnswer(call->{
Context c=call.getArgument(0);f.db.update("UPDATE ll_outbound_order SET status='OUTBOUNDED'");f.db.update("UPDATE ll_delivery_order SET status='DELIVERING'");
return new DepartureResult(outbounds("OUTBOUNDED"),deliveries(c,"DELIVERING"),8);
});
var ledger=new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json));
var hashes=new DispatchCommandHasher(f.json);
var sources=new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class));
var departure=new DispatchDepartureService(f.service,f.repository,sources,delivery,sourceWorkflow,f.fleet,ledger,hashes,f.json);
service=new DispatchLoadingDepartureService(f.service,delivery,departure,ledger,hashes);
}
/** 每次请求沿用最初真实测量和版本,便于验证竞争及原键恢复。 */
DispatchCommands.LoadingComplete request(String key){return new DispatchCommands.LoadingComplete(version,key,manifest,measurements,"实装发车");}
/** 两客户子出库保持独立业务号和箱数。 */
List<OutboundReference> outbounds(String status){return List.of(new OutboundReference("101","客户101","OUT-A",status,3,1),new OutboundReference("202","客户202","OUT-B",status,5,1));}
/** 两目的地仍对应各自真实客户,不把整车合并为单客户。 */
List<DeliveryReference> deliveries(Context c,String status){return List.of(new DeliveryReference("GA","101","客户101","OUT-A","DL-A",c.cargo().get(0).destination(),status,3,1),new DeliveryReference("GB","202","客户202","OUT-B","DL-B",c.cargo().get(1).destination(),status,5,1));}
/** 各线程独立身份和数据库事务竞争同一整车父锁。 */
boolean tryComplete(CountDownLatch start,Actor actor,String key) throws Exception {
authorize();try {start.await(5,TimeUnit.SECONDS);f.tx.execute(s->service.complete(actor,number,request(key)));return true;}
catch(com.baiyue.common.core.exception.BizException expected){return false;}
finally {SecurityContextHolder.clearContext();}
}
}
}
@@ -13,6 +13,32 @@ import static org.assertj.core.api.Assertions.*;
/** 统一PDA读取实际多客户主单和逐项扫码,仓限制在SQL完成。 */
class DispatchPdaReadPersistenceTest {
/** 默认PDA待办包含已通过调度的待理货整车,资源取同一冻结快照。 */
@Test void pendingTallyReviewIsVisibleWithResourcesButDraftAndRejectedAreNot() throws Exception {
var security=org.springframework.security.core.context.SecurityContextHolder.getContext();var previous=security.getAuthentication();
security.setAuthentication(new org.springframework.security.authentication.TestingAuthenticationToken("picker","","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));
try {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("pending-read",List.of(f.source(1,"101",501,"ONT8",3)))));
f.db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(64),balance_id BIGINT,pallet_id BIGINT,overseas_warehouse_id VARCHAR(64),available_packages BIGINT,hold_packages BIGINT,allocated_packages BIGINT,picked_packages BIGINT)");
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(DispatchWholePdaReadMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(f.db.getDataSource());factory.setConfiguration(config);factory.afterPropertiesSet();
var queries=new DispatchPdaQueryService(new DispatchPdaReadRepository(new SqlSessionTemplate(factory.getObject()).getMapper(DispatchWholePdaReadMapper.class)),f.service);
var actor=new DispatchContracts.Actor("picker",ActorMode.PDA,"9");
assertThat(f.tx.<List<DispatchViews.PdaTask>>execute(s->queries.list(actor,null))).isEmpty();
var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit",null)));
assertThat(f.tx.<List<DispatchViews.PdaTask>>execute(s->queries.list(actor,null))).isEmpty();
var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"dispatch",Decision.APPROVED,null,null)));
var list=f.tx.execute(s->queries.list(actor,null));assertThat(list).hasSize(1);
var task=list.get(0);assertThat(task.stage()).isEqualTo(Stage.BEFORE_LOADING);
assertThat(task.actions().get(Action.LOADING_REVIEW).allowed()).isTrue();assertThat(task.actions().get(Action.PICK).allowed()).isFalse();
assertThat(task.resources().vehicle().plate()).isEqualTo("TEST-TRUCK");assertThat(task.resources().driver().name()).isEqualTo("测试司机");
f.tx.execute(s->f.service.review(actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"tally-reject",Decision.REJECTED,"车辆不符",null)));
assertThat(f.tx.<List<DispatchViews.PdaTask>>execute(s->queries.list(actor,null))).isEmpty();
assertThat(f.tx.execute(s->queries.task(actor,created.dispatchNo())).reviews().loadingReview().reason()).isEqualTo("车辆不符");
} finally {security.setAuthentication(previous);}
}
/** 同仓两个客户、两个目的地只返回一个整车作业,别仓不能借单号读取。 */
@Test void readsOneTruckAcrossCustomersAndRejectsOtherWarehouseNumber() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();var a=f.source(1,"101",501,"ONT8",3);var b=f.source(2,"202",502,"LAX9",5);
@@ -0,0 +1,104 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway;
import com.baiyue.lastleg.dispatch.port.*;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** 审核附件以真实Mapper保存并随审核轮次流转,不能改写车辆和司机资源。 */
class DispatchReviewAttachmentsPersistenceTest {
/** 两级附件随决定保存,理货驳回与客服改货保留证据,重提清当前但历史不丢。 */
@Test void attachmentsSurviveRejectionAndEditsUntilResubmission() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("review-evidence",List.of(f.source(1,"101",501,"ONT8",3)))));
var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit",null)));
var before=f.repository.find(created.dispatchNo()).getResourcesJson();
var first=new DispatchContracts.ReviewAttachments(List.of(91L),List.of(92L),List.of(93L));
var request=new DispatchCommands.ReviewCommand(submitted.recordVersion(),"dispatch",Decision.APPROVED,null,first);
var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,request));
assertThat(f.service.detail(f.actor,created.dispatchNo()).reviews().dispatchReview().attachments()).isEqualTo(first);
assertThat(f.repository.find(created.dispatchNo()).getResourcesJson()).isEqualTo(before);
var second=new DispatchContracts.ReviewAttachments(List.of(94L),List.of(95L),List.of(96L));
var rejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"reject",Decision.REJECTED,"照片不清晰",second)));
var reviews=f.service.detail(f.actor,created.dispatchNo()).reviews();
assertThat(reviews.dispatchReview().status()).isEqualTo(ReviewStatus.NOT_SUBMITTED);
assertThat(reviews.dispatchReview().attachments()).isEqualTo(first);
assertThat(reviews.loadingReview().attachments()).isEqualTo(second);
var changed=f.tx.execute(s->f.service.addCargo(f.actor,created.dispatchNo(),new DispatchCommands.AddCargo(rejected.recordVersion(),"cargo",List.of(f.source(2,"101",502,"ONT8",2)),List.of())));
reviews=f.service.detail(f.actor,created.dispatchNo()).reviews();
assertThat(reviews.dispatchReview().attachments()).isEqualTo(first);
assertThat(reviews.loadingReview().attachments()).isEqualTo(second);
assertThat(reviews.loadingReview().reason()).isEqualTo("照片不清晰");
f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(changed.recordVersion(),"resubmit",null)));
reviews=f.service.detail(f.actor,created.dispatchNo()).reviews();
assertThat(reviews.dispatchReview().attachments()).isEqualTo(DispatchContracts.ReviewAttachments.empty());
assertThat(reviews.loadingReview().attachments()).isEqualTo(DispatchContracts.ReviewAttachments.empty());
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_history WHERE snapshot_json LIKE '%driverPhotoFileIds%92%'",Long.class)).isPositive();
assertThat(f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,request)).replayed()).isTrue();
assertThatThrownBy(()->f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"dispatch",Decision.APPROVED,null,second)))).hasMessageContaining("原请求标识");
}
/** 拒绝也逐文件核验本车分类;错误归属不提交状态和版本。 */
@Test void rejectsForeignKindOrMasterLicenseAndKeepsOriginalVersion() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("verify-evidence",List.of(f.source(1,"101",501,"ONT8",3)))));
var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit",null)));
var gateway=(DispatchEvidenceGateway)ReflectionTestUtils.getField(f.service,"evidence");
long id=f.repository.find(created.dispatchNo()).getId();
var attachments=new DispatchContracts.ReviewAttachments(List.of(91L),List.of(92L),List.of(93L));
doThrow(new IllegalArgumentException("本车分类归属不匹配")).when(gateway).metadata(id,DispatchEvidenceGateway.Kind.DRIVER,93L);
assertThatThrownBy(()->f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"bad",Decision.REJECTED,"需补资料",attachments)))).hasMessageContaining("归属不匹配");
verify(gateway).metadata(id,DispatchEvidenceGateway.Kind.VEHICLE,91L);
verify(gateway).metadata(id,DispatchEvidenceGateway.Kind.DRIVER_PHOTO,92L);
verify(gateway).metadata(id,DispatchEvidenceGateway.Kind.DRIVER,93L);
assertThat(f.repository.find(created.dispatchNo()).getRecordVersion()).isEqualTo(submitted.recordVersion());
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='bad'",Long.class)).isZero();
}
/** 只有当前待审角色能上传;维护权限不能替代审核资格,上传不变更版本和依据。 */
@Test void uploadUsesCurrentReviewAuthorityWithoutEditAndDoesNotMutateAggregate() throws Exception {
var security=SecurityContextHolder.getContext();var prior=security.getAuthentication();
try {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("upload-evidence",List.of(f.source(1,"101",501,"ONT8",3)))));
var file=new MockMultipartFile("file","driver.png","image/png",new byte[]{1});
var kind=DispatchEvidenceGateway.Kind.DRIVER_PHOTO;
security.setAuthentication(new TestingAuthenticationToken("operator","","platform:fleet:trip:dispatch-review"));
assertThatThrownBy(()->f.service.uploadEvidence(f.actor,created.dispatchNo(),kind,file)).hasMessageContaining("无当前阶段");
var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit",null)));
var before=f.repository.find(created.dispatchNo());
f.service.uploadEvidence(f.actor,created.dispatchNo(),kind,file);
security.setAuthentication(new TestingAuthenticationToken("operator","","platform:overseas-warehouse:dispatch:edit"));
assertThatThrownBy(()->f.service.uploadEvidence(f.actor,created.dispatchNo(),kind,file)).hasMessageContaining("无当前阶段");
var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"approve",Decision.APPROVED,null,null)));
security.setAuthentication(new TestingAuthenticationToken("operator","","platform:fleet:trip:dispatch-review"));
assertThatThrownBy(()->f.service.uploadEvidence(f.actor,created.dispatchNo(),kind,file)).hasMessageContaining("无当前阶段");
security.setAuthentication(new TestingAuthenticationToken("operator","","platform:fleet:trip:loading-review"));
f.service.uploadEvidence(f.actor,created.dispatchNo(),kind,file);
var after=f.repository.find(created.dispatchNo());
assertThat(after.getRecordVersion()).isEqualTo(approved.recordVersion());
assertThat(after.getReviewBasisHash()).isEqualTo(before.getReviewBasisHash());
assertThat(after.getResourcesJson()).isEqualTo(before.getResourcesJson());
var loaded=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"load",Decision.APPROVED,null,null)));
assertThatThrownBy(()->f.service.uploadEvidence(f.actor,created.dispatchNo(),kind,file)).hasMessageContaining("装车前");
} finally {security.setAuthentication(prior);}
}
/** 省略附件保持PDA原输入语义,非法或过多引用在进入业务前拒绝。 */
@Test void optionalAttachmentShapeIsBoundedAndNormalized() throws Exception {
var json=new com.fasterxml.jackson.databind.ObjectMapper();
var request=json.readValue("{\"recordVersion\":1,\"idempotencyKey\":\"pda\",\"decision\":\"APPROVED\"}",DispatchCommands.ReviewCommand.class);
assertThat(request.attachments()).isEqualTo(DispatchContracts.ReviewAttachments.empty());
assertThatThrownBy(()->new DispatchContracts.ReviewAttachments(List.of(1L,1L),null,null)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(()->new DispatchContracts.ReviewAttachments(null,List.of(-1L),null)).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(()->new DispatchContracts.ReviewAttachments(null,null,java.util.stream.LongStream.rangeClosed(1,21).boxed().toList())).isInstanceOf(IllegalArgumentException.class);
}
}
@@ -0,0 +1,126 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.lastleg.dispatch.port.*;
import org.junit.jupiter.api.Test;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.List;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
import static org.assertj.core.api.Assertions.*;
/** 审核两端共用真实持久主单,驳回回客服且不能复用上一轮批准。 */
class DispatchReviewWorkflowPersistenceTest {
/** 客服、调度、理货队列互斥,任一级驳回都只回客服队列。 */
@Test void eachReviewRejectionReturnsOnlyToCustomerServiceQueue() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("queues",List.of(f.source(1,"101",501,"ONT8",3)))));
assertQueue(f,created.dispatchNo(),BeforeLoadingSection.VEHICLE_INFO);
var submitted=submit(f,created,"submit-1");assertQueue(f,created.dispatchNo(),BeforeLoadingSection.DISPATCH_REVIEW);
var rejected=review(f,f.actor,submitted,false,"dispatch-reject",Decision.REJECTED," 车辆图片不清楚 ");
assertQueue(f,created.dispatchNo(),BeforeLoadingSection.VEHICLE_INFO);
assertThat(f.service.detail(f.actor,created.dispatchNo()).reviews().dispatchReview().reason()).isEqualTo("车辆图片不清楚");
var resubmitted=submit(f,rejected,"submit-2");
var approved=review(f,f.actor,resubmitted,false,"dispatch-approve",Decision.APPROVED,null);
assertQueue(f,created.dispatchNo(),BeforeLoadingSection.LOADING_REVIEW);
var tallyRejected=review(f,f.actor,approved,true,"loading-reject",Decision.REJECTED,"司机不符");
assertQueue(f,created.dispatchNo(),BeforeLoadingSection.VEHICLE_INFO);
var detail=f.service.detail(f.actor,created.dispatchNo());
assertThat(detail.reviews().loadingReview().reason()).isEqualTo("司机不符");
assertThat(detail.reviews().dispatchReview().status()).isEqualTo(ReviewStatus.NOT_SUBMITTED);
assertThatThrownBy(()->review(f,f.actor,tallyRejected,true,"cannot-skip-dispatch",Decision.APPROVED,null)).hasMessageContaining("状态已变化");
var third=submit(f,tallyRejected,"submit-3");assertQueue(f,created.dispatchNo(),BeforeLoadingSection.DISPATCH_REVIEW);
detail=f.service.detail(f.actor,created.dispatchNo());
assertThat(detail.reviews().dispatchReview().reviewRound()).isEqualTo(3);
assertThat(detail.reviews().loadingReview().reason()).isNull();
assertThatThrownBy(()->review(f,f.actor,third,true,"cannot-skip-again",Decision.APPROVED,null)).hasMessageContaining("状态已变化");
}
/** 两级拒绝都须真实原因;失败不写版本、审核历史或命令结果。 */
@Test void blankRejectionReasonIsRejectedAtBothLevelsWithoutWrites() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("reason",List.of(f.source(1,"101",501,"ONT8",3)))));
var submitted=submit(f,created,"submit");
assertThatThrownBy(()->review(f,f.actor,submitted,false,"blank-dispatch",Decision.REJECTED," \n ")).hasMessageContaining("驳回原因不能为空");
assertThat(f.repository.find(created.dispatchNo()).getRecordVersion()).isEqualTo(submitted.recordVersion());
var approved=review(f,f.actor,submitted,false,"approve",Decision.APPROVED,null);
assertThatThrownBy(()->review(f,f.actor,approved,true,"blank-loading",Decision.REJECTED,null)).hasMessageContaining("驳回原因不能为空");
assertThat(f.repository.find(created.dispatchNo()).getRecordVersion()).isEqualTo(approved.recordVersion());
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key LIKE 'blank-%'",Long.class)).isZero();
}
/** 客服多次改车货保留本轮驳回原因,重新提交才开始全新审核轮次。 */
@Test void cargoChangesKeepRejectionReasonUntilNextSubmission() throws Exception {
for(boolean loading:List.of(false,true)) {
var f=new DispatchAggregatePersistenceTest.Fixture();
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("retain",List.of(f.source(1,"101",501,"ONT8",3)))));
var current=submit(f,created,"submit");
if(loading)current=review(f,f.actor,current,false,"approve",Decision.APPROVED,null);
var rejected=review(f,f.actor,current,loading,"reject",Decision.REJECTED,"请重新核对货物");
var added=f.tx.execute(s->f.service.addCargo(f.actor,created.dispatchNo(),new DispatchCommands.AddCargo(rejected.recordVersion(),"add",List.of(f.source(2,"101",502,"ONT8",2)),List.of())));
var addedAgain=f.tx.execute(s->f.service.addCargo(f.actor,created.dispatchNo(),new DispatchCommands.AddCargo(added.recordVersion(),"add-again",List.of(f.source(3,"101",503,"ONT8",1)),List.of())));
long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE plan_pallet_id=3",Long.class);
var removed=f.tx.execute(s->f.service.removeCargo(f.actor,created.dispatchNo(),item,new DispatchCommands.ReasonCommand(addedAgain.recordVersion(),"remove","调整目的仓")));
var resources=f.configured("updated-resources",List.of());
var updated=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(removed.recordVersion(),"update-vehicle",resources.vehicle(),resources.driver(),resources.plannedDepartureAt().plusSeconds(60),null,"客服更正")));
var detail=f.service.detail(f.actor,created.dispatchNo());
var retained=loading?detail.reviews().loadingReview():detail.reviews().dispatchReview();
assertThat(detail.reviewState()).isEqualTo(ReviewState.DRAFT);
assertThat(retained.status()).isEqualTo(ReviewStatus.REJECTED);assertThat(retained.reason()).isEqualTo("请重新核对货物");
assertThat(retained.basisHash()).isNull();assertQueue(f,created.dispatchNo(),BeforeLoadingSection.VEHICLE_INFO);
submit(f,updated,"resubmit");detail=f.service.detail(f.actor,created.dispatchNo());
assertThat(detail.reviews().dispatchReview().status()).isEqualTo(ReviewStatus.PENDING);
assertThat(detail.reviews().dispatchReview().reason()).isNull();assertThat(detail.reviews().loadingReview().reason()).isNull();
}
}
/** PDA理货共享后台版本;成功恢复不重复写,越仓、失权和替换身份均拒绝。 */
@Test void pdaReviewSharesVersionAndOriginalRecoveryWithAdmin() throws Exception {
var security=SecurityContextHolder.getContext();var previous=security.getAuthentication();
try {
security.setAuthentication(new TestingAuthenticationToken("picker","","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));
var f=new DispatchAggregatePersistenceTest.Fixture();
var pda=new DispatchContracts.Actor("picker",ActorMode.PDA,"9");
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("pda-review",List.of(f.source(1,"101",501,"ONT8",3)))));
var submitted=submit(f,created,"submit");
assertThat(f.service.actions(pda,f.repository.find(created.dispatchNo())).get(Action.LOADING_REVIEW).allowed()).isFalse();
assertThatThrownBy(()->review(f,pda,submitted,false,"pda-dispatch",Decision.APPROVED,null)).hasMessageContaining("仅当前绑定仓");
var approved=review(f,f.actor,submitted,false,"dispatch",Decision.APPROVED,null);
assertThat(f.service.actions(pda,f.repository.find(created.dispatchNo())).get(Action.LOADING_REVIEW).allowed()).isTrue();
assertThatThrownBy(()->review(f,new DispatchContracts.Actor("picker",ActorMode.PDA,"99"),approved,true,"wrong-warehouse",Decision.APPROVED,null)).hasMessageContaining("当前绑定仓");
var loaded=review(f,pda,approved,true,"pda-approve",Decision.APPROVED,null);
assertThat(loaded.stage()).isEqualTo(Stage.LOADING);
assertThatThrownBy(()->review(f,f.actor,approved,true,"admin-stale",Decision.APPROVED,null)).hasMessageContaining("版本已变化");
assertThat(review(f,pda,approved,true,"pda-approve",Decision.APPROVED,null).replayed()).isTrue();
assertThat(f.tx.execute(s->f.service.command(pda,created.dispatchNo(),"pda-approve")).replayed()).isTrue();
assertThatThrownBy(()->f.tx.execute(s->f.service.command(new DispatchContracts.Actor("picker",ActorMode.PDA,"99"),created.dispatchNo(),"pda-approve"))).hasMessageContaining("当前绑定仓");
assertThatThrownBy(()->f.tx.execute(s->f.service.command(new DispatchContracts.Actor("other",ActorMode.PDA,"9"),created.dispatchNo(),"pda-approve"))).hasMessageContaining("无权");
security.setAuthentication(new TestingAuthenticationToken("picker","","lastleg:pda:operate"));
assertThatThrownBy(()->review(f,pda,approved,true,"pda-approve",Decision.APPROVED,null)).hasMessageContaining("仅当前绑定仓");
assertThatThrownBy(()->f.tx.execute(s->f.service.command(pda,created.dispatchNo(),"pda-approve"))).hasMessageContaining("原动作权限");
assertThat(f.repository.find(created.dispatchNo()).getRecordVersion()).isEqualTo(loaded.recordVersion());
} finally {security.setAuthentication(previous);}
}
/** 使用服务真实SQL分页和全量统计验证当前队列,不以返回动作猜测队列。 */
private static void assertQueue(DispatchAggregatePersistenceTest.Fixture f,String number,BeforeLoadingSection expected) {
for(var section:BeforeLoadingSection.values()) {
var page=f.tx.execute(s->f.service.page(f.actor,new DispatchViews.Query(1,20,number,null,null,null,null,StatusFilter.ACTIVE,Stage.BEFORE_LOADING,section,null)));
assertThat(page.total()).isEqualTo(section==expected?1:0);
var counts=page.statistics().beforeLoadingReviewCounts();
assertThat(counts.vehicleInfoTotal()).isEqualTo(expected==BeforeLoadingSection.VEHICLE_INFO?1:0);
assertThat(counts.dispatchReview().pending()).isEqualTo(expected==BeforeLoadingSection.DISPATCH_REVIEW?1:0);
assertThat(counts.loadingReview().pending()).isEqualTo(expected==BeforeLoadingSection.LOADING_REVIEW?1:0);
}
}
/** 每次提交以真实返回版本开始下一轮,不人工造审核状态。 */
private static DispatchContracts.CommandResult submit(DispatchAggregatePersistenceTest.Fixture f,DispatchContracts.CommandResult prior,String key) {
return f.tx.execute(s->f.service.submitReview(f.actor,prior.dispatchNo(),new DispatchCommands.SubmitReview(prior.recordVersion(),key,null)));
}
/** 通过同一审核服务分别调用两端,版本和幂等键始终由请求明确提供。 */
private static DispatchContracts.CommandResult review(DispatchAggregatePersistenceTest.Fixture f,DispatchContracts.Actor actor,DispatchContracts.CommandResult prior,boolean loading,String key,Decision decision,String reason) {
return f.tx.execute(s->f.service.review(actor,prior.dispatchNo(),loading,new DispatchCommands.ReviewCommand(prior.recordVersion(),key,decision,reason,null)));
}
}
@@ -20,6 +20,7 @@ final class OwnedBusinessFixtures {
/** 装车完成但未发车,证明不依赖DEPART历史。 */
static Source source() {
var row=new Source();row.setId(1L);row.setDispatchNo("DS-OWN-ONE");row.setStatus("ACTIVE");row.setStage("AFTER_LOADING");
row.setPooledCargo(false);
row.setRecordVersion(8L);row.setOverseasWarehouseId("9");row.setOverseasWarehouseCode("WH-9");row.setOverseasWarehouseName("测试仓");
row.setPlannedDepartureAt(LocalDateTime.parse("2026-10-20T08:00:00"));row.setLoadingCompletedAt(LocalDateTime.parse("2026-10-20T07:30:00"));
row.setLoadingCompletionHash("actual-load");row.setOwnedLoadingCompletionHash("actual-load");row.setOwnedLoadingCompletedAt(row.getLoadingCompletedAt());
@@ -50,8 +50,12 @@ class OwnedBusinessPersistenceTest {
customer_name VARCHAR(128),source_key VARCHAR(128),order_no VARCHAR(128),inbound_code VARCHAR(128),
pallet_no VARCHAR(128),warehouse_pallet_id BIGINT,destination_type VARCHAR(32),
destination_warehouse_code VARCHAR(128),destination_warehouse_name VARCHAR(128),fba_warehouse_code VARCHAR(128),
delivery_address VARCHAR(256),postal_code VARCHAR(64),package_count INT)
delivery_address VARCHAR(256),postal_code VARCHAR(64),package_count INT,plan_id BIGINT,plan_pallet_id BIGINT)
""");
jdbc.execute("CREATE TABLE ll_fleet_external_booking(id BIGINT PRIMARY KEY,trip_id BIGINT,dispatch_no VARCHAR(128),customer_id VARCHAR(128),offer_id BIGINT,status VARCHAR(32),incoming_no VARCHAR(128),receipt_json LONGTEXT)");
jdbc.execute("CREATE TABLE ll_warehouse_transfer_order(customer_id VARCHAR(128),transfer_no VARCHAR(128),inbound_code VARCHAR(128))");
jdbc.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT PRIMARY KEY,plan_id BIGINT,customer_id VARCHAR(128),inbound_code VARCHAR(128))");
jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,plan_id BIGINT,customer_id VARCHAR(128))");
jdbc.execute("CREATE TABLE ll_dispatch_appointment_allocation(dispatch_id BIGINT,dispatch_item_id BIGINT,customer_id VARCHAR(64),batch_key VARCHAR(128),destination_key VARCHAR(128),package_count INT)");
jdbc.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_no VARCHAR(64),confirmation_no VARCHAR(64),appointment_start_utc DATETIME(3))");
jdbc.execute("""
@@ -41,14 +41,15 @@ class OwnedBusinessQueryTest {
assertThrows(BizException.class,()->service.detail(OwnedBusinessFixtures.actor(),"DS-OWN-ONE"));
verifyNoInteractions(dispatch);verify(repo,never()).capture(any());assertEquals(8L,source.getRecordVersion());
}
/** 装车前、非自有归属、失配的原装车hash均不能作为本页可写来源。 */
/** 装车前、已实际拼车、失配的装车hash均不可写;未拼车的外配车辆仍属自有业务。 */
@Test void stageAndFrozenOwnershipFactsAreBothRequired() {
var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec();
var service=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,mock(DispatchDetailReader.class));
var source=OwnedBusinessFixtures.source();when(repo.source("DS-OWN-ONE")).thenReturn(source);
source.setStage("LOADING");assertThrows(BizException.class,()->service.requireOwned("DS-OWN-ONE"));
source.setStage("AFTER_LOADING");source.setOwnedVehicleOwnership("DISPATCHED");assertThrows(BizException.class,()->service.requireOwned("DS-OWN-ONE"));
source.setOwnedVehicleOwnership("OWNED");source.setOwnedLoadingCompletionHash("other");assertThrows(BizException.class,()->service.requireOwned("DS-OWN-ONE"));
source.setStage("AFTER_LOADING");source.setOwnedVehicleOwnership("DISPATCHED");assertSame(source,service.requireOwned("DS-OWN-ONE"));
source.setPooledCargo(true);assertThrows(BizException.class,()->service.requireOwned("DS-OWN-ONE"));
source.setPooledCargo(false);source.setOwnedLoadingCompletionHash("other");assertThrows(BizException.class,()->service.requireOwned("DS-OWN-ONE"));
}
/** 列表权限不自动授予编辑或上传,不暴露主流程写动作。 */
@Test void readOnlyActorHasOnlyReadGates() {
@@ -75,7 +75,7 @@ class OwnedBusinessScopePersistenceTest {
""",id,"DS-OUT-"+id,stage,load,snapshot,"TEST-OUT-"+id);
baseline.jdbc.update("""
INSERT INTO ll_dispatch_item SELECT id+?, ?,customer_id,customer_name,source_key,order_no,inbound_code,?, ?,destination_type,
destination_warehouse_code,destination_warehouse_name,fba_warehouse_code,delivery_address,postal_code,package_count FROM ll_dispatch_item WHERE dispatch_order_id=1
destination_warehouse_code,destination_warehouse_name,fba_warehouse_code,delivery_address,postal_code,package_count,NULL,NULL FROM ll_dispatch_item WHERE dispatch_order_id=1
""",id*100,id,"PAL-"+(31+id),31+id);
var ownership=new OwnedBusinessRows.Ownership();ownership.setDispatchId(id);ownership.setVehicleId(vehicle.vehicleId());
ownership.setResourceMode(mode);ownership.setVehicleOwnership("DISPATCHED");ownership.setVehicleRecordVersion(vehicle.vehicleId()==null?null:3L);
@@ -83,6 +83,12 @@ class OwnedBusinessScopePersistenceTest {
baseline.jdbc.update("INSERT INTO ll_dispatch_history VALUES(?,?,8,'LOADING_COMPLETE','TEST-picker',NULL,'{}',?)",100+id,id,original.getLoadingCompletedAt());
if(!"AFTER_LOADING".equals(stage))baseline.jdbc.update("UPDATE ll_dispatch_order SET departed_at=? WHERE id=?",original.getLoadingCompletedAt().plusHours(1),id);
if("DELIVERED".equals(stage))baseline.jdbc.update("UPDATE ll_dispatch_order SET delivery_completed_at=? WHERE id=?",original.getLoadingCompletedAt().plusHours(2),id);
// 每张外配测试任务真实关联一票拼车来源,车辆归属不再充当分类依据。
baseline.jdbc.update("UPDATE ll_dispatch_item SET plan_id=?,plan_pallet_id=? WHERE dispatch_order_id=? AND customer_id='101'",id*1000,id*1000+1,id);
baseline.jdbc.update("INSERT INTO ll_inbound_plan_line VALUES(?,?,'101','IN-A')",id*1000+2,id*1000);
baseline.jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,?,?,'101')",id*1000+1,id*1000+2,id*1000);
baseline.jdbc.update("INSERT INTO ll_warehouse_transfer_order VALUES('101',?,'IN-A')","INCOMING-"+id);
baseline.jdbc.update("INSERT INTO ll_fleet_external_booking VALUES(?,2,?,'101',9,'BOUND',?,'{}')",id,"DS-OUT-"+id,"INCOMING-"+id);
}
/** 外配真实三权限,与自有集合完全不相交。 */
private OwnedBusinessModels.Actor actor(){return new OwnedBusinessModels.Actor("outsourced-operator","TEST外配操作员",Set.of(OutsourcedBusinessModels.LIST,OutsourcedBusinessModels.EDIT,OutsourcedBusinessModels.UPLOAD));}
@@ -101,6 +107,75 @@ class OwnedBusinessScopePersistenceTest {
assertEquals(0,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,"2026-10-20T08:00:00Z",null,null,"9")).total());
for(var stage:OwnedBusinessModels.Stage.values())assertEquals(1,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,"2026-10-20T08:00:00Z","2026-10-21T08:00:00Z",null,stage,"9")).total());
}
/** 四种车辆/拼车组合只按货物分类;临时外配未拼车也可从自有入口登记。 */
@Test void classificationIsIndependentOfVehicleOwnershipAndResourceMode() {
baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET vehicle_ownership='OWNED' WHERE dispatch_id=2");
baseline.jdbc.update("UPDATE ll_fleet_external_booking SET status='RECEIVED',dispatch_no=NULL WHERE id=3");
baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET vehicle_ownership='DISPATCHED' WHERE dispatch_id=1");
var query=new OwnedBusinessModels.Query(1,20,null,null,null,null,null);
assertEquals(2,outsourced.list(actor(),query).total());assertEquals(2,queries.list(OwnedBusinessFixtures.actor(),query).total());
assertEquals("OWNED",outsourced.detail(actor(),"DS-OUT-2").row().vehicle().ownership());
var external=queries.detail(OwnedBusinessFixtures.actor(),"DS-OUT-3");
assertNull(external.row().vehicle().vehicleId());assertEquals("DISPATCHED",external.row().vehicle().ownership());
var saved=records.save(OwnedBusinessFixtures.actor(),"DS-OUT-3",new OwnedBusinessModels.SaveOperations("0","non-pool-external-save",
new OwnedBusinessModels.Operations(new OwnedBusinessModels.Money("USD","0"),null,null,List.of()),null));
assertEquals("1",saved.row().recordVersion());
var pooled=outsourced.save(actor(),"DS-OUT-2",request("0","pool-owned-save",new OwnedBusinessModels.Money("USD","25"),List.of()));
assertEquals("OWNED",pooled.row().vehicle().ownership());assertEquals("25.00",pooled.row().unitPrice());
assertThrows(BizException.class,()->queries.detail(OwnedBusinessFixtures.actor(),"DS-OUT-2"));
assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-3"));
}
/** 发布、询价、占位、实收未排入、取消和非池来源均不计拼车,详情与完整统计一致。 */
@Test void onlyReceivedPoolCargoBoundToThisDispatchCounts() {
var query=new OwnedBusinessModels.Query(1,1,null,null,null,null,null);
for(String status:List.of("DRAFT","CONFIRMED","RECEIVED","CANCELLED")) {
baseline.jdbc.update("UPDATE ll_fleet_external_booking SET status=? WHERE id=2",status);
assertEquals(2,outsourced.list(actor(),query).total());assertEquals(2,queries.list(OwnedBusinessFixtures.actor(),query).total());
assertNotNull(queries.detail(OwnedBusinessFixtures.actor(),"DS-OUT-2"));
assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-2"));
}
baseline.jdbc.update("UPDATE ll_fleet_external_booking SET status='BOUND',offer_id=NULL WHERE id=2");
assertNotNull(queries.detail(OwnedBusinessFixtures.actor(),"DS-OUT-2"));
baseline.jdbc.update("UPDATE ll_fleet_external_booking SET offer_id=9,receipt_json=NULL WHERE id=2");
assertNotNull(queries.detail(OwnedBusinessFixtures.actor(),"DS-OUT-2"));
baseline.jdbc.update("UPDATE ll_fleet_external_booking SET receipt_json='{}',trip_id=999 WHERE id=2");
assertNotNull(queries.detail(OwnedBusinessFixtures.actor(),"DS-OUT-2"));
baseline.jdbc.update("UPDATE ll_fleet_external_booking SET trip_id=2,dispatch_no='OTHER' WHERE id=2");
assertNotNull(queries.detail(OwnedBusinessFixtures.actor(),"DS-OUT-2"));
baseline.jdbc.update("UPDATE ll_fleet_external_booking SET dispatch_no='DS-OUT-2',customer_id='102' WHERE id=2");
assertNotNull(queries.detail(OwnedBusinessFixtures.actor(),"DS-OUT-2"));
}
/** 用真实计划组成核对来源,展示入仓码不能代替组成;多条有效拼车登记不重复整车。 */
@Test void actualCargoSourceIsRequiredAndMultipleBookingsNeverMultiplyStatistics() {
baseline.jdbc.update("UPDATE ll_dispatch_item SET inbound_code='DISPLAY-ONLY' WHERE dispatch_order_id=2");
assertNotNull(outsourced.detail(actor(),"DS-OUT-2"));
baseline.jdbc.update("INSERT INTO ll_fleet_external_booking SELECT 22,trip_id,dispatch_no,customer_id,offer_id,status,incoming_no,receipt_json FROM ll_fleet_external_booking WHERE id=2");
var query=new OwnedBusinessModels.Query(1,1,null,null,null,null,null);
assertEquals(3,outsourced.list(actor(),query).total());assertEquals(1,outsourced.list(actor(),query).records().size());
baseline.jdbc.update("DELETE FROM ll_inbound_plan_pallet_allocation WHERE plan_id=2000");
assertNotNull(queries.detail(OwnedBusinessFixtures.actor(),"DS-OUT-2"));
assertEquals(2,outsourced.list(actor(),query).total());
assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-2"));
}
/** 同前缀编号末位必须参与来源匹配,128字符的多字节入仓码不能被二进制宽度截断。 */
@Test void completeIdentifiersDeterminePoolScopeWithoutPrefixTruncation() {
String transfer="T".repeat(63)+"A",dispatch="D".repeat(63)+"A",inbound="仓".repeat(127)+"甲";
baseline.jdbc.update("UPDATE ll_warehouse_transfer_order SET transfer_no=?,inbound_code=? WHERE transfer_no='INCOMING-2'",transfer,inbound);
baseline.jdbc.update("UPDATE ll_inbound_plan_line SET inbound_code=? WHERE plan_id=2000",inbound);
baseline.jdbc.update("UPDATE ll_dispatch_order SET dispatch_no=? WHERE id=2",dispatch);
baseline.jdbc.update("UPDATE ll_fleet_external_booking SET dispatch_no=?,incoming_no=? WHERE id=2",dispatch,transfer);
assertNotNull(outsourced.detail(actor(),dispatch));
baseline.jdbc.update("UPDATE ll_fleet_external_booking SET incoming_no=? WHERE id=2","T".repeat(63)+"B");
assertNotNull(queries.detail(OwnedBusinessFixtures.actor(),dispatch));
baseline.jdbc.update("UPDATE ll_fleet_external_booking SET incoming_no=?,dispatch_no=? WHERE id=2",transfer,"D".repeat(63)+"B");
assertNotNull(queries.detail(OwnedBusinessFixtures.actor(),dispatch));
baseline.jdbc.update("UPDATE ll_fleet_external_booking SET dispatch_no=? WHERE id=2",dispatch);
baseline.jdbc.update("UPDATE ll_inbound_plan_line SET inbound_code=? WHERE plan_id=2000","仓".repeat(127)+"乙");
assertNotNull(queries.detail(OwnedBusinessFixtures.actor(),dispatch));
baseline.jdbc.update("UPDATE ll_inbound_plan_line SET inbound_code=? WHERE plan_id=2000",inbound);
assertNotNull(outsourced.detail(actor(),dispatch));
assertEquals(3,outsourced.list(actor(),new OwnedBusinessModels.Query(1,1,null,null,null,null,null)).summary().dispatchCount());
}
/** 外配档案和临时车辆分别保持真实ID/空ID及原承运商,GET不建记录。 */
@Test void masterAndExternalReadOriginalSnapshotWithoutBackfill() {
var master=outsourced.detail(actor(),"DS-OUT-2");var external=outsourced.detail(actor(),"DS-OUT-3");
@@ -210,7 +285,7 @@ class OwnedBusinessScopePersistenceTest {
@Test void roundingUsesActualDistinctPalletsAndZeroDenominatorStaysNull() {
String load=baseline.repository.source("DS-OUT-2").getLoadingConfirmationJson().replace("\"pallets\":[", "\"pallets\":[{\"warehousePalletId\":99,\"palletSpaces\":5,\"weightKg\":10,\"volumeCbm\":1,\"deliveryIndex\":1},");
baseline.jdbc.update("UPDATE ll_dispatch_order SET loading_confirmation_json=? WHERE id=2",load);
baseline.jdbc.update("INSERT INTO ll_dispatch_item SELECT id+1000,dispatch_order_id,customer_id,customer_name,CONCAT(source_key,'-2'),order_no,inbound_code,'PAL-99',99,destination_type,destination_warehouse_code,destination_warehouse_name,fba_warehouse_code,delivery_address,postal_code,package_count FROM ll_dispatch_item WHERE id=211");
baseline.jdbc.update("INSERT INTO ll_dispatch_item SELECT id+1000,dispatch_order_id,customer_id,customer_name,CONCAT(source_key,'-2'),order_no,inbound_code,'PAL-99',99,destination_type,destination_warehouse_code,destination_warehouse_name,fba_warehouse_code,delivery_address,postal_code,package_count,plan_id,plan_pallet_id FROM ll_dispatch_item WHERE id=211");
var saved=outsourced.save(actor(),"DS-OUT-2",request("0","out-half-up-rounding",new OwnedBusinessModels.Money("USD","10.01"),List.of()));
assertEquals(2,saved.row().actualPalletCount());assertEquals(3,saved.cargo().size());assertEquals("5.01",saved.row().unitPrice());
var confirmation=baseline.codec.read(load,DispatchContracts.ConfirmedLoad.class);
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.fleet.trip;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class FleetBookingRequestHashTest {
final FleetBookingService service = new FleetBookingService(null, null, null, null, null, null,
new ObjectMapper().findAndRegisterModules(), null, null, null);
private String hash(Object value) { return ReflectionTestUtils.invokeMethod(service, "hash", value); }
@Test void incomingDispatchSourceKeyAcceptsArrayAndPreservesBookingAndCommandIdentity() {
var source = List.of("FB202610210004", "pool-arrange-command");
var digest = assertDoesNotThrow(() -> hash(source));
assertEquals(64, digest.length());
assertEquals(digest, hash(source));
assertNotEquals(digest, hash(List.of("FB202610210005", "pool-arrange-command")));
assertNotEquals(digest, hash(List.of("FB202610210004", "another-command")));
assertNotEquals(digest, hash(List.of("pool-arrange-command", "FB202610210004")));
}
@Test void bookingCommandsStillIgnoreReplayKeyAndTripVersionButRetainBookingVersion() {
var digest = hash(new FleetBookingModels.Command(8L, 4L, "command-original", "TEST"));
assertEquals(digest, hash(new FleetBookingModels.Command(9L, 4L, "command-replay", "TEST")));
assertNotEquals(digest, hash(new FleetBookingModels.Command(8L, 5L, "command-original", "TEST")));
}
}
@@ -62,4 +62,24 @@ class FleetTripCalendarMapperTest {
bindDispatches(jdbc);
assertThat(mapper.calendar(from,to)).hasSize(2001);
}
/** 实际Mapper批读同单跨客户多柜,排除其他主单与取消主单,不用货物行放大日历。 */
@Test void batchReadsEverySourceSnapshotOnlyForTheRequestedActiveDispatches() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
var jdbc=new JdbcTemplate(ds);
jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(80) UNIQUE,status VARCHAR(32))");
jdbc.execute("CREATE TABLE ll_dispatch_item(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT,customer_id VARCHAR(64),source_snapshot_json VARCHAR(2000))");
jdbc.update("INSERT INTO ll_dispatch_order VALUES(1,'PC-A','ACTIVE'),(2,'PC-B','ACTIVE'),(3,'PC-OTHER','ACTIVE'),(4,'PC-CANCELLED','CANCELLED')");
jdbc.update("INSERT INTO ll_dispatch_item VALUES(11,1,'101',?), (12,1,'102',?), (13,1,'101',?), (21,2,'103',?), (31,3,'101',?), (41,4,'101',?)",
"{\"containerNo\":\"TSTU1234567\"}","{\"containerNo\":\"TSTU1234567\"}","{\"containerNo\":\"TSTU7654321\"}",
"{\"planNo\":\"PLAN-B\"}","{\"containerNo\":\"OTHER-CARGO\"}","{\"containerNo\":\"CANCELLED-CARGO\"}");
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(FleetTripMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(FleetTripMapper.class);
var repository=new FleetTripCalendarRepository(mapper);
assertThat(repository.sourceSnapshots(List.of())).isEmpty();
var rows=repository.sourceSnapshots(List.of("PC-A","PC-B","PC-CANCELLED"));
assertThat(rows).extracting(FleetTripCalendarRepository.Source::dispatchNo).containsExactly("PC-A","PC-A","PC-A","PC-B");
assertThat(rows).extracting(FleetTripCalendarRepository.Source::sourceSnapshotJson).containsExactly(
"{\"containerNo\":\"TSTU1234567\"}","{\"containerNo\":\"TSTU1234567\"}","{\"containerNo\":\"TSTU7654321\"}","{\"planNo\":\"PLAN-B\"}");
}
}
@@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class FleetTripCalendarServiceTest {
final FleetTripMapper trips=mock(FleetTripMapper.class);
final FleetTripCalendarRepository trips=mock(FleetTripCalendarRepository.class);
final FleetWarehouseClient warehouses=mock(FleetWarehouseClient.class);
final FleetTripCalendarService service=new FleetTripCalendarService(trips,warehouses,new ObjectMapper(),"internal-test");
final Instant from=Instant.parse("2026-10-01T00:00:00Z"),to=from.plus(Duration.ofDays(44));
@@ -107,4 +107,51 @@ class FleetTripCalendarServiceTest {
var platform=new com.baiyue.common.security.AuthenticatedUser(1L,0L,0L,1L,"platform","PLATFORM","WEB",Set.of("platform:overseas-warehouse:dispatch:list"));
controller.calendar(platform,from,to);verify(calendar).calendar(from,to);
}
/** 同车全部来源只批读一次,跨客户重复柜号去重,多柜完整保留。 */
@Test void readsAllContainerNumbersInOneBatchAndPreservesTheLocalDateFacts() {
var first=trip("PC-A",1L);first.setPlannedDepartureAt(LocalDateTime.of(2026,10,22,16,0));
when(trips.calendar(any(),any())).thenReturn(List.of(first,trip("PC-B",1L)));
when(trips.sourceSnapshots(List.of("PC-A","PC-B"))).thenReturn(List.of(
new FleetTripCalendarRepository.Source("PC-A","{\"containerNo\":\" TSTU1234567 \",\"planNo\":\"PLAN-A\"}"),
new FleetTripCalendarRepository.Source("PC-A","{\"containerNo\":\"TSTU1234567\"}"),
new FleetTripCalendarRepository.Source("PC-A","{\"containerNo\":\"TSTU7654321\"}"),
new FleetTripCalendarRepository.Source("PC-A","{\"containerNo\":null}"),
new FleetTripCalendarRepository.Source("PC-B","{}"),
new FleetTripCalendarRepository.Source("PC-B","{\"containerNo\":\" \"}")));
metadata(List.of(warehouse(1,"US","America/Los_Angeles")),List.of());
var result=service.calendar(from,to);
assertThat(result.records()).hasSize(2);
assertThat(result.records().get(0).containerNumbers()).containsExactly("TSTU1234567","TSTU7654321");
assertThat(result.records().get(1).containerNumbers()).isEmpty();
assertThat(result.records().get(0).plannedDepartureAt()).isEqualTo(Instant.parse("2026-10-22T16:00:00Z"));
assertThat(result.records().get(0).originTimezone()).isEqualTo("America/Los_Angeles");
assertThat(result.unresolvedRecords()).isEmpty();
verify(trips).calendar(any(),any());verify(trips).sourceSnapshots(List.of("PC-A","PC-B"));verifyNoMoreInteractions(trips);
verify(warehouses).platformMetadata("internal-test",new FleetWarehouseClient.MetadataRequest(List.of(1L)));
verifyNoMoreInteractions(warehouses);
}
/** 缺时区的主单仍保留真实柜号,不将货物信息丢失误作起运地已核实。 */
@Test void keepsKnownContainersOnAnUnresolvedOrigin() {
when(trips.calendar(any(),any())).thenReturn(List.of(trip("PC-UNKNOWN",1L)));
when(trips.sourceSnapshots(List.of("PC-UNKNOWN"))).thenReturn(List.of(
new FleetTripCalendarRepository.Source("PC-UNKNOWN","{\"containerNo\":\"TSTU1234567\"}")));
metadata(List.of(warehouse(1,"US",null)),List.of());
var result=service.calendar(from,to);assertThat(result.records()).isEmpty();
assertThat(result.unresolvedRecords()).singleElement().satisfies(row->{
assertThat(row.containerNumbers()).containsExactly("TSTU1234567");
assertThat(row.incompleteReasons()).containsExactly("ORIGIN_TIMEZONE_UNKNOWN");
});
}
/** 损坏或非当前正式文本快照明确失败,不伪造空数组或静默隐藏整月。 */
@Test void rejectsMalformedOrForeignSourceSnapshotsExplicitly() {
when(trips.calendar(any(),any())).thenReturn(List.of(trip("PC-A",1L)));
for(var invalid:Arrays.asList(null,"","not-json","null","[]","{\"containerNo\":123}","{\"containerNo\":[\"TSTU1234567\"]}","{} {}")) {
when(trips.sourceSnapshots(List.of("PC-A"))).thenReturn(List.of(new FleetTripCalendarRepository.Source("PC-A",invalid)));
assertThatThrownBy(()->service.calendar(from,to)).isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("柜号资料暂不可用");
}
when(trips.sourceSnapshots(List.of("PC-A"))).thenReturn(List.of(new FleetTripCalendarRepository.Source("OTHER-PC","{}")));
assertThatThrownBy(()->service.calendar(from,to)).isInstanceOf(ResponseStatusException.class).hasMessageContaining("柜号资料暂不可用");
verifyNoInteractions(warehouses);
}
}
@@ -13,16 +13,19 @@ import static org.assertj.core.api.Assertions.*;
class CustomerOrderProgressMapperTest {
private JdbcTemplate jdbc;
private CustomerOrderProgressMapper mapper;
/** 两客户共用一单一任务,计划和在途均按各自真实明细归属统计。 */
/** 两客户共用一单一任务,出库和在途均按各自真实明细归属统计。 */
@Test void sharedDispatchAndTaskKeepBothCustomerProgressSeparate() {
jdbc.update("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,plan_pallet_id,overseas_warehouse_id,destination_warehouse_code) VALUES(101,10,'22',501,'W1','FBA2')");
jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(501,1002,30)");
pendingOutbound(10000,"11",1,10);
pendingOutbound(10000,"11",2,20);
pendingOutbound(10002,"22",3,30);
assertThat(mapper.sources("11",null)).extracting(s->s.scheduledPackages()).containsExactly(10L,20L);
assertThat(mapper.sources("22",null)).singleElement().satisfies(s->assertThat(s.scheduledPackages()).isEqualTo(30));
jdbc.update("INSERT INTO ll_dispatch_pda_task VALUES(700,10)");
jdbc.update("INSERT INTO ll_dispatch_pda_pick VALUES(600,'11',100,400,4,700),(601,'22',101,401,7,700)");
jdbc.update("INSERT INTO ll_dispatch_pda_pick_order VALUES(900,600,'11',1,4),(901,601,'22',3,7)");
jdbc.update("INSERT INTO ll_outbound_order VALUES(10000,'11','OUTBOUNDED'),(10002,'22','OUTBOUNDED')");
jdbc.update("UPDATE ll_outbound_order SET status='OUTBOUNDED'");
jdbc.update("INSERT INTO ll_delivery_order VALUES(10001,'11',10,10000,'DELIVERING','CODE:FBA1'),(10003,'22',10,10002,'DELIVERING','CODE:FBA2')");
jdbc.update("UPDATE ll_dispatch_order SET stage='DELIVERING',departed_at=CURRENT_TIMESTAMP");
assertThat(mapper.sources("11",null)).extracting(s->s.deliveringPackages()).containsExactly(4L,0L);
@@ -45,6 +48,7 @@ class CustomerOrderProgressMapperTest {
jdbc.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,dispatch_order_id BIGINT UNIQUE)");
jdbc.execute("CREATE TABLE ll_delivery_order(id BIGINT,customer_id VARCHAR,dispatch_order_id BIGINT,outbound_order_id BIGINT,status VARCHAR,delivery_group_key VARCHAR)");
jdbc.execute("CREATE TABLE ll_outbound_order(id BIGINT,customer_id VARCHAR,status VARCHAR)");
jdbc.execute("CREATE TABLE ll_outbound_pallet_item(outbound_order_id BIGINT,customer_id VARCHAR,source_order_id BIGINT,package_count INT)");
jdbc.update("INSERT INTO ll_order VALUES(1,'11','101','201','FIRSTLEG','FBA',10),(2,'11','102','202','FIRSTLEG','FBA',20),(3,'22','103','203','FIRSTLEG','FBA',30)");
jdbc.update("INSERT INTO ll_dispatch_order VALUES(10,'ACTIVE','BEFORE_LOADING',NULL)");
jdbc.update("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,plan_pallet_id,overseas_warehouse_id,destination_warehouse_code) VALUES(100,10,'11',500,'W1','FBA1')");
@@ -54,24 +58,40 @@ class CustomerOrderProgressMapperTest {
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
mapper=new SqlSessionTemplate(factory.getObject()).getMapper(CustomerOrderProgressMapper.class);
}
/** 计划托盘上的所有本客户来源均计入,取消后不再计入。 */
@Test void plannedSharedPalletIncludesEveryOwnSourceAndExcludesCancellation() {
/** 排车号及审核阶段不代表有效出库,生成出库后才计入,取消后自动移出。 */
@Test void onlyEffectiveOutboundSourcesCountAsScheduled() {
jdbc.execute("ALTER TABLE ll_dispatch_order ADD review_state VARCHAR");
for (String review : java.util.List.of("DRAFT","DISPATCH_PENDING","DISPATCH_REJECTED","LOADING_PENDING","LOADING_REJECTED","APPROVED")) {
jdbc.update("UPDATE ll_dispatch_order SET review_state=?",review);
assertThat(mapper.sources("11", null)).allMatch(s->s.scheduledPackages()==0);
assertThat(new com.baiyue.lastleg.service.CustomerOrderProgressService(mapper).read(11,null).progress()).isEmpty();
}
jdbc.update("UPDATE ll_dispatch_order SET stage='AFTER_LOADING'");
assertThat(mapper.sources("11", null)).allMatch(s->s.scheduledPackages()==0);
pendingOutbound(10000,"11",1,10);
pendingOutbound(10000,"11",2,20);
var sources=mapper.sources("11", null);assertThat(sources).hasSize(2);
assertThat(sources.get(0).scheduledPackages()).isEqualTo(10);
assertThat(sources.get(1).scheduledPackages()).isEqualTo(20);
assertThat(mapper.sources("11",java.util.List.of("101"))).containsExactly(sources.get(0));
assertThat(sources).allMatch(s->s.deliveringPackages()==0);
jdbc.update("UPDATE ll_dispatch_order SET status='CANCELLED'");
assertThat(mapper.sources("11", null)).allMatch(s->s.scheduledPackages()==0);
for (String status : java.util.List.of("CANCELLED","CANCELED","OUTBOUNDED")) {
jdbc.update("UPDATE ll_outbound_order SET status=?",status);
assertThat(mapper.sources("11", null)).allMatch(s->s.scheduledPackages()==0);
}
assertThat(mapper.sources("22", null)).hasSize(1).allMatch(s->s.scheduledPackages()==0);
}
/** 已收货使用实际库存和扫码,不与计划量重复累计。 */
@Test void receivedPalletUsesInventoryAndActualPicksWithoutCountingBothPlanAndStock() {
/** 已收货和扫码不能提前推进,出库来源快照不随剩余库存变化。 */
@Test void scheduledUsesOutboundSnapshotInsteadOfPlannedOrRemainingInventory() {
jdbc.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=800");
jdbc.update("INSERT INTO ll_inventory_balance VALUES(300,'11',1),(301,'11',2)");
jdbc.update("INSERT INTO ll_inventory_position VALUES(400,'11',800,'W1',300,6),(401,'11',800,'W1',301,20)");
jdbc.update("INSERT INTO ll_dispatch_pda_pick VALUES(600,'11',100,400,4,700)");
var sources=mapper.sources("11", null);assertThat(sources.get(0).scheduledPackages()).isEqualTo(10);
assertThat(mapper.sources("11", null)).allMatch(s->s.scheduledPackages()==0);
pendingOutbound(10000,"11",1,4);
pendingOutbound(10000,"11",2,20);
jdbc.update("UPDATE ll_inventory_position SET available_packages=0");
var sources=mapper.sources("11", null);assertThat(sources.get(0).scheduledPackages()).isEqualTo(4);
assertThat(sources.get(1).scheduledPackages()).isEqualTo(20);
assertThat(mapper.sources("11",java.util.List.of("101"))).containsExactly(sources.get(0));
}
@@ -95,8 +115,10 @@ class CustomerOrderProgressMapperTest {
assertThat(mapper.sources("11", null)).allMatch(s->s.deliveringPackages()==0);
}
/** 同源多入仓片段完整返回,过滤不得扩大到其他订单或客户。 */
@Test void exactSourceOrderScopePreservesAllOwnInboundFragmentsAndExcludesForeignOrders() {
jdbc.update("INSERT INTO ll_order VALUES(4,'11','101','204','FIRSTLEG','FBA',5)");
pendingOutbound(10000,"11",1,10);
var own=mapper.sources("11",java.util.List.of("101","103"));
assertThat(own).extracting(s->s.orderId()).containsExactly("101","101");
assertThat(own).extracting(s->s.scheduledPackages()).containsExactly(10L,0L);
@@ -104,4 +126,30 @@ class CustomerOrderProgressMapperTest {
assertThat(mapper.sources("11",java.util.List.of("999"))).isEmpty();
}
/** 多张出库只汇总本客户的有效来源数量,不借用其他来源或客户单据。 */
@Test void splitOutboundQuantitiesExcludeCancelledAndForeignDocuments() {
pendingOutbound(10000,"11",1,4);
pendingOutbound(10001,"11",1,6);
pendingOutbound(10002,"11",2,20);
jdbc.update("UPDATE ll_outbound_order SET status='CANCELLED' WHERE id=10001");
jdbc.update("INSERT INTO ll_outbound_order VALUES(10003,'22','WAIT_OUTBOUND')");
jdbc.update("INSERT INTO ll_outbound_pallet_item VALUES(10003,'11',1,100),(10000,'22',3,100),(10003,'22',1,100)");
assertThat(mapper.sources("11", null)).extracting(s->s.scheduledPackages()).containsExactly(4L,20L);
assertThat(new com.baiyue.lastleg.service.CustomerOrderProgressService(mapper).read(11,java.util.List.of(101L)).progress())
.singleElement().satisfies(p->{assertThat(p.stage()).isEqualTo("scheduled");assertThat(p.partial()).isTrue();});
assertThat(mapper.sources("22", null)).allMatch(s->s.scheduledPackages()==0);
jdbc.update("UPDATE ll_outbound_order SET status='WAIT_OUTBOUND' WHERE id=10001");
assertThat(mapper.sources("11", null)).extracting(s->s.scheduledPackages()).containsExactly(10L,20L);
assertThat(new com.baiyue.lastleg.service.CustomerOrderProgressService(mapper).read(11,java.util.List.of(101L)).progress())
.singleElement().satisfies(p->{assertThat(p.stage()).isEqualTo("scheduled");assertThat(p.partial()).isFalse();});
}
/** 创建同客户的待出库单及精确来源明细,允许一单包含多个来源。 */
private void pendingOutbound(long outboundId,String customerId,long sourceOrderId,int packages) {
if (jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_order WHERE id=?",Integer.class,outboundId)==0) {
jdbc.update("INSERT INTO ll_outbound_order VALUES(?,?,'WAIT_OUTBOUND')",outboundId,customerId);
}
jdbc.update("INSERT INTO ll_outbound_pallet_item VALUES(?,?,?,?)",outboundId,customerId,sourceOrderId,packages);
}
}
@@ -72,6 +72,18 @@ class PdaOutboundDispatchContractTest {
verify(outboundService).pdaTasks("900", null);
}
/** PDA只暴露理货审核且身份固定取绑定仓,不提供调度审核入口。 */
@Test
void loadingReviewUsesTheSharedServiceWithBoundPdaIdentity() {
var workflow=mock(com.baiyue.lastleg.dispatch.service.DispatchAggregateService.class);
var controller=new DispatchPdaController();ReflectionTestUtils.setField(controller,"workflow",workflow);
var user=pda(Set.of("lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"),900L);
var request=new DispatchCommands.ReviewCommand(2L,"pda-review",DispatchTypes.Decision.REJECTED,"司机资料待核对",null);
controller.loadingReview(user,"PC202610210001",request);
verify(workflow).review(new DispatchContracts.Actor("7",DispatchTypes.ActorMode.PDA,"900"),"PC202610210001",true,request);
assertFalse(Arrays.stream(DispatchPdaController.class.getDeclaredMethods()).anyMatch(method->method.getName().equals("dispatchReview")));
}
@Test
void missingRolePermissionOrWarehouseBindingIsRejected() {
DispatchPdaController controller = new DispatchPdaController();
@@ -88,7 +100,10 @@ class PdaOutboundDispatchContractTest {
String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-pda-operations-v1.yaml"),
StandardCharsets.UTF_8);
assertTrue(yaml.contains("/api/pda/v1/last-leg/dispatches:"));
assertTrue(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/depart:"));
assertTrue(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-review:"));
assertTrue(yaml.contains("overseas-business-v1.yaml#/components/schemas/DispatchReviewCommand"));
assertFalse(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/depart:"));
assertTrue(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-complete:"));
assertTrue(yaml.contains("/api/pda/v1/last-leg/outbound-tasks/{taskNo}/complete:"));
assertTrue(yaml.contains("/api/pda/v1/last-leg/outbound-tasks/{taskNo}/pallets/{palletNo}/pick:"));
assertFalse(yaml.contains("name: customerId"));
@@ -148,7 +148,7 @@ class LastlegOrderServiceTest {
when(orders.countContainerPlans("BOOKING-1")).thenReturn(0L);
when(orders.updateContainerExpectedWarehouseArrival("BOOKING-1", estimatedAt, 0)).thenReturn(2);
when(orders.findContainerArrivalTimes(List.of("BOOKING-1"))).thenReturn(List.of(
new ContainerArrivalTimeView("BOOKING-1", estimatedAt, null, 1)));
new ContainerArrivalTimeView("BOOKING-1", estimatedAt, null, 1, 0)));
ContainerArrivalTimeView result = service.changeExpectedWarehouseArrival("BOOKING-1", "arrival-1",
new LastlegOrderRequests.ChangeExpectedWarehouseArrival(estimatedAt, 0, null), "admin");
@@ -716,13 +716,22 @@ public class UnifiedOrderQueryService {
|| StringUtils.hasText(query.measurementReviewStatus());
}
/** 保留内部调用的原始异常,便于按请求链路定位连接和协议故障。 */
private <T> ApiResponse<T> call(java.util.concurrent.Callable<ApiResponse<T>> invocation) {
try { return invocation.call(); }
catch (FeignException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程服务暂时不可用"); }
catch (Exception exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程服务查询失败"); }
catch (FeignException exception) {
throw (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程服务暂时不可用").initCause(exception);
}
catch (Exception exception) {
throw (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程服务查询失败").initCause(exception);
}
}
/** 失败关闭时保留下游错误码与链路标识,不记录业务载荷或服务令牌。 */
private <T> T require(ApiResponse<T> response, String message) {
if (response == null || !response.successful() || response.data() == null) {
LOGGER.warn("头程查询响应无效 operation={} downstreamCode={} downstreamMessage={} downstreamRequestId={} downstreamTraceId={}",
message, response == null ? null : response.code(), response == null ? null : response.message(),
response == null ? null : response.requestId(), response == null ? null : response.traceId());
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, message);
}
return response.data();
+2 -1
View File
@@ -10,7 +10,8 @@
| 类别 | 正式入口 | 管理边界 |
|---|---|---|
| 本机重型检查限流 | `local-check.ps1` | 所有工作目录、对话共用一个用户级执行名额;PowerShell 7,状态/超时/并发与 Job Object 回收规则见外层 `docs/AI工程执行与协作约束.md`;只读状态不申请重型名额 |
| 本机重型检查限流 | `local-check.ps1` | 所有工作目录、对话共用两个用户级执行名额;PowerShell 7,状态/超时/并发与 Job Object 回收规则见外层 `docs/AI工程执行与协作约束.md`;只读状态不申请重型名额 |
| 后端分层检查 | `backend-check.ps1` | 默认指定模块回归,支持测试源码编译和精确测试类;必填模块,自动接入重型限流;全量触发条件见后端根指引,参数及副作用见 README |
| 测试/灰度单票验收 | `prod-release-acceptance.mjs`、`prod-release-delivery-stage.mjs`、内部 `prod-release-order-stage.mjs` | 显式 test/gray、统一 Gateway/accessToken;同一票义乌到洛杉矶、前置同时核对东莞;full-flow 串行覆盖买单FBA正常链至签收回传及车次资源释放,保留分阶段入口与独立检查点;不调用测试造数模块,不创建账号/主档或删除数据;配置和边界见 README |
| Admin 全量 HTTP 验证 | `admin-test-env-acceptance.mjs` | 通过显式环境文件运行统一 TEST Gateway/会话,拒绝旧海外网关参数;默认模块覆盖平台全链路,定向验证使用 `--modules`,模块注册和依赖以总入口及 `admin-test-env-modules/README.md` 为准 |
| Client 全量 HTTP 验证 | `client-test-env-acceptance.mjs` | 通过显式环境文件运行统一 TEST Gateway/会话,拒绝旧海外网关参数;覆盖客户工作台完整链路,`client-phase2-test-env-acceptance.mjs` 只是入库测量与计费专项,不替代全量入口 |
+57 -5
View File
@@ -1,5 +1,29 @@
# 后端脚本管理与全量 HTTP 验收索引
## 先看范围,再看证据
`acceptance-overview.mjs` 是离线只读总览入口,不需要凭据、不联网、不创建报告或测试数据。默认范围直接读取 Admin 实际注册表,不维护另一份模块枚举:
```powershell
node scripts/acceptance-overview.mjs
node scripts/acceptance-overview.mjs --report reports/<明确的一次验收报告>.json
```
| 验收层 | 正式入口 | 结论边界 |
| --- | --- | --- |
| Admin 默认 HTTP | `admin-test-env-acceptance.mjs` | 总览的 defaultModules;optionalModules 需显式指定,不自动计入 |
| Client 全量 HTTP | `client-test-env-acceptance.mjs` | 独立执行,跳过详情不得视为详情已验证 |
| 测量计费专项 | `client-phase2-test-env-acceptance.mjs` | 不替代 Client 全量,使用本轮样本 |
| 车辆等专项 | 本索引对应的专项入口 | 只覆盖各自声明范围,不能合并宣称默认全量一次通过 |
| 离线检查 | `local-check.ps1` 包装受影响测试 | 证明源码检查,不证明部署或真实 HTTP |
| 页面与真机 | 应用验收规则 | 单独提供操作和视觉证据,HTTP 不代替 |
总览显式读取一份报告,不遍历历史目录挑选绿灯。`FAILED` 表示有失败、fatal 或阻断缺口;`INCOMPLETE` 表示有跳过、非阻断缺口、计划模块未出现或仅准备/局部模式;`RECORDED_CHECKS_PASSED` 只表示所记录检查通过,**不是当前版本全量通过**。旧报告没有执行计划时显示 `UNKNOWN_LEGACY_REPORT`,不反推范围。报告损坏或格式不支持时退出1,不自动重跑;正常分析退出0,即使报告失败也不作为 CI 放行命令。
新 Admin 报告的 `executionScope` 保存申请模块、依赖展开后的计划、当轮默认范围及模式限制;这里只记录计划,不以注册即视为已执行。总览将有请求失败与无请求失败分开统计,后者包含模块退出/前置不足,不能把失败条数直接当作独立 Bug 数。缺少模块断言也仅表示缺少证据,不猜测未执行原因。
最新部署与已验/未验状态仍只维护在外层 `docs/测试环境单机部署与配置契约.md`;本索引不复制每日进度。总览不校验实际部署版本、不读取环境文件,执行前仍须经正式入口校验凭据和环境。直接离线测试为 `acceptance-overview.test.mjs`。
`local-dispatch-transport-acceptance.mjs`:仅本机 LOCAL 的现有排车运输任务 HTTP 验收。显式 `node --env-file=<本机凭据文件>` 注入 `ADMIN_TEST_GATEWAY`、`ADMIN_TEST_USERNAME/PASSWORD`、`LOCAL_TRANSFER_PDA_PASSWORD`;拒绝远端网关。创建3张 TEST 仓储转运单、1张本土卡车原单、5张排车(完成/取消/重新安排),以及本轮专用调度/PDA账号、角色、车辆司机;缺失的正式排车权限仅补资源并授予专用角色,不修改既有角色。经正式网关验证原单审核、跨客户取货、资源冲突、凭证隔离、取消重排、发车完成回写、独立仓库清点和车辆司机释放。合成 PNG 只验文件链路,不代表实际运输或有效证照。默认报告 `reports/local-dispatch-transport.json`,可用 `TRANSPORT_ACCEPTANCE_OUTPUT` 指定;每步保留请求键、脱敏意图、错误码和关联标识,失败停止且保留数据。确认已知失败结果后显式 `TRANSPORT_ACCEPTANCE_RESUME=true` 复用同一报告继续;网络结果未知必须先核对,不自动清理或删除,不使用业务 SQL。已成功批次禁止重复运行;不能替代远端部署或人工截图验收。
`local-check.ps1`:Windows / PowerShell 7 本机重型检查统一入口,适用于前后端测试、类型检查、构建、安装依赖及 Maven。示例:`& ./scripts/local-check.ps1 -WorkingDirectory ../apps/admin-web -Label 'Admin typecheck' -Command @('pnpm', 'typecheck')`;`-Status` 查看占用。当前用户所有工作目录共用一个文件句柄锁,默认排队900秒、执行1200秒;超时分别退出75/124,其余透传命令退出码。Vitest workers=2,Node堆2048MB,Java堆1536MB/处理器2;直接 Maven 自动单线程/单fork,直接 Node 内置测试自动并发2。Windows Job Object 仅回收本次后代,不接管旧进程;嵌套调用拒绝,批次内部执行原始命令。无凭据、不主动联网或写业务数据;被包装命令的环境授权不变。状态和最近结果仅写 `%LOCALAPPDATA%/Chongchong/LocalChecks/`,不存命令参数或输出;不自动缓存成功结果。失败保留最近摘要,确认输入后重新排队;锁文件无需手工删除,状态不是验收报告。完整规则见 `../../docs/AI工程执行与协作约束.md` 的“本机重型检查与多对话限流”;离线行为测试为 `local-check.test.mjs`。
@@ -93,7 +117,7 @@ F33 分阶段助手的组合换标顺序为 `unit → unit-carton → bind`:
F40 专项可用 `--signature-mode ROOT_FIRST|CHILD_FIRST|ROOT_SIGNED_REMAINDER_CANCELLED` 只运行显式指定的一种顺序,省略仍串行执行三种;报告和成功范围仅覆盖所列模式。`--verify-delivery-event-recovery` 必须同时显式指定一种顺序,且环境为TEST;仅在该模式第一张真实FBA派送签收前开启定向ACK丢失,不能使用普通海外转运的签收替代国内回写。故障窗口会重建同镜像Lastleg并临时调整事件目标和退避配置,禁止与其他部署或共享环境写入并行;原事件DEAD恢复后必须验证原镜像/配置/网络身份、健康及代理退出。入口事先留存精确故障scope,finally兜底恢复,缺恢复证据或恢复校验失败一律失败。完整库存取证接入既有只读器,报告保留于该批次 `evidence/`,不能把某一模式或事件通过解读为全部反向事件完成。
`fleet-cross-customer-test-env.mjs` 是最小两客户同一排车主单的串行 TEST 专项入口,默认 `--plan` 零网络。显式运行参数为 `--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外收货仓ID> --destination-warehouse-id <FBA目的仓ID>`,凭据仅从显式 `node --env-file="<TEST配置>" --env-file="<Jenkins配置>"` 与 `FBA_CORE_JENKINS_AGENT` 提供。通过普通转运来货、PDA实收和客户确认创建两个新客户各一托一箱,真实来源一次Create到同一主单,自动唯一Fleet记录、配置资源并完成两审;一客户未核平时验证整车装车/发车拒绝及完整库存快照不变,全量实拣后按真实测量显式装车、平台逐客户目的地安排,再统一发车、原键重放、逐客户签收,最后正式结案并分别释放车辆/司机,核对结案前后库存零变化。完整库存由只读取证器获取,缺实测不补计划值,不写业务SQL,不部署或改共享配置。报告位于 `reports/fleet-cross-<新批次>/`,每次请求发送前记录意图;失败保留原现场,不自动重跑或续跑,已存在批次拒绝覆盖。finally停用本轮PDA并退出独立会话;业务样本保留。前置拒绝不代表事务中途回滚,后者另验。对应入口保护合同为 `fleet-cross-customer-test-env.test.mjs`;内部生成与验收函数见模块README。
`fleet-cross-customer-test-env.mjs` 是最小两客户同一排车主单的串行 TEST 专项入口,默认 `--plan` 零网络。显式运行参数为 `--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外收货仓ID> --destination-warehouse-id <FBA目的仓ID>`,凭据仅从显式 `node --env-file="<TEST配置>" --env-file="<Jenkins配置>"` 与 `FBA_CORE_JENKINS_AGENT` 提供。通过普通转运来货、PDA实收和客户确认创建两个新客户各一托一箱,真实来源一次Create到同一主单,自动唯一Fleet记录、配置资源并完成两审;一客户未核平时验证整车装车/发车拒绝及完整库存快照不变,全量实拣后按真实测量确认装车,自动生成客户目的地单据并发车、原键重放、逐客户签收,最后正式结案并分别释放车辆/司机,核对结案前后库存零变化。完整库存由只读取证器获取,缺实测不补计划值,不写业务SQL,不部署或改共享配置。报告位于 `reports/fleet-cross-<新批次>/`,每次请求发送前记录意图;失败保留原现场,不自动重跑或续跑,已存在批次拒绝覆盖。finally停用本轮PDA并退出独立会话;业务样本保留。前置拒绝不代表事务中途回滚,后者另验。对应入口保护合同为 `fleet-cross-customer-test-env.test.mjs`;内部生成与验收函数见模块README。
头程订单提交使用首次原始详情的 `expectedVersion` 与原 `Idempotency-Key`。单票验收的 `SUBMIT_INTENT` 必须先持久化 `submitCommand` 再发送,显式续跑不得使用当前版本替换原命令,缺少原命令的提交意图及后续检查点停止并等待人工核对。源 Excel 造数同样在提交前保存订单条目的 `submitCommand`;中断或失败条目保留原检查点且不自动重跑,不重建订单或换版本继续。
@@ -101,7 +125,7 @@ F40 专项可用 `--signature-mode ROOT_FIRST|CHILD_FIRST|ROOT_SIGNED_REMAINDER_
`fleet-trip-rollback-test-env.mjs` 是跨客户同一原生主单发车中途回滚的串行TEST入口,默认`--plan`零网络;实际参数沿用`--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外仓ID> --destination-warehouse-id <FBA目的仓ID>`,凭据只通过显式TEST/Jenkins环境文件及`FBA_CORE_JENKINS_AGENT`提供。复用正式两客户一托一箱普通转仓来货/实收、同主单两审/实拣/实测装车及平台安排链;按Java客户字符串顺序,通过原业务号只读解析当前主单、客户子单及真实转仓来源,第二客户转仓行建立65秒SELECT FOR UPDATE租约,等待事务自身的50秒会话锁超时使原整车发车失败。不修改业务行、Schema、共享参数或镜像;GLOBAL超时或HTTP超时不能代替会话与事务证据。等待图须精确对应原发车事务、目标转仓主键及实际UPDATE,同时独立READ UNCOMMITTED观察已写的第一客户子单、来源计数及历史;此时主单仍AFTER_LOADING、Fleet为PLANNED、尚无DEPART命令结果,不能沿用旧DEPARTED/重放表断言。最终新已提交读按实际Schema全字段比较相关主/子单、来源历史、占用、命令账本及已提交扫码/SHIP事实,脏读仅作中途证明。租约自动ROLLBACK,核对原事务和等待均已消失,再用同一原发车键/载荷完成正式发车、重放、逐客户签收和资源释放。报告沿`reports/fleet-cross-<新批次>/`,额外`rollback.json`、源码SHA与镜像身份保留;权限、字段、会话超时或任一证据缺失明确阻断,失败保留原现场,不自动重造或续跑,finally仅清理自有PDA/会话。执行需要对应TEST数据库取证、持锁及真实HTTP的明确授权,共享窗口禁止并行发布或操作相同库存。对应`fleet-trip-rollback-test-env.test.mjs`验证证据反例和范围;通用跨客户入口不默认故障注入。
`fleet-test-env-acceptance.mjs` 是显式 TEST 车辆专项入口,默认 `--plan` 仅输出计划;使用显式外部 TEST 与 Jenkins 配置后,以 `--execute --environment TEST --run-id <新批次>` 串行创建本轮头程/海外来源并验证原费用全覆盖、取消重排后的部分覆盖、外接承运占位、PDA真实收货、外接排车计费、逐客户PDA拣货、整车发车、逐目的地签收及资源释放。报告在 `reports/fleet-<run-id>/`;目录和锁独占,失败保留原请求/资源,不自动续跑或重造。会通过正式HTTP写入测试资源和取消未执行单据;既有收货取证使用Jenkins只读查询,不执行业务SQL写入、部署或共享配置修改。Finance费用池消费事实与生产者ACK分开记录;UI及未实现车辆产品范围另验。对应离线入口合同为 `fleet-test-env-acceptance.test.mjs`,内部助手详见模块README。
`fleet-test-env-acceptance.mjs` 是显式 TEST 车辆专项入口,默认 `--plan` 仅输出计划;使用显式外部 TEST 与 Jenkins 配置后,以 `--execute --environment TEST --run-id <新批次>` 串行创建本轮头程/海外来源并验证两组独立货物自动生成派送单据、外接承运占位、PDA真实收货、外接排车计费、逐客户PDA拣货、整车发车、逐目的地签收及资源释放。报告在 `reports/fleet-<run-id>/`;目录和锁独占,失败保留原请求/资源,不自动续跑或重造。会通过正式HTTP写入测试资源和取消未执行单据;既有收货取证使用Jenkins只读查询,不执行业务SQL写入、部署或共享配置修改。Finance费用池消费事实与生产者ACK分开记录;UI及未实现车辆产品范围另验。对应离线入口合同为 `fleet-test-env-acceptance.test.mjs`,内部助手详见模块README。
整车回滚专项的完整快照覆盖当前23表,包括本批真实实托的`ll_physical_pallet_guard`永久串行行;按实际Schema全列及COUNT取证,中途和回滚后均核对原主键与首次创建时间不变,不把删除重建串行点视作成功回滚。此取证范围仅限该回滚专项,不扩展通用车辆入口的故障注入。
@@ -271,7 +295,7 @@ node --env-file="<仓库外灰度验收环境文件>" scripts/admin-test-env-acc
排车扩展入口还支持独立新批次 `--scenario reviews`,真实覆盖两级驳回重提、零扫码退回改货改车及旧版本/幂等冲突,再接完整履约。`--scenario review-terminal --resume-report <本扩展原失败报告> --resume-report-sha256 <原SHA>` 只接收已完成双签收、请求全部明确RESPONSE、审核包VERIFIED但顺序只读快照跨过异步完成版本的精确停点;新报告先只读核齐原单DELIVERED及全量库存,再仅结案释放本批资源,不重拣、重发、重签或覆盖旧失败。其他未知写和原人工单不接受此模式。
`dispatch-workflow-extensions-test-env.mjs` 是排车扩展 TEST HTTP 串行入口,默认 `--plan` 零网络。显式执行使用 `--execute --environment TEST --scenario dual-destination --run-id <新批次> --warehouse-id <实际海外仓> --destination-warehouse-id <首个FBA仓> --pda-env-file <现有海外PDA私有配置> --synthetic-pallet-spaces 1`;TEST及只读取证凭据由调用者在脚本前显式 `node --env-file=<TEST配置>` 加载。正式目录找到第二个同国有效FBA仓后才造新客户和货物,验证双目的仓、独立费用/单据/库存与完整履约。现有PDA只开新会话、不创建停用账号;托位、费用、附件标记TEST合成,重量体积必须来自正式测量证据,不代表现场或硬件。原人工单不参与新造数。报告 `reports/fleet-cross-<run-id>/` 与独占intent存在即拒绝重入;未知写保留原键载荷并停写。只复用受保护库存读者,不新增SQL、部署、改权限或清理数据;退出自有会话、保留业务样本。未就绪矩阵保持未执行;直接合同为 `admin-test-env-modules/dispatch-workflow-extension-contract.test.mjs`。
`dispatch-workflow-extensions-test-env.mjs` 是排车扩展 TEST HTTP 串行入口,默认 `--plan` 零网络。显式执行使用 `--execute --environment TEST --scenario dual-destination --run-id <新批次> --warehouse-id <实际海外仓> --destination-warehouse-id <首个FBA仓> --pda-env-file <现有海外PDA私有配置> --synthetic-pallet-spaces 1`;TEST及只读取证凭据由调用者在脚本前显式 `node --env-file=<TEST配置>` 加载。正式目录找到第二个同国有效FBA仓后才造新客户和货物,验证双目的仓、自动单据、实际费用归属/库存与完整履约。现有PDA只开新会话、不创建停用账号;托位、附件标记TEST合成,重量体积必须来自正式测量证据,不代表现场或硬件。原人工单不参与新造数。报告 `reports/fleet-cross-<run-id>/` 与独占intent存在即拒绝重入;未知写保留原键载荷并停写。只复用受保护库存读者,不新增SQL、部署、改权限或清理数据;退出自有会话、保留业务样本。未就绪矩阵保持未执行;直接合同为 `admin-test-env-modules/dispatch-workflow-extension-contract.test.mjs`。
`--scenario competition` 同样仅使用本轮新样本,验证占用中的车辆/司机拒绝第二张空排车、装车后尚未安排派送的发车拒绝,以及已成功装车原键的两个 Admin/PDA 请求并发重放;原操作员恢复唯一结果,另一操作员被拒绝,完整库存与主单事实不变。该场景不宣称首次有效命令竞争或完整权限矩阵通过。第二次签收确定成功后增加最多30秒只读完结等待,再进入既有跨接口一致性快照;不重放签收写入,也不修改既有失败报告或共享验证器。
@@ -309,7 +333,8 @@ BOL长托盘码按现行模板省略,自动核验使用完整入仓码与本
| 订单与客户本地验收 | `phase1-acceptance.ps1` | 订单与客户本地服务/API 综合验收,会创建本地业务数据 |
| 订单与客户本地验收 | `phase1-business-maturity-regression.ps1` | 订单与客户业务成熟度回归,调用本地服务并生成报告 |
| 订单与客户本地验收 | `phase1-onboarding-order-e2e.ps1` | 注册、资质、审核到下单的本地 E2E,会创建客户、仓库、往来单位和订单 |
| 本地质量 | `backend-full-regression.ps1` | Gateway 路由与 Mapper 门禁后执行根 POM 全部有效模块 Maven 测试;`-DryRun` 只打印与校验 |
| 本地质量 | `backend-check.ps1` | 必填模块,默认模块回归;Compile 编译测试源码,Focused 运行精确测试类并检查本轮报告;`-DryRun` 只校验选择并打印 |
| 本地质量 | `backend-full-regression.ps1` | Gateway 路由与 Mapper 门禁后执行根 POM 全部有效模块 Maven 测试;`-DryRun` 只打印与校验;全量触发条件见后端根 AGENTS |
| 本地质量 | `frontend-full-regression.ps1` | 编排 Admin/Client 本地测试、类型检查及国内/海外 PDA Node 全量测试,不访问 Gateway |
| 本地质量 | `database-comment-audit.ps1` | 离线扫描数据库中文注释,不连接数据库 |
| Jenkins 只读数据库结构 | `database-schema-contract.mjs` | 只查询 `information_schema` 核对区域初始化 SQL 的表和字段;不执行 SQL、不造业务数据、不执行迁移 |
@@ -402,7 +427,7 @@ Gateway 路由防漏:`node --test scripts/gateway-route-contract.test.mjs`。
| 检查 | 入口 |
|---|---|
| 后端 Maven 契约/单元测试 | `scripts/backend-full-regression.ps1` |
| 后端 Maven 契约/单元测试 | 日常 `scripts/backend-check.ps1`;全仓 `scripts/backend-full-regression.ps1` |
| Admin/Client/国内与海外 PDA 本地全量回归 | `scripts/frontend-full-regression.ps1` |
| PDA 本地页面和状态定向测试 | `../apps/pda-uniapp/tests/*.test.mjs` |
| 本地数据库 SQL 静态/受控验收 | `scripts/local-db-full-acceptance.ps1` |
@@ -420,6 +445,33 @@ Admin/Client HTTP 脚本的离线合同只验证脚本结构、参数和模块
| Client 消息中心调用 | `scripts/client-notifications-contract.test.mjs` |
| 验收入口与模块注册 | `scripts/full-regression-entry-contract.test.mjs`、`scripts/controller-pipeline-contract.test.mjs` |
## 后端分层检查
`backend-check.ps1` 是本地日常入口,PowerShell 7 执行,自动经 `local-check.ps1` 占用一个重型名额。全量触发条件唯一见 `../AGENTS.md`;不自动根据 Git diff 猜测跨服务消费者,不自动缓存或跳过测试。
在后端根目录执行,`-Modules` 使用逗号分隔的当前根 POM 模块名:
```powershell
# 只查看范围,不运行 Maven。
.\scripts\backend-check.ps1 -Modules order -DryRun
# 签名变化:保留生产及测试源码编译。
.\scripts\backend-check.ps1 -Modules order -Mode Compile
# 开发迭代:精确类名,支持逗号分隔和全限定名。
.\scripts\backend-check.ps1 -Modules common-core -Mode Focused -Tests ApiResponseTest
# 单服务收尾:指定模块及上游依赖测试。
.\scripts\backend-check.ps1 -Modules order
# 跨服务收尾:显式列出受影响生产者、消费者。
.\scripts\backend-check.ps1 -Modules 'order,firstleg'
```
三种模式均使用根 reactor 的 `-pl <modules> -am`,不会安装制品到本地仓库,不默认 `clean`。`Compile` 运行 `test-compile`;`Focused`/`Module` 运行 `test`。`-am` 只补上游构建依赖,HTTP/事件下游消费者须由调用者显式列入。公共模块变更也须核对下游,不能只测试公共模块自身。Focused 只用于迭代,不能替代有行为变化时的模块收尾;API、Mapper、索引等相关静态合同仍按其规则单独执行。
Focused 不接受通配符或方法选择,每个类必须唯一命中所选模块的 `src/test/java`。为允许上游无同名测试,Maven 设置 `surefire.failIfNoSpecifiedTests=false`,随后逐类核对本轮更新的 Surefire XML;报告缺失、陈旧、全部跳过或含失败均不能通过,避免零测试假绿。
无环境凭据,不调用真实 HTTP、部署或共享数据库,不启动常驻服务;Maven 可读取依赖缓存、下载缺失依赖,并写各模块 `target/`,测试中的隔离内存数据库行为不变。沿用现有 Maven 测试配置,不额外启用环境集成测试。报告仍在各模块 `target/surefire-reports/`,不创建额外报告目录、不删除旧报告。失败保留产物,修复后重新执行相同范围;报告新鲜度校验仅防止复用旧结果,不支持同一模块在多个对话并发执行测试。
`-MavenCommand` 可指定 Maven 可执行文件,默认 `mvn`。退出码:0 为所选范围通过(DryRun 仅预检通过),2 为模块/测试选择无效;Maven 非零退出码透传,报告核验失败返回非零。排队/执行超时沿用 `local-check.ps1` 的 75/124。入口行为测试为 `backend-check.test.mjs`。
## 后端全量回归
## 入口
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env node
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { describeAcceptanceScope } from './admin-test-env-acceptance.mjs'
/** 只输出白名单统计和模块名,不转抄报告中的账号、地址、凭据或业务载荷。 */
export function summarizeReport(report) {
if (!report || !Array.isArray(report.steps) || report.steps.length === 0
|| report.steps.some((step) => typeof step.passed !== 'boolean')) {
throw new Error('报告缺少有效 steps,不能认定验收结果')
}
const steps = report.steps
const failed = steps.filter((step) => !step.passed && !step.skipped)
const skipped = steps.filter((step) => step.skipped)
const skippedModules = Array.isArray(report.modules) ? report.modules.filter((module) => module.skipped).length : 0
const gaps = Array.isArray(report.coverageGaps) ? report.coverageGaps : []
const blockers = gaps.filter((gap) => gap.blocking || gap.deploymentBlocker)
const scope = report.executionScope
// 历史报告不包含计划信息,不能从断言数量或模块名字推定完整执行。
const scopeKnown = Array.isArray(scope?.plannedModules) && scope.plannedModules.length > 0
&& Array.isArray(scope.defaultModules) && scope.defaultModules.length > 0
const observedModules = new Set(steps.map((step) => step.module))
const missingModules = scopeKnown ? scope.plannedModules.filter((name) => !observedModules.has(name)) : []
const limited = Boolean(scope?.preparationOnly || scope?.returnMainChainOnly || report.fullAcceptance === false)
const failedResult = Boolean(report.fatal) || report.success === false || failed.length > 0 || blockers.length > 0
return {
result: failedResult ? 'FAILED' : skipped.length || skippedModules || gaps.length || missingModules.length || limited ? 'INCOMPLETE' : 'RECORDED_CHECKS_PASSED',
passed: steps.filter((step) => step.passed && !step.skipped).length,
failed: failed.length,
skipped: skipped.length,
skippedModules,
blockingGaps: blockers.length,
nonBlockingGaps: gaps.length - blockers.length,
failedModules: [...new Set(failed.map((step) => step.module).filter(Boolean))],
// 无请求的失败包含前置不足和模块退出,不能计作独立接口缺陷。
failuresWithRequest: failed.filter((step) => step.request).length,
failuresWithoutRequest: failed.filter((step) => !step.request).length,
scope: !scopeKnown ? 'UNKNOWN_LEGACY_REPORT' : limited ? 'LIMITED_SCOPE'
: scope.defaultModules.every((name) => scope.plannedModules.includes(name)) ? 'DEFAULT_ADMIN_PLAN' : 'SELECTED_MODULES',
missingPlannedModules: missingModules,
currentVersionVerified: false,
pageAcceptance: 'NOT_VERIFIED_BY_HTTP',
}
}
/** 默认仅展示范围;显式报告只读分析,不发现历史目录、不联网、不执行验收。 */
async function main(argv) {
if (argv.length && !(argv.length === 2 && argv[0] === '--report')) {
throw new Error('用法:node scripts/acceptance-overview.mjs [--report <明确JSON路径>]')
}
const scope = describeAcceptanceScope()
const result = {
purpose: '离线验收范围总览,不执行HTTP,不证明当前部署通过',
admin: { entry: 'scripts/admin-test-env-acceptance.mjs', ...scope },
otherEntries: [
{ entry: 'scripts/client-test-env-acceptance.mjs', scope: 'Client全量HTTP,独立执行' },
{ entry: 'scripts/client-phase2-test-env-acceptance.mjs', scope: '测量计费专项,不替代Client全量' },
{ entry: 'scripts/fleet-test-env-acceptance.mjs', scope: '车辆专项,不计入Admin默认全量' },
],
boundaries: ['可选模块不等于默认执行;其他专项入口详见scripts/README.md',
'本地合同、HTTP、页面与真机验收分别报告',
'报告通过只代表该次记录;需另核对源码、配置与实际部署版本',
'环境与凭据前置仍由实际入口校验;本工具不读取环境文件'],
}
if (argv.length) result.report = summarizeReport(JSON.parse(await readFile(resolve(argv[1]), 'utf8')))
console.log(JSON.stringify(result, null, 2))
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
main(process.argv.slice(2)).catch((error) => { console.error(error.message); process.exitCode = 1 })
}
+46
View File
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { summarizeReport } from './acceptance-overview.mjs'
import { describeAcceptanceScope } from './admin-test-env-acceptance.mjs'
test('注册表返回独立副本,默认与可选不重复', () => {
const scope = describeAcceptanceScope()
assert.ok(scope.defaultModules.includes('appointment-work'))
assert.ok(scope.optionalModules.includes('phase4-overseas-return-followups'))
assert.ok(scope.optionalModules.every((name) => !scope.defaultModules.includes(name)))
scope.defaultModules.length = 0
assert.ok(describeAcceptanceScope().defaultModules.length > 0)
})
test('旧报告全绿也不推断默认全量或当前版本通过', () => {
const result = summarizeReport({ success: true, steps: [{ module: 'orders', passed: true }] })
assert.equal(result.scope, 'UNKNOWN_LEGACY_REPORT')
assert.equal(result.currentVersionVerified, false)
assert.equal(result.result, 'RECORDED_CHECKS_PASSED')
})
test('跳过、覆盖缺口和未执行模块不能被success掩盖', () => {
const base = { success: true, steps: [{ module: 'orders', passed: true }],
executionScope: { plannedModules: ['orders', 'finance'], defaultModules: ['orders', 'finance'] } }
assert.equal(summarizeReport(base).result, 'INCOMPLETE')
assert.deepEqual(summarizeReport(base).missingPlannedModules, ['finance'])
assert.equal(summarizeReport({ ...base, coverageGaps: [{ blocking: true }] }).result, 'FAILED')
assert.equal(summarizeReport({ steps: [{ passed: true, skipped: true }] }).result, 'INCOMPLETE')
assert.equal(summarizeReport({ steps: [{ passed: true }], modules: [{ name: 'orders', skipped: true }] }).result, 'INCOMPLETE')
})
test('前置退出与接口失败分开统计,输出不携带敏感载荷', () => {
const result = summarizeReport({ success: false, gateway: 'SECRET', steps: [
{ module: 'exports', passed: false, request: { path: 'SECRET' }, message: 'SECRET' },
{ module: 'dependent', passed: false, evidence: 'SECRET' },
] })
assert.equal(result.failed, 2)
assert.equal(result.failuresWithRequest, 1)
assert.equal(result.failuresWithoutRequest, 1)
assert.equal(JSON.stringify(result).includes('SECRET'), false)
})
test('准备数据和局部模式不冒充完整验收,空报告拒绝', () => {
assert.equal(summarizeReport({ fullAcceptance: false, steps: [{ passed: true }] }).result, 'INCOMPLETE')
assert.throws(() => summarizeReport({ success: true, steps: [] }), /steps/)
})
+18
View File
@@ -281,6 +281,14 @@ export function normalizeModuleOrder(modules) {
}
const DEFAULT_MODULES = normalizeModuleOrder(CONFIGURED_MODULES)
/** 从执行注册表返回范围,供离线总览使用,避免维护第二套模块清单。 */
export function describeAcceptanceScope() {
return {
defaultModules: [...DEFAULT_MODULES],
optionalModules: OPTIONAL_MODULES.filter((name) => !DEFAULT_MODULES.includes(name)),
}
}
const WEIGHT_IMAGE_PATH = process.env.ADMIN_TEST_WEIGHT_IMAGE_PATH || ''
const CUSTOMER_FIELDS = [
'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative',
@@ -1940,6 +1948,16 @@ export class AcceptanceRun {
return {
reportName: this.options.prepareDomesticPdaH5 ? '国内 PDA H5 待拣任务准备(非全链路验收)' : this.options.environment === 'gray' ? '灰度环境 FBA 定向闭环 HTTP API 验收' : '后台管理测试环境 HTTP API 验收', marker: this.marker,
environment: this.options.environment || 'test', gateway: this.options.gateway,
executionScope: {
requestedModules: [...(this.options.requestedModules || [])],
plannedModules: [...this.options.modules],
defaultModules: [...DEFAULT_MODULES],
defaultPlanSelected: DEFAULT_MODULES.every((name) => this.options.modules.includes(name)),
preparationOnly: this.options.prepareDomesticPdaH5 === true || this.options.prepareDropshipManualCount === true,
returnMainChainOnly: this.options.returnMainChainOnly === true,
evidenceType: 'HTTP',
pageAcceptance: 'NOT_VERIFIED',
},
generatedAt: new Date().toISOString(), startedAt: this.startedAt,
success: this.fatal === null && summary.failed === 0, fatal: this.fatal,
...(this.options.prepareDomesticPdaH5 ? { fullAcceptance: false, purpose: 'DOMESTIC_PDA_H5_FIXTURE' } : {}),
+13 -5
View File
@@ -1,6 +1,14 @@
# Admin 测试环境 HTTP 模块
`dispatch-workflow-extension-acceptance.mjs` 由 `../dispatch-workflow-extensions-test-env.mjs` 显式调用,不是独立CLI。双客户双目的仓扩展先从正式目录核验两FBA仓,复用本轮独立来货和PDA实收;派送费用按真实group客户使用不同合成金额,完整正常链后核对逐客户目的仓、费用、单据和交叉读取拒绝。`fleet-cross-customer-fixture.mjs` 可接收两个明确 `destinationWarehouseIds`,在任何造数写前核验全部目的仓;原单目的仓调用不变。所有写经过既有逐请求持久化和未知结果停写边界,不修改共享runner;中途故障注入无工具时明确NOT_TESTED。直接合同 `dispatch-workflow-extension-contract.test.mjs` 不证明环境通过。
`fleet-return-redispatch-acceptance.mjs` 的原出发证明读取本轮 `LOADING_COMPLETE` 回执与 `departureDeliveries`;重派在 PDA 装车完成后回查自动生成的出库/派送及两箱已发运事实,不再调用手工安排派送或确认发车。原键重放和跨操作员拒绝仍由共享装车验证覆盖,原签收不漂移、源订单完成事件审计及资源分别释放仍须独立核对。离线模拟不能代替匹配部署的真实 HTTP。
固定托盘造数中,`inbound-measurement.mjs` 只复用 `TP-001`、`YW-TP-002` 指定池中的现有托盘;一只可复用,不要求两只同时存在,两只均缺失才阻断,不自动选择池外托盘。仅明确设置内部 `allowAnyExistingPallet` 的通用场景可读取当前仓库已有资源;两者均不创建托盘。
OCR模块必须通过`ADMIN_TEST_OCR_IMAGE`显式指定本轮图片,不依赖个人桌面路径。合成称重测试图只证明上传、识别响应及人工确认协议,不代表现场称重照片或设备验收。费用项目模块按受控编码验证112条正式基线,合法扩展项目保留并单独核对分页和筛选。散板普通状态接口仅验证取消及非法派车拒绝,原单须出现在待匹配需求池;需求池接车到签收另列覆盖缺口,不以手工跳状态冒充履约。
先运行 `node scripts/acceptance-overview.mjs`(后端根目录,无网络)查看当前默认与可选模块。范围来自真实注册表;本文件描述各模块用途与依赖,不代表所有模块默认执行或已通过。报告解读及跨端/专项边界见 [总索引](../README.md#先看范围再看证据),最新运行状态只查外层部署契约。
`dispatch-workflow-extension-acceptance.mjs` 由 `../dispatch-workflow-extensions-test-env.mjs` 显式调用,不是独立CLI。双客户双目的仓扩展先从正式目录核验两FBA仓,复用本轮独立来货和PDA实收;装车完成自动生成派送并发车,完整正常链后核对逐客户目的仓、实际费用、单据和交叉读取拒绝。`fleet-cross-customer-fixture.mjs` 可接收两个明确 `destinationWarehouseIds`,在任何造数写前核验全部目的仓;原单目的仓调用不变。所有写经过既有逐请求持久化和未知结果停写边界,不修改共享runner;中途故障注入无工具时明确NOT_TESTED。直接合同 `dispatch-workflow-extension-contract.test.mjs` 不证明环境通过。
其 `competition` 扩展只并发两个已经成功装车命令的跨端原键重放,并回查完整库存、主单、货物和车次;不是首次执行竞争。占用资源与未安排派送发车分别串行验证拒绝后快照不变。签收成功后的自动完结只做有界只读等待,保持真实写回执,等待失败阻断下一请求而不伪报已知写为未知结果。
@@ -18,7 +26,7 @@
`dispatch-repair-acceptance.mjs` 是本批排车修复的内部增量验收助手,不是独立 CLI。`verifyDispatchRepair` 默认只返回计划,显式执行要求 TEST、Lastleg/Admin 健康及 Schema 就绪、请求前后持久化检查点。`context.producerEvidence` 指定本轮生产回执 JSON 的路径及 SHA-256;文件须含 `environment=TEST`、`marker`、已部署 Lastleg 的 `sourceCommit` 和 `requests`。请求条目保留实际 `method/path/body/actor`、`status=RESPONDED`、`http/code/requestId/traceId/result`,不得保存登录凭据或 Token。助手从正式创建响应及父来源回执提取业务号、客户和仓库,形成资源白名单;所有 fixture 关联身份在首个请求前核对,不能仅声明“本轮新建”。缺样本标记 NOT_RUN,不借旧业务样本。
该助手核对取消出库/取消排车/释放重排、实际出库门禁、换标全托、混托两种展示来源、拆合托后重核、原箱回仓及只读权限。两种混托须使用独立排车/托盘/预订,普通与外接来源的箱数由生产实收回执确定,并回查实物仓位组成及报价数量/单价/金额。原箱限本轮单箱出库,按原始 SKU、客户、仓分页精确筛选库存及流水,以排车号匹配 OUTBOUND;先取完成前 S、首次完成后 R,再同键重放取 R′,核对本轮唯一 SKU 的完成前零库存基线及首次总量、可用展示数量、箱数恢复、receiptNo 的 EXCEPTION_RETURN_IN 精确集合、目标托盘库位及重放零增量。HTTP availableQuantity 是受实箱可用量限制的展示值,不冒称裸余额证明。只对已授权的新排车调用 DELETE 取消状态,不运行移托 DELETE 负向分支,不物理删除、SQL 造数或清理。未知结果保存原命令并停写;证据明确标为合成测试资料,不冒充真实外部预约/POD。保留 `sourceEventsVerified=false`,来源事件、人工页面及 PDA 真机仍单列。相邻 `dispatch-repair-acceptance-contract.test.mjs` 只做离线验证,未实际执行不得表述为环境通过。
该助手将已撤销的装车后取消/人工重排场景标为 NOT_APPLICABLE,仍核对实际出库门禁、换标全托、混托两种展示来源、拆合托后重核、原箱回仓及只读权限。两种混托须使用独立排车/托盘/预订,普通与外接来源的箱数由生产实收回执确定,并回查实物仓位组成及报价数量/单价/金额。原箱限本轮单箱出库,按原始 SKU、客户、仓分页精确筛选库存及流水,以排车号匹配 OUTBOUND;先取完成前 S、首次完成后 R,再同键重放取 R′,核对本轮唯一 SKU 的完成前零库存基线及首次总量、可用展示数量、箱数恢复、receiptNo 的 EXCEPTION_RETURN_IN 精确集合、目标托盘库位及重放零增量。HTTP availableQuantity 是受实箱可用量限制的展示值,不冒称裸余额证明。不运行移托 DELETE 负向分支,不物理删除、SQL 造数或清理。未知结果保存原命令并停写;证据明确标为合成测试资料,不冒充真实外部预约/POD。保留 `sourceEventsVerified=false`,来源事件、人工页面及 PDA 真机仍单列。相邻 `dispatch-repair-acceptance-contract.test.mjs` 只做离线验证,未实际执行不得表述为环境通过。
- 人工状态矩阵的 `FEE_CONFIRMING` 使用独立新订单,在测量提交成功后暂停审核,经正式 `fees/calculate` 生成费用;回查同一费用版本、入库客户身份、测量仍待审核及入仓码精确筛选结果。该状态是必验项,不再列入不可造数缺口;不发送费用、不推进该样本、不借历史数据。入口仍为 `--modules manual-state-matrix`,对应 `order-inbound-status-fixtures-contract.test.mjs` 和 `manual-state-matrix-contract.test.mjs`。
- `WAIT_PRICING` 另建保留原始 `APPROVED` 的订单,先形成未发布的 `FEE_CONFIRMING`,再通过客户实时渠道目录与报价选择同仓、同目的仓、同税制的不同渠道,以正式申报更新触发费用失效;回查 `WAIT_PRICING/UNPRICED`、新渠道及精确筛选。缺少可计价替换渠道、客户漂移或状态未收敛均失败;不写业务SQL,退出专用目录查询会话。该状态同样为必验项,原先借历史 `WAIT_SPLIT` 样本的兜底已移除。
@@ -42,7 +50,7 @@
`fleet-cross-customer-fixture.mjs` 导出 `createFleetCrossCustomerFixture`,由上述专用 TEST 入口显式调用。默认只返回准备契约;执行时重新核对指定实际收货仓和FBA目的仓,通过正式客户资质、普通转运来货、PDA一托一箱测量/绑定/实收及客户确认生成两套独立本轮货物,再以真实来源一次 Create 建立同一原生排车主单、自动唯一 Fleet 资源记录并完成两级审核,停在 LOADING、零扫码、未生成出库和派送单。每条明细保留其真实客户、来货码、物理托盘和库位,主单不挂客户。客户上下文独立且不覆盖外层客户环境,退出本函数创建的客户会话,PDA启停归外层入口;无外接预订循环依赖、业务SQL、部署或自动恢复。`fleet-cross-customer-fixture-contract.test.mjs` 只证明生成编排及拒绝边界,真实运行另行留证。
`fleet-cross-customer-acceptance.mjs` 是跨客户同一原生排车主单的内部后半链验收函数,不是独立入口。默认只返回 fixture 契约、零网络;显式 `execute:true` 需要同批次两个独立客户、同仓各一托一箱已实收、同一主单已通过两级审核且未拣货,以及调用方提供的完整库存只读取证器。按各明细实际客户扫码,验证一个客户未核平时整车装车/发车拒绝及全部快照不变;全部实拣核平后按实际测量显式装车、平台逐客户目的地安排,再以当前主单版本统一发车、原键重放和分别签收。实托测量缺失明确阻断,不以计费/计划常量替代。前置门禁与事务中途回滚分别记录;默认后者为 `NOT_TESTED`,只有显式注入`verifyDepartureRollback`且返回精确本轮验证、完整一致性快照恢复相同后才记VERIFIED。专用`../fleet-trip-rollback-test-env.mjs`负责租约/脏读观察与持久化,普通入口不注入故障;钩子异常、未验证或快照漂移必须停止在正常发车前。失败保留当前样本与原请求,不自动续跑、补造或清理。对应合同 `fleet-cross-customer-acceptance-contract.test.mjs` 仅证明编排与反例断言,不能代替真实运行。临时账号生命周期、最小来货生成及报告保存由外层显式编排负责。
`fleet-cross-customer-acceptance.mjs` 是跨客户同一原生排车主单的内部后半链验收函数,不是独立入口。默认只返回 fixture 契约、零网络;显式 `execute:true` 需要同批次两个独立客户、同仓各一托一箱已实收、同一主单已通过两级审核且未拣货,以及调用方提供的完整库存只读取证器。按各明细实际客户扫码,验证一个客户未核平时整车装车/发车拒绝及全部快照不变;全部实拣核平后按实际测量确认装车,在同一次命令自动生成客户目的地单据并发车;按详情 documents 核对真实单据,原键重放和分别签收。实托测量缺失明确阻断,不以计费/计划常量替代。前置门禁与事务中途回滚分别记录;默认后者为 `NOT_TESTED`,只有显式注入`verifyDepartureRollback`且返回精确本轮验证、完整一致性快照恢复相同后才记VERIFIED。专用`../fleet-trip-rollback-test-env.mjs`负责租约/脏读观察与持久化,普通入口不注入故障;钩子异常、未验证或快照漂移必须停止在正常发车前。失败保留当前样本与原请求,不自动续跑、补造或清理。对应合同 `fleet-cross-customer-acceptance-contract.test.mjs` 仅证明编排与反例断言,不能代替真实运行。临时账号生命周期、最小来货生成及报告保存由外层显式编排负责。
`TERMINAL_READONLY` 用于全部业务写入和结案助手均完成、仅专项入口汇总状态断言错误的已复核报告。结案助手成功状态为 `CARGO_COMPLETED_AND_RESOURCES_RELEASED`。该检查点登录后仅 GET 原车次、每张排车/派送、资源占用测试计划与精确财务来源版本,不重放任何业务写入,不覆盖原失败报告;真实状态、数量、签收或费用不符仍失败。它不补充生产者 ACK 或页面验收证据。
@@ -58,7 +66,7 @@
车辆专项入口为 `../fleet-test-env-acceptance.mjs`,先使用原 `phase4-overseas-dispatch` 依赖链创建本轮客户、头程费用和海外库存,再执行专用车辆编排;不改变已通过的 F40 默认调用顺序。默认 `--plan` 无网络,实际运行须显式 `--execute --environment TEST --run-id <新批次>`。仅串行运行,会创建当前批次资源及执行正式未执行单据取消重排,不执行部署、配置修改或业务 SQL 写入;原收货取证沿用现有只读读取器。失败保留原样本、请求与报告,不自动重造或续跑。
四个内部助手及各自 `*-contract.test.mjs`:`fleet-booking-reservation-acceptance.mjs` 验证询价/确认/超卖/取消的逐段占位与重放;`fleet-fee-coverage-acceptance.mjs` 用独立排车验证全覆盖及多原入仓的部分覆盖,核对原费用版本、目的地、快照及重放不重复收费;`fleet-external-receipt-acceptance.mjs` 通过正式来货与 PDA 测量、生成托盘、扫码绑定、照片和完成接口入库,按客户/仓/来货码查询余额与流水,验证重放不再入库、运营核量改价和预留替换;`fleet-external-dispatch-acceptance.mjs` 将该实收货物预排并绑定原车次,重核容量并逐客户审核,验证约定运费来源与重放。后两者会产生新外接货,必须走专用编排,不能直接加入只拣一张排车的 F40 默认流程。专项入口继续逐单 PDA 拣货、整车发车、逐目的地签收及资源释放;报告将 Finance 消费入池与生产者 ACK 分别记录,消费行存在不能冒充生产者已收到 ACK。以上助手不能独立当作全链入口,离线合同通过不代表真实 HTTP 或页面验收通过。
四个内部助手及各自 `*-contract.test.mjs`:`fleet-booking-reservation-acceptance.mjs` 验证询价/确认/超卖/取消的逐段占位与重放;`fleet-fee-coverage-acceptance.mjs` 复用两组独立排车样本,核对装车完成自动生成单据的客户、目的地、箱数和实际费用;已撤销的人工费用覆盖选择不计作通过;`fleet-external-receipt-acceptance.mjs` 通过正式来货与 PDA 测量、生成托盘、扫码绑定、照片和完成接口入库,按客户/仓/来货码查询余额与流水,验证重放不再入库、运营核量改价和预留替换;`fleet-external-dispatch-acceptance.mjs` 将该实收货物预排并绑定原车次,重核容量并逐客户审核,验证约定运费来源与重放。后两者会产生新外接货,必须走专用编排,不能直接加入只拣一张排车的 F40 默认流程。专项入口继续逐单 PDA 拣货、整车发车、逐目的地签收及资源释放;报告将 Finance 消费入池与生产者 ACK 分别记录,消费行存在不能冒充生产者已收到 ACK。以上助手不能独立当作全链入口,离线合同通过不代表真实 HTTP 或页面验收通过。
头程提交、平台编辑与取消按首次原始详情版本冻结请求。原键原载荷重放成功;同键异载荷、旧版本及新键重复操作分别核验拒绝后事实不变。Platform 编辑使用平台权限与显式客户范围,客户 Token 不得调用。
@@ -488,6 +496,6 @@ Client 全量为单文件,新增的客户账单、海外仓自助、跨境采
- 必须在 `baiyue-firstleg` 根运行批量合同:`auth-login-payload` 等合同按 `scripts/...` 相对路径读取,从工作区根运行会产生 `ENOENT` 假失败。
- `appointment-work-test-env`、`agents-index-registry`、`ci-maven-resource`、`deploy-built-service-rollback` 会 `spawn` 子进程;受限沙箱下 `EPERM` 属于运行环境限制,不是合同失败。
- `database-schema-diff`、`prod-release-*`、`fleet-*-test-env` 等需要数据库或测试环境的合同不进入本地批量扫描;按各自 README 单独授权执行。
- 当前 134 个模块合同中有 4 个 `fleet-*` 合同失败(`fleet-cross-customer-fixture`、`fleet-external-dispatch-acceptance`、`fleet-fee-coverage-acceptance`、`fleet-return-redispatch-acceptance`),失败点在 fleet 业务模拟断言,与脚本新增模块和本次 `phase4-overseas-dispatch`/`finance-downloads` 修改无关,另行处理。
- 合同数量与失败清单以同一源码快照的实际报告为准,不在模块索引维护固定失败数量;当前已验/未验范围统一查外层部署契约,历史报告不覆盖最新源码。
采购费用不再使用通用费用草稿、审核和发布链。`phase4-overseas-finance-cross-region.mjs` 对采购改为验证通用创建被拒绝,其他四类费用继续验证既有投递链;采购成功来源由完整样品流程核对实扣与来源关联。
@@ -63,7 +63,7 @@ function producerReport() {
event('GET', '/api/platform/v1/last-leg/return-inventory/cartons/BOX', undefined, { cartonMark: 'BOX', sourceReturnNo: 'RT', customerId: 'C', overseasWarehouseId: 'W', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 3 }, { skuCode: 'SKU-B', quantity: 2 }] })
event('POST', '/api/merchant/v1/last-leg/relabel-applications', { operationWarehouseId: 'W' }, { application: { relabelNo: 'RL-N', overseasWarehouseId: 'W' } }, 'MERCHANT')
dispatch('NDP', [candidate(null, '601', null, { sourceStage: 'RELABEL', orderRefs: 'RL-N', returnCartonSource: true })], 'RL-N', 1)
event('POST', '/api/platform/v1/last-leg/dispatches/NDP/transfer-delivery', {}, { outboundNo: 'O', deliveries: [{ deliveryNo: 'D' }] })
event('GET', '/api/platform/v1/last-leg/dispatches/NDP', undefined, { stage:'DELIVERING', documents:{outbounds:[{outboundNo:'O',customerId:'C'}],deliveries:[{deliveryNo:'D',outboundNo:'O',customerId:'C'}]} })
event('GET', '/api/platform/v1/last-leg/delivery-orders/D/return-inbound-options?customerId=C', undefined, [{ customerId: 'C', lastMileOrderId: '91', deliveryNo: 'D', outboundNo: 'O', returnSources: [{ returnCartonId: '81', cartonMark: 'BOX', inventoryPositionId: null }] }])
return { environment: 'TEST', marker: 'CURRENT', sourceCommit: commit, requests }
}
@@ -98,7 +98,7 @@ test('import/plan is zero-network and missing current fixtures never become a pa
const ctx = context(); assert.equal((await verifyDispatchRepair(ctx, {})).status, 'PLAN_ONLY'); assert.equal(ctx.calls.length, 0)
const result = await execute(ctx, {})
assert.equal(result.status, 'PARTIAL_NOT_ALL_VERIFIED')
assert.equal(Object.values(result.cases).every(row => row.status === 'NOT_RUN'), true)
assert.equal(Object.entries(result.cases).filter(([key])=>key!=='cancellation').every(([,row]) => row.status === 'NOT_RUN'), true)
assert.equal(result.sourceEventsVerified, false)
})
for (const field of ['lastlegHealthy', 'adminHealthy', 'schemaReady']) test(`unready ${field} blocks before any HTTP`, async () => {
@@ -137,25 +137,7 @@ test('failed pre-send checkpoint leaves the command unsent', async () => {
assert.equal(ctx.calls.length, 0)
})
test('new unpicked dispatch cancels outbound, retains cancelled history, re-arranges released pallets and replays old transfer', async () => {
let cancelled = false, dispatchCancelled = false, transfers = 0
const ctx = context((method, path, body) => {
if (path.endsWith('/transfer-delivery')) return ok({ outboundNo: 'O', deliveries: [{ deliveryNo: 'D' }], replayed: transfers++ > 0 })
if (path.includes('/delivery-orders/') && path.includes('/status')) { assert.ok(['DELIVERING', 'REJECTED', 'CANCELLED'].includes(body.status)); assert.equal(body.expectedVersion, 0); return reject() }
if (path.includes('/delivery-orders/')) return ok({ order: { deliveryNo: 'D', outboundNo: 'O', status: cancelled ? 'CANCELLED' : 'WAIT_DELIVERY', version: 0, packageCount: 1 } })
if (path.endsWith('/outbound-orders/O/cancel')) { cancelled = true; return ok(null) }
if (path.endsWith('/outbound-orders/O')) return ok({ outbound: { status: cancelled ? 'CANCELLED' : 'WAIT_OUTBOUND' } })
if (method === 'DELETE') { assert.equal(path, '/api/platform/v1/last-leg/dispatches/DP'); assert.equal(cancelled, true); dispatchCancelled = true; return ok(null) }
if (path.endsWith('/dispatches/DP')) return ok({ status: dispatchCancelled ? 'CANCELLED' : 'PENDING_DEPARTURE', createdAt: '2026-10-18T16:00:00', actions: { canTransfer: true } })
if (path.includes('/dispatches/candidates?')) return ok([{ planPalletId: '11' }])
if (path.endsWith('/pre-arrange')) { assert.deepEqual(body.planPalletIds, ['11']); assert.equal(dispatchCancelled, true); return ok({ dispatchNo: 'DP-NEW', status: 'PENDING_DEPARTURE', items: [{ planPalletId: '11' }] }) }
assert.fail(path)
})
const result = await execute(ctx, { cancellation: fresh({ allowDispatchCancellation: true, dispatchNo: 'DP', transferBody: { fees: [] }, rearrangeBody: { planPalletIds: ['11'] }, candidateQuery: { sourceStage: 'RECEIVED' } }) })
assert.equal(result.cases.cancellation.status, 'PASS')
assert.equal(ctx.calls.filter(row => row.method === 'DELETE').length, 1)
assert.equal(ctx.calls.some(row => row.method === 'DELETE' && row.path.includes('/items/')), false)
})
test('retired pre-departure cancellation is not reported as verified',async()=>{const ctx=context(),proof=await execute(ctx,{});assert.equal(proof.cases.cancellation.status,'NOT_APPLICABLE');assert.equal(ctx.calls.length,0);assert.notEqual(proof.status,'VERIFIED')})
test('domain rejection cannot be faked by a missing permission; actual shipment cancellation leaves facts unchanged', async () => {
for (const forbidden of [false, true]) {

Some files were not shown because too many files have changed in this diff Show More