This commit is contained in:
2026-10-21 13:22:35 +08:00
parent b4ff441a70
commit 337da6d67c
25 changed files with 528 additions and 43 deletions
+5 -1
View File
@@ -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`。
+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}
+1 -1
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`。
+2 -2
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不重写。
@@ -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,24 @@ import java.util.List;
/** 在原排车事实之上做自有运营分页,不复制或推进履约模型。 */
@Mapper
public interface OwnedBusinessQueryMapper {
/** 拼车登记须已实收并绑定本车,且原实收来源仍在本车计划托盘组成内;多票只判存在。 */
String POOLED_CARGO="""
EXISTS (SELECT 1 FROM ll_fleet_external_booking b
JOIN ll_warehouse_transfer_order incoming ON incoming.customer_id=b.customer_id AND incoming.transfer_no=b.incoming_no
WHERE b.trip_id=d.trip_id AND b.dispatch_no=d.dispatch_no 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 allocation.customer_id=cargo.customer_id
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 cargo.customer_id=b.customer_id
AND line.customer_id=b.customer_id AND line.inbound_code=incoming.inbound_code
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 +36,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("车辆归属与原装车事实不一致");
@@ -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"
@@ -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,56 @@ 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"));
}
/** 外配档案和临时车辆分别保持真实ID/空ID及原承运商,GET不建记录。 */
@Test void masterAndExternalReadOriginalSnapshotWithoutBackfill() {
var master=outsourced.detail(actor(),"DS-OUT-2");var external=outsourced.detail(actor(),"DS-OUT-3");
@@ -210,7 +266,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);
+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` 只是入库测量与计费专项,不替代全量入口 |
+54 -2
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`。
@@ -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' } : {}),
+2
View File
@@ -1,5 +1,7 @@
# Admin 测试环境 HTTP 模块
先运行 `node scripts/acceptance-overview.mjs`(后端根目录,无网络)查看当前默认与可选模块。范围来自真实注册表;本文件描述各模块用途与依赖,不代表所有模块默认执行或已通过。报告解读及跨端/专项边界见 [总索引](../README.md#先看范围再看证据),最新运行状态只查外层部署契约。
`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` 不证明环境通过。
其 `competition` 扩展只并发两个已经成功装车命令的跨端原键重放,并回查完整库存、主单、货物和车次;不是首次执行竞争。占用资源与未安排派送发车分别串行验证拒绝后快照不变。签收成功后的自动完结只做有界只读等待,保持真实写回执,等待失败阻断下一请求而不伪报已知写为未知结果。
+117
View File
@@ -0,0 +1,117 @@
[CmdletBinding()]
param(
[ValidateSet('Compile', 'Focused', 'Module')][string]$Mode = 'Module',
[string]$Modules,
[string]$Tests,
[switch]$DryRun,
[string]$MavenCommand = 'mvn'
)
$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$pom = Join-Path $repoRoot 'pom.xml'
# 显式选择当前聚合模块,避免漏传参数时意外触发全仓测试。
function Read-Selection([string]$Value, [string]$Name) {
if ([string]::IsNullOrWhiteSpace($Value)) { throw "$Name is required." }
$items = @($Value.Split(',') | ForEach-Object { $_.Trim() })
if ($items -contains '') { throw "$Name contains an empty selection." }
return @($items | Select-Object -Unique)
}
try {
[xml]$rootPom = Get-Content -LiteralPath $pom -Raw
$selectedModules = @(Read-Selection $Modules 'Modules')
foreach ($module in $selectedModules) {
if ($module -cnotin @($rootPom.project.modules.module)) {
throw "Unknown active module: $module"
}
}
$selectedTests = @()
if ($Mode -eq 'Focused') {
$requestedTests = @(Read-Selection $Tests 'Tests')
# 只接受明确类名,不接受通配符或方法筛选;每个请求必须唯一命中所选模块。
foreach ($requested in $requestedTests) {
if ($requested -cnotmatch '^[A-Za-z_$][A-Za-z0-9_$]*(\.[A-Za-z_$][A-Za-z0-9_$]*)*$') {
throw "Tests must contain exact Java class names: $requested"
}
$matches = @(foreach ($module in $selectedModules) {
$testRoot = Join-Path $repoRoot "$module/src/test/java"
if (-not (Test-Path -LiteralPath $testRoot)) { continue }
foreach ($file in Get-ChildItem -LiteralPath $testRoot -Recurse -File -Filter "$($requested.Split('.')[-1]).java") {
$source = Get-Content -LiteralPath $file.FullName -Raw
$package = [regex]::Match($source, '(?m)^\s*package\s+([\w.]+)\s*;').Groups[1].Value
$className = if ($package) { "$package.$($file.BaseName)" } else { $file.BaseName }
if ($requested -ceq $file.BaseName -or $requested -ceq $className) {
[pscustomobject]@{ module = $module; className = $className }
}
}
})
if ($matches.Count -ne 1) { throw "Test must match exactly one source in selected modules: $requested (matches: $($matches.Count))" }
$selectedTests += $matches[0]
}
} elseif ($Tests) {
throw 'Tests is only supported in Focused mode.'
}
} catch {
Write-Host "INVALID CHECK: $($_.Exception.Message)"
exit 2
}
$mavenArgs = @('-T', '1', '-DforkCount=1', '-DreuseForks=true', '-f', $pom, '-pl', ($selectedModules -join ','), '-am')
if ($Mode -eq 'Compile') {
$mavenArgs += 'test-compile'
} else {
$mavenArgs += 'test'
}
if ($Mode -eq 'Focused') {
# 上游依赖可能没有所选类,允许该模块零命中;执行后逐类核对本轮报告。
$mavenArgs += "-Dtest=$((@($selectedTests.className | Select-Object -Unique)) -join ',')"
$mavenArgs += '-Dsurefire.failIfNoSpecifiedTests=false'
}
Write-Host "Backend check: $Mode; selected modules: $($selectedModules -join ','); upstream dependencies included"
Write-Host 'Downstream consumers are NOT auto-selected; include affected consumers explicitly.'
Write-Host ("Command: {0} {1}" -f $MavenCommand, ($mavenArgs -join ' '))
if ($DryRun) {
Write-Host 'DRY-RUN PASS: selection validated; no tests or compilation executed.'
exit 0
}
# 整批占用现有重型名额;已受保护的批次直接执行,不嵌套申请锁。
if (-not $env:CHONGCHONG_LOCAL_CHECK_OWNER) {
$childArgs = @('pwsh', '-NoProfile', '-File', $PSCommandPath, '-Mode', $Mode, '-Modules', ($selectedModules -join ','), '-MavenCommand', $MavenCommand)
if ($Mode -eq 'Focused') { $childArgs += @('-Tests', $Tests) }
& (Join-Path $PSScriptRoot 'local-check.ps1') -WorkingDirectory $repoRoot -Label "Backend $Mode check" -Command $childArgs
exit $LASTEXITCODE
}
$startedAt = [DateTime]::UtcNow
Push-Location $repoRoot
try {
& $MavenCommand @mavenArgs
$code = $LASTEXITCODE
if ($code -ne 0) { exit $code }
if ($Mode -eq 'Focused') {
foreach ($selection in $selectedTests) {
$reportDir = Join-Path $repoRoot "$($selection.module)/target/surefire-reports"
$executed = 0
if (Test-Path -LiteralPath $reportDir) {
foreach ($report in Get-ChildItem -LiteralPath $reportDir -File -Filter 'TEST-*.xml') {
if ($report.LastWriteTimeUtc -lt $startedAt) { continue }
[xml]$xml = Get-Content -LiteralPath $report.FullName -Raw
$suite = $xml.testsuite
if ($suite.name -ceq $selection.className -or ([string]$suite.name).StartsWith($selection.className + '$', [StringComparison]::Ordinal)) {
if ([int]$suite.failures -gt 0 -or [int]$suite.errors -gt 0) { throw "Failed test report: $($suite.name)" }
$executed += [int]$suite.tests - [int]$suite.skipped
}
}
}
if ($executed -le 0) { throw "No executed tests in fresh reports for $($selection.className). Missing, stale or all-skipped reports cannot pass." }
Write-Host "Verified $($selection.className): $executed executed tests"
}
}
} finally {
Pop-Location
}
Write-Host "Backend $Mode check PASS (not a full regression or HTTP acceptance)"
exit 0
+82
View File
@@ -0,0 +1,82 @@
import assert from 'node:assert/strict'
import { spawn } from 'node:child_process'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const launcher = fileURLToPath(new URL('./backend-check.ps1', import.meta.url))
const quote = value => `'${String(value).replaceAll("'", "''")}'`
// 运行真实 PowerShell 参数解析与入口,执行分支使用内存 Maven 替身,不启动 Java。
function run(args, mockExit) {
if (mockExit !== undefined) assert.ok(process.env.CHONGCHONG_LOCAL_CHECK_OWNER, 'Run tests through local-check.ps1')
const mock = mockExit === undefined ? '' : `function global:Mock-Maven { Write-Host ('MOCK_ARGS:' + (ConvertTo-Json -Compress -InputObject @($args))); $global:LASTEXITCODE = ${mockExit} }; `
const parameters = []
for (let i = 0; i < args.length; i++) {
const key = args[i].slice(1)
parameters.push(`${quote(key)}=${key === 'DryRun' ? '$true' : quote(args[++i])}`)
}
if (mockExit !== undefined) parameters.push("MavenCommand='Mock-Maven'")
const command = `$ErrorActionPreference='Stop'; ${mock}$parameters=@{${parameters.join(';')}}; & ${quote(launcher)} @parameters; exit $LASTEXITCODE`
return new Promise((resolve, reject) => {
const child = spawn('pwsh', ['-NoProfile', '-NonInteractive', '-OutputFormat', 'Text', '-EncodedCommand', Buffer.from(command, 'utf16le').toString('base64')])
let output = ''
child.stdout.on('data', chunk => { output += chunk })
child.stderr.on('data', chunk => { output += chunk })
child.on('error', reject)
child.on('close', code => resolve({ code, output }))
})
}
test('module selection is mandatory and rejects inactive modules and injected Maven options', async () => {
for (const args of [[], ['-Modules', 'gateway-overseas'], ['-Modules', 'order,'], ['-Modules', '-DskipTests']]) {
const result = await run([...args, '-DryRun'])
assert.equal(result.code, 2, result.output)
assert.doesNotMatch(result.output, /DRY-RUN PASS/)
}
})
test('default selection runs only requested modules plus upstream dependencies', async () => {
const result = await run(['-Modules', 'order,firstleg,order', '-DryRun'])
assert.equal(result.code, 0, result.output)
assert.match(result.output, /-pl order,firstleg -am test/)
assert.doesNotMatch(result.output, /skipTests|maven.test.skip|backend-full-regression/)
})
test('compile retains compilation of test sources without executing tests', async () => {
const result = await run(['-Mode', 'Compile', '-Modules', 'order'], 0)
assert.equal(result.code, 0, result.output)
assert.match(result.output, /MOCK_ARGS:.*"-pl","order","-am","test-compile"/)
assert.match(result.output, /Compile check PASS/)
})
test('focused selection resolves exact classes and rejects missing or broad selectors', async () => {
const good = await run(['-Mode', 'Focused', '-Modules', 'common-core', '-Tests', 'ApiResponseTest,BoundedRetryPolicyTest', '-DryRun'])
assert.equal(good.code, 0, good.output)
assert.match(good.output, /-Dtest=com.baiyue.common.core.ApiResponseTest,com.baiyue.common.core.BoundedRetryPolicyTest/)
for (const name of ['MissingTest', '*Test', 'ApiResponseTest#method']) {
const result = await run(['-Mode', 'Focused', '-Modules', 'common-core', '-Tests', name, '-DryRun'])
assert.equal(result.code, 2, result.output)
}
})
test('test filter cannot silently apply to compile or module mode', async () => {
for (const mode of ['Compile', 'Module']) {
const result = await run(['-Mode', mode, '-Modules', 'common-core', '-Tests', 'ApiResponseTest', '-DryRun'])
assert.equal(result.code, 2, result.output)
}
const missing = await run(['-Mode', 'Focused', '-Modules', 'common-core', '-DryRun'])
assert.equal(missing.code, 2, missing.output)
})
test('Maven failure is propagated without claiming success', async () => {
const result = await run(['-Modules', 'common-core'], 7)
assert.equal(result.code, 7, result.output)
assert.doesNotMatch(result.output, /check PASS/)
})
test('Maven success without freshly executed selected tests cannot pass', async () => {
const result = await run(['-Mode', 'Focused', '-Modules', 'common-core', '-Tests', 'ApiResponseTest'], 0)
assert.notEqual(result.code, 0, result.output)
assert.match(result.output, /No executed tests in fresh reports/)
assert.doesNotMatch(result.output, /check PASS/)
})