diff --git a/AGENTS.md b/AGENTS.md index b28db77af..04e997b80 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,7 +132,11 @@ - 数据库只访问所属服务库。模块表结构位于 `/src/main/resources/db/`;开发环境按最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql` 收敛,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作结构来源和追溯,部署说明见 `deploy/sql/README.md`;生产升级另按明确发布规则处理。 - 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。 - 通用配置在 `config/nacos/test/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。 -- Java 签名变更的最低编译门禁是 `mvn -pl -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl -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 -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`。实际部署仍须当前对话明确授权。 diff --git a/README.md b/README.md index d1cf1d8a7..7611f0394 100644 --- a/README.md +++ b/README.md @@ -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`。 diff --git a/config/nacos/test/finance-service.yml b/config/nacos/test/finance-service.yml index dca977209..79267f412 100644 --- a/config/nacos/test/finance-service.yml +++ b/config/nacos/test/finance-service.yml @@ -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} diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 9a045e112..e99f710d2 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -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`。 diff --git a/lastleg-service/docs/business-index.md b/lastleg-service/docs/business-index.md index e7a751f4b..d628ca955 100644 --- a/lastleg-service/docs/business-index.md +++ b/lastleg-service/docs/business-index.md @@ -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不重写。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/service/OutsourcedBusinessService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/service/OutsourcedBusinessService.java index 7052b263e..185b14bae 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/service/OutsourcedBusinessService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/service/OutsourcedBusinessService.java @@ -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()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java index a3582c459..ac511bf51 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java @@ -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 NOT + """+POOLED_CARGO+""" AND d.planned_departure_at >= #{q.from} AND d.planned_departure_at < #{q.to} AND d.stage=#{q.stage} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessRows.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessRows.java index 38e66f20d..7bdd8d7b5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessRows.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessRows.java @@ -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; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessScope.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessScope.java index fcefe58c5..fabd6344b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessScope.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessScope.java @@ -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?"自有业务":"外配任务"));} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java index c3d96d31a..e32e526a7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java @@ -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()), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java index d9fb9d19e..f21ece7a1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java @@ -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("车辆归属与原装车事实不一致"); diff --git a/lastleg-service/src/main/resources/openapi/fleet-outsourced-business-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-outsourced-business-v1.yaml index b1067f20e..00f84a83f 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-outsourced-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-outsourced-business-v1.yaml @@ -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 diff --git a/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml index a65002c10..5a8fdf286 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml @@ -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" diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFixtures.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFixtures.java index 8f69de744..9424d9403 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFixtures.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFixtures.java @@ -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()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPersistenceTest.java index f932ce7f9..868db7081 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPersistenceTest.java @@ -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(""" diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessQueryTest.java index becb67302..a467180db 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessQueryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessQueryTest.java @@ -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() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java index 7101dd39f..d54ff5f29 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java @@ -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); diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 9cb00307e..1d9b08948 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -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` 只是入库测量与计费专项,不替代全量入口 | diff --git a/scripts/README.md b/scripts/README.md index d2307f60d..dd51f64f6 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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 -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`。 + ## 后端全量回归 ## 入口 diff --git a/scripts/acceptance-overview.mjs b/scripts/acceptance-overview.mjs new file mode 100644 index 000000000..66db95ca3 --- /dev/null +++ b/scripts/acceptance-overview.mjs @@ -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 }) +} diff --git a/scripts/acceptance-overview.test.mjs b/scripts/acceptance-overview.test.mjs new file mode 100644 index 000000000..169646f68 --- /dev/null +++ b/scripts/acceptance-overview.test.mjs @@ -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/) +}) diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index a51961e09..9b5d5ddbe 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -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' } : {}), diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index c999fbc7b..ce82425ce 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -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` 扩展只并发两个已经成功装车命令的跨端原键重放,并回查完整库存、主单、货物和车次;不是首次执行竞争。占用资源与未安排派送发车分别串行验证拒绝后快照不变。签收成功后的自动完结只做有界只读等待,保持真实写回执,等待失败阻断下一请求而不伪报已知写为未知结果。 diff --git a/scripts/backend-check.ps1 b/scripts/backend-check.ps1 new file mode 100644 index 000000000..ec0279984 --- /dev/null +++ b/scripts/backend-check.ps1 @@ -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 diff --git a/scripts/backend-check.test.mjs b/scripts/backend-check.test.mjs new file mode 100644 index 000000000..e88b469ad --- /dev/null +++ b/scripts/backend-check.test.mjs @@ -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/) +})