Compare commits

..
Author SHA1 Message Date
ouyangxq 55436e3a0a fix(firstleg): align warehouse arrival sources on latest observed port fact 2026-10-21 16:45:42 +08:00
ouyangxq c962f614e6 优化 2026-10-21 16:40:18 +08:00
ouyangxq 090591076f 优化 2026-10-21 15:53:33 +08:00
ouyangxq 94595c810b fix(ci): retain configured image repository defaults 2026-10-21 15:28:44 +08:00
55 changed files with 1132 additions and 116 deletions
+2 -2
View File
@@ -133,9 +133,9 @@
- 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。
- 通用配置在 `config/nacos/test/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
- 后端本地验证默认使用 `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/事件消费者;不能以单类测试代替模块收尾。
- 局部逻辑使用 `-Mode Focused -Tests <精确测试类名>`,覆盖修改行为及必要边界即可交付,不因收尾再跑 `Module`。Java 签名变化须覆盖生产与测试源码编译;定向测试已编译时不重复跑 `-Mode Compile`。只有影响扩及服务内多个行为且定向覆盖不足才选 `Module`,先说明原因。跨服务 DTO、接口或事件变化显式列入生产者和实际消费者;`-am` 仅补上游构建依赖,不自动包含下游或 HTTP/事件消费者。
- 仅注释/文档调整不触发 Java 回归;脚本调整执行受影响脚本测试和登记合同。API、Mapper、AGENTS 等静态合同仍按各自触发条件执行。纯重构也应覆盖受影响行为,不能只用编译宣称回归通过。`-Dmaven.test.skip=true` 跳过测试编译,不得作为签名变更交付验证。
- 全仓回归只在以下情况触发:根 POM/统一依赖版本/聚合拓扑/测试基础配置变化;公共认证、序列化、基础模型等影响广泛且不能可靠限定消费者;跨多域批次集成验收;用户明确要求全量回归。使用 `scripts/backend-full-regression.ps1`,保留当前 CI 全量验证。普通单服务任务、单次提交或对话收尾不自动触发全仓;已明确消费者的公共或跨服务变更按受影响模块验证。同批同一相关输入只跑一次,输入变化后补跑受影响范围,触及上述全量条件再重跑全量。
- 全仓回归仅用于已确认影响广泛且定向覆盖不足的公共依赖/聚合拓扑/认证/序列化/基础模型变化,或用户要求的全量/跨域集成验收;先说明具体影响,不能因未查清范围而全跑。根 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、不连接数据库;遗漏注解、扫描根漂移或重新引入子域扫描时直接失败并输出类与文件路径。
Vendored
+2 -1
View File
@@ -161,7 +161,8 @@ pipeline {
choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境')
booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署')
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试执行(仍编译测试源码)')
string(name: 'IMAGE_REPOSITORY', defaultValue: '', description: '必须填写已核实的共享镜像仓库地址 host[:port]/owner/image')
// 地址由当前 Job 配置提供;参数声明刷新时保留已配置值,不将环境地址写入源码。
string(name: 'IMAGE_REPOSITORY', defaultValue: params.IMAGE_REPOSITORY ?: '', description: '沿用当前 Job 已配置的镜像仓库地址;首次配置或更换仓库时填写 host[:port]/owner/image')
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
choice(name: 'DOWNSTREAM_JOB_PREFIX', choices: downstreamJobPrefixChoicesForJob(env.JOB_BASE_NAME), description: '由当前总控 Job 固定的单服务 Job 前缀')
}
+2 -1
View File
@@ -49,7 +49,8 @@ pipeline {
booleanParam(name: 'ALLOW_SAME_CONTRACT_ROLLBACK', defaultValue: false, description: '仅确认相同协议与Schema时允许回滚;架构切换保持关闭')
booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务')
booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试')
string(name: 'IMAGE_REPOSITORY', defaultValue: '', description: '必须填写已核实的共享镜像仓库基础路径,不包含服务名和标签')
// 独立构建沿用本 Job 配置,总控调度时保留其明确传入的地址。
string(name: 'IMAGE_REPOSITORY', defaultValue: params.IMAGE_REPOSITORY ?: '', description: '沿用当前 Job 已配置的镜像仓库基础路径,不包含服务名和标签;首次配置或更换仓库时填写')
string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像')
}
@@ -429,6 +429,7 @@ CREATE TABLE ll_dispatch_order (
review_state VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '审核子流程当前状态',
review_round BIGINT NOT NULL DEFAULT 0 COMMENT '当前审核轮次',
review_basis_hash CHAR(64) NULL COMMENT '本审核轮次核对的货物和资源依据摘要',
exceptions_json JSON NULL COMMENT '派送异常登记记录及图片引用,不改变派送及库存状态',
dispatch_review_json JSON NULL COMMENT '当前调度审核事实快照',
loading_review_json JSON NULL COMMENT '当前理货审核事实快照',
resources_json JSON NULL COMMENT '本趟主档或临时车辆司机的真实资源快照',
@@ -11,6 +11,7 @@ enum BusinessFileCategory {
FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
OVERSEAS_DISPATCH_FUEL_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG),
@@ -79,9 +80,9 @@ enum BusinessFileCategory {
&& tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "排车运营照片必须归属平台");
}
if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE)
if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE || this == OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE)
&& tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "排车车辆及司机证据必须归属平台");
throw new BizException(ResultCode.BAD_REQUEST, "排车证据必须归属平台");
}
if (this == FLEET_TRIP_OPERATION_EVIDENCE && tenantId != 0) {
throw new BizException(ResultCode.BAD_REQUEST, "出车实测凭证必须归属平台");
@@ -6,7 +6,7 @@ info:
复用 InternalBusinessFileController 的现有通用内部入口,不新增公开文件路由。
三类证据均固定平台 tenantId=0,businessId 为排车内部正数 Long ID 的十进制表示,
不使用车辆 ID、司机 ID、车牌或客户 ID。车辆与司机证据分别使用
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE,不可互换。司机本人照片DRIVER_PHOTO仅支持JPG/PNG;其他两类沿PDF/JPG/PNG。
OVERSEAS_DISPATCH_VEHICLE_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE,不可互换。司机本人照片DRIVER_PHOTO和排车异常图片EXCEPTION仅支持JPG/PNG;其他两类沿PDF/JPG/PNG。
File 校验内部服务令牌、平台归属、正数业务 ID、文件非空、最大 10MiB(10485760 字节,包含边界)、
PDF/JPG/PNG MIME 及对应内容签名。元数据和下载按 tenantId、businessType、businessId、fileId
联合查询 ACTIVE 且未删除文件,错车或错类别均视为归属不匹配,下载前完成归属校验。
@@ -126,7 +126,7 @@ components:
description: 正数Long的十进制字符串,值不得超过9223372036854775807
DispatchEvidenceType:
type: string
enum: [OVERSEAS_DISPATCH_VEHICLE_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE]
enum: [OVERSEAS_DISPATCH_VEHICLE_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE, OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE]
MetadataEnvelope:
type: object
required: [code, data]
@@ -30,6 +30,7 @@ import static org.mockito.Mockito.*;
class DispatchEvidenceFileTest {
private static final String VEHICLE = "OVERSEAS_DISPATCH_VEHICLE_EVIDENCE";
private static final String EXCEPTION = "OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE";
private static final String PHOTO = "OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE";
private static final String DRIVER = "OVERSEAS_DISPATCH_DRIVER_EVIDENCE";
private static final int MAX_BYTES = 10 * 1024 * 1024;
@@ -43,7 +44,7 @@ class DispatchEvidenceFileTest {
/** 三类证据只接受平台与有效排车内部 ID,非法输入不得访问数据库或磁盘。 */
@ParameterizedTest
@ValueSource(strings = {VEHICLE, DRIVER, PHOTO})
@ValueSource(strings = {VEHICLE, DRIVER, PHOTO, EXCEPTION})
void requiresPlatformAndPositiveDispatchIdBeforeAccess(String type) {
var files = mock(FileObjectMapper.class);
var storage = mock(BusinessFileStorage.class);
@@ -65,9 +66,10 @@ class DispatchEvidenceFileTest {
verifyNoInteractions(files, storage);
}
/** 司机本人照片使用独立平台类别,只保存真实图片,拒绝PDF和伪造图片。 */
@org.junit.jupiter.api.Test void driverPhotoAcceptsOnlyRealImages() throws Exception {
var storage=storage();var category=BusinessFileCategory.require(PHOTO);
/** 司机照片与异常图片使用独立平台类别,拒绝PDF和伪造图片。 */
@ParameterizedTest @ValueSource(strings = {PHOTO, EXCEPTION})
void photoAcceptsOnlyRealImages(String type) throws Exception {
var storage=storage();var category=BusinessFileCategory.require(type);
for(var format:new String[]{"jpeg","png"}) {
var upload=new MockMultipartFile("file","driver."+format,"image/"+format,image(format));
var saved=storage.store(0,"71",upload,category);
@@ -162,7 +164,7 @@ class DispatchEvidenceFileTest {
/** 元数据和下载均逐列带全复合键;错车、错类别、错文件不得读取存储内容。 */
@ParameterizedTest
@ValueSource(strings = {VEHICLE, DRIVER, PHOTO})
@ValueSource(strings = {VEHICLE, DRIVER, PHOTO, EXCEPTION})
void metadataAndDownloadEnforceEveryScopeKeyBeforeReading(String type) throws Exception {
var files = mock(FileObjectMapper.class);
var storage = mock(BusinessFileStorage.class);
@@ -63,6 +63,7 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
""")
CarrierScheduleQueryEntity findByContainerBookingId(@Param("containerBookingId") long containerBookingId);
/** 到港与日历统一取最新观测的实际记录,后续修正为更早时间时不能被历史最大值覆盖。 */
@Select("""
<script>
SELECT b.id AS containerBookingId,q.id AS queryId,q.version AS queryVersion,
@@ -70,7 +71,7 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
current.actual_at AS latestActualAt,
MAX(CASE WHEN f.standard_node='DEPARTED_WAREHOUSE' THEN f.actual_at END) AS departedWarehouseAt,
MAX(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT' THEN f.actual_at END) AS departedOriginPortAt,
MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT' THEN f.actual_at END) AS arrivedDestinationPortAt,
MAX(arrival.actual_at) AS arrivedDestinationPortAt,
MAX(CASE WHEN f.standard_node='PICKUP_READY' THEN f.actual_at END) AS pickupReadyAt,
MAX(CASE WHEN f.standard_node='PICKED_UP' THEN f.actual_at END) AS pickedUpAt,
MAX(CASE WHEN f.standard_node='EMPTY_RETURNED' THEN f.actual_at END) AS emptyReturnedAt
@@ -81,6 +82,10 @@ public interface CarrierScheduleQueryMapper extends BaseMapper<CarrierScheduleQu
ORDER BY f2.observed_at DESC,f2.id DESC LIMIT 1)
LEFT JOIN fl_carrier_schedule_query q ON q.id=current.query_id
LEFT JOIN fl_carrier_schedule_node_fact f ON f.container_booking_id=b.id AND f.actual_at IS NOT NULL
LEFT JOIN fl_carrier_schedule_node_fact arrival ON arrival.id=(
SELECT a.id FROM fl_carrier_schedule_node_fact a
WHERE a.container_booking_id=b.id AND a.standard_node='ARRIVED_DESTINATION_PORT' AND a.actual_at IS NOT NULL
ORDER BY a.observed_at DESC,a.id DESC LIMIT 1)
WHERE b.id IN
<foreach collection="containerBookingIds" item="id" open="(" separator="," close=")">#{id}</foreach>
GROUP BY b.id,q.id,q.version,q.status,current.standard_node,current.actual_at
@@ -44,8 +44,9 @@ class CarrierScheduleQuerySqlContractTest {
}
}
/** 保留批量读取,实际到港与日历采用相同的最新观测顺序。 */
@Test
void batchesActualContainerFactsWithoutEstimatedOrPerNodeQueries() throws Exception {
void batchesActualContainerFactsWithLatestObservedArrivalAndWithoutEstimatedTimes() throws Exception {
String sql = String.join("\n", CarrierScheduleQueryMapper.class
.getMethod("selectContainerActualFacts", List.class)
.getAnnotation(Select.class).value());
@@ -55,13 +56,16 @@ class CarrierScheduleQuerySqlContractTest {
.contains("f.actual_at IS NOT NULL")
.contains("MAX(CASE WHEN f.standard_node='DEPARTED_WAREHOUSE'")
.contains("MAX(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT'")
.contains("MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT'")
.contains("MAX(arrival.actual_at) AS arrivedDestinationPortAt")
.contains("a.standard_node='ARRIVED_DESTINATION_PORT' AND a.actual_at IS NOT NULL")
.contains("ORDER BY a.observed_at DESC,a.id DESC LIMIT 1")
.doesNotContain("MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT'")
.contains("MAX(CASE WHEN f.standard_node='PICKUP_READY'")
.contains("MAX(CASE WHEN f.standard_node='PICKED_UP'")
.contains("MAX(CASE WHEN f.standard_node='EMPTY_RETURNED'")
.contains("ORDER BY b.id")
.doesNotContain("estimated_at");
assertThat(count(sql, "fl_carrier_schedule_node_fact")).isEqualTo(3);
assertThat(count(sql, "fl_carrier_schedule_node_fact")).isEqualTo(5);
assertThat(count(sql, "SELECT f2.id FROM fl_carrier_schedule_node_fact f2")).isEqualTo(1);
}
@@ -2,6 +2,7 @@ package com.baiyue.firstleg.terminaldrayage.mapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryMapper;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
@@ -10,6 +11,51 @@ import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.*;
class ContainerCalendarSourceMapperTest {
/** 后续观测可将实际到港修正为更早日期,两入口须取同一条最新实际记录。 */
@Test
void usesLatestObservedArrivalIncludingSameObservationIdTieBreakAcrossBothEntrypoints() throws Exception {
var ds = new JdbcDataSource();
ds.setURL("jdbc:h2:mem:arrival-source-correction;MODE=MySQL;DB_CLOSE_DELAY=-1");
try (var connection = ds.getConnection(); var sql = connection.createStatement()) {
sql.execute("CREATE TABLE fl_container_booking(id BIGINT PRIMARY KEY,cabinet_no VARCHAR,container_no VARCHAR," +
"vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_warehouse_code VARCHAR," +
"actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,unloading_completed_at TIMESTAMP,record_status VARCHAR)");
sql.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)");
sql.execute("CREATE TABLE fl_carrier_schedule_query(id BIGINT,version INT,status VARCHAR)");
sql.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,query_id BIGINT,container_booking_id BIGINT," +
"standard_node VARCHAR,actual_at TIMESTAMP,observed_at TIMESTAMP)");
sql.execute("CREATE TABLE fl_direct_delivery_unloading(container_booking_id BIGINT,actual_at TIMESTAMP)");
sql.execute("CREATE TABLE fl_outbound_dispatch(container_booking_id BIGINT,confirmed_at TIMESTAMP,reversed_at TIMESTAMP,status VARCHAR)");
sql.execute("INSERT INTO fl_container_booking(id,cabinet_no,estimated_port_arrival_at,record_status) VALUES " +
"(1,'D1','2026-10-29 08:00:00','ACTIVE'),(2,'D2','2026-10-29 08:00:00','ACTIVE')");
sql.execute("INSERT INTO fl_carrier_schedule_query VALUES(1,0,'READY'),(2,0,'READY')");
sql.execute("INSERT INTO fl_outbound_dispatch SELECT id,'2026-10-01 00:00:00',NULL,'WAITING_FOR_LOADING' FROM fl_container_booking");
sql.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES " +
"(1,1,1,'ARRIVED_DESTINATION_PORT','2026-10-23 14:00:00','2026-10-24 14:00:00')," +
"(2,1,1,'ARRIVED_DESTINATION_PORT','2026-10-21 14:00:00','2026-10-25 14:00:00')," +
"(3,1,1,'ARRIVED_DESTINATION_PORT',NULL,'2026-10-26 14:00:00')," +
"(4,2,2,'ARRIVED_DESTINATION_PORT','2026-10-24 14:00:00','2026-10-25 14:00:00')," +
"(5,2,2,'ARRIVED_DESTINATION_PORT','2026-10-22 14:00:00','2026-10-25 14:00:00')");
}
var config = new MybatisConfiguration();
config.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds));
config.addMapper(ContainerCalendarSourceMapper.class);
config.addMapper(CarrierScheduleQueryMapper.class);
try (var session = new MybatisSqlSessionFactoryBuilder().build(config).openSession()) {
var from = LocalDateTime.parse("2026-10-01T00:00:00");
var calendar = session.getMapper(ContainerCalendarSourceMapper.class).sources(from, from.minusDays(2), from.minusDays(4), from.plusMonths(1));
var workbench = session.getMapper(CarrierScheduleQueryMapper.class).selectContainerActualFacts(java.util.List.of(1L, 2L));
assertEquals(2, calendar.size());
assertEquals(2, workbench.size());
for (int index = 0; index < calendar.size(); index++) {
var expected = LocalDateTime.parse(index == 0 ? "2026-10-21T14:00:00" : "2026-10-22T14:00:00");
assertEquals(expected, calendar.get(index).arrivalAt());
assertEquals("ACTUAL", calendar.get(index).arrivalType());
assertEquals(expected, workbench.get(index).arrivedDestinationPortAt());
assertEquals(calendar.get(index).arrivalAt().plusDays(2).toLocalDate(), workbench.get(index).arrivedDestinationPortAt().plusDays(2).toLocalDate());
}
}
}
/** 使用实际 Mapper 核对跨月默认日期候选、缺日期与真实出仓边界。 */
@Test
void selectsShiftedArrivalUndatedAndOlderReturnCandidatesWithoutInactiveOrUndepartedContainers() throws Exception {
@@ -77,6 +77,12 @@ public class DispatchAggregateController {
public ApiResponse<CommandResult> loadingComplete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.LoadingComplete request) {
return ApiResponse.success(loadingDeparture.complete(DispatchActorFactory.platform(user),dispatchNo,request));
}
/** 派送异常登记不触发签收或退货入库。 */
@PostMapping("/{dispatchNo}/exceptions")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<CommandResult> markException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.MarkException request) {
return ApiResponse.success(service.markException(DispatchActorFactory.platform(user),dispatchNo,request));
}
/** 本车证据先上传;维护和当前待审权限分别由服务端核验,引用随对应命令保存。 */
@PostMapping(value="/{dispatchNo}/evidence/{kind}",consumes=org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAnyAuthority('platform:overseas-warehouse:dispatch:edit','platform:fleet:trip:dispatch-review','platform:fleet:trip:loading-review')")
@@ -26,7 +26,7 @@ public class SmartDispatchController {
/** 读取实际候选,不在草稿域复制来源筛选逻辑。 */
@GetMapping("/candidates")
@PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_LIST+"')")
public ApiResponse<CandidateList> candidates(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String warehouseId,
public ApiResponse<CandidateList> candidates(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam(required=false) String warehouseId,
@RequestParam(required=false)List<Long> planIds,@RequestParam(required=false)String orderNo,
@RequestParam(required=false)String destinationWarehouseCode,@RequestParam(required=false)String sourceStage,@RequestParam(required=false)String draftToken,
@RequestParam(defaultValue="1")int page,@RequestParam(defaultValue="20")int size,@RequestParam(defaultValue="ALL")String warehouseTab) {
@@ -24,6 +24,7 @@ public class DispatchAggregate {
private String dispatchReviewJson;
private String loadingReviewJson;
private String resourcesJson;
private String exceptionsJson;
private String vehiclePlate;
private String route;
private LocalDateTime plannedDepartureAt;
@@ -4,7 +4,7 @@ import org.springframework.web.multipart.MultipartFile;
/** 车辆、司机及审核补充证据属于本张整车,不能借司机主档或客户文件授权。 */
public interface DispatchEvidenceGateway {
enum Kind { VEHICLE, DRIVER, DRIVER_PHOTO }
enum Kind { VEHICLE, DRIVER, DRIVER_PHOTO, EXCEPTION }
record File(Long fileId,String fileName,String contentType,long sizeBytes) { }
/** 上传只写本车、本类证据。 */
File upload(long dispatchId,Kind kind,String operator,MultipartFile file);
@@ -83,9 +83,9 @@ public class HttpDispatchEvidenceGateway implements DispatchEvidenceGateway {
|| !supportedType(kind,value.contentType()) || value.sizeBytes()==null || value.sizeBytes()<=0 || value.sizeBytes()>MAX)throw invalid();
return new File(value.fileId(),value.fileName(),value.contentType(),value.sizeBytes());
}
/** 司机本人照片只接受图片;车辆和驾驶证沿现有PDF/图片证据规则。 */
/** 司机本人照片与异常证据只接受图片;车辆和驾驶证沿现有PDF/图片证据规则。 */
private static boolean supportedType(Kind kind,String contentType) {
return TYPES.contains(Objects.toString(contentType,"")) && (kind!=Kind.DRIVER_PHOTO || !"application/pdf".equals(contentType));
return TYPES.contains(Objects.toString(contentType,"")) && ((kind!=Kind.DRIVER_PHOTO && kind!=Kind.EXCEPTION) || !"application/pdf".equals(contentType));
}
/** 无效证据不能作为已核实资料。 */
private static ResponseStatusException invalid(){return new ResponseStatusException(HttpStatus.BAD_REQUEST,"证据须为本车有效PDF、JPG或PNG,最大10MiB");}
@@ -94,7 +94,7 @@ public interface DispatchAggregateMapper {
UPDATE ll_dispatch_order SET status=#{r.status},stage=#{r.stage},record_version=#{r.recordVersion},
review_state=#{r.reviewState},review_round=#{r.reviewRound},review_basis_hash=#{r.reviewBasisHash},
dispatch_review_json=#{r.dispatchReviewJson},loading_review_json=#{r.loadingReviewJson},
cargo_hash=#{r.cargoHash},cargo_revision=#{r.cargoRevision},resources_json=#{r.resourcesJson},vehicle_plate=#{r.vehiclePlate},planned_departure_at=#{r.plannedDepartureAt},
exceptions_json=#{r.exceptionsJson},cargo_hash=#{r.cargoHash},cargo_revision=#{r.cargoRevision},resources_json=#{r.resourcesJson},vehicle_plate=#{r.vehiclePlate},planned_departure_at=#{r.plannedDepartureAt},
estimated_arrival_at=#{r.estimatedArrivalAt},remark=#{r.remark},loading_completed_at=#{r.loadingCompletedAt},
loading_completion_hash=#{r.loadingCompletionHash},loading_confirmation_json=#{r.loadingConfirmationJson},arrangement_key=#{r.arrangementKey},
arrangement_business_hash=#{r.arrangementBusinessHash},departed_at=#{r.departedAt},
@@ -50,7 +50,8 @@ public interface DispatchCandidatePageMapper {
}
/** 仓库页签也是服务端筛选,分页后不能再用本地筛选伪装完整结果。 */
private static String filtered(boolean count) {
return " FROM ("+rows(count)+") candidates WHERE overseasWarehouseId=#{query.warehouseId}"
return " FROM ("+rows(count)+") candidates WHERE 1=1"
+"<if test='query.warehouseId != null'> AND overseasWarehouseId=#{query.warehouseId}</if>"
+"<if test='query.destinationWarehouseCode != null'> AND fbaWarehouseCode=#{query.destinationWarehouseCode}</if>"
+"<if test=\"query.warehouseTab == 'LAX'\"> AND LOWER(CONCAT(COALESCE(overseasWarehouseCode,''),' ',COALESCE(overseasWarehouseName,''))) REGEXP 'lax|洛杉矶|los angeles'</if>"
+"<if test=\"query.warehouseTab == 'NYC'\"> AND LOWER(CONCAT(COALESCE(overseasWarehouseCode,''),' ',COALESCE(overseasWarehouseName,''))) REGEXP 'nyc|jfk|(^|[^a-z])ny([^a-z]|$)|纽约|new[ -]?york'</if>";
@@ -73,6 +73,13 @@ public final class DispatchCommands {
public LoadingComplete { recordVersion = requireVersion(recordVersion); }
}
/** 派送异常仅登记事实,不改变派送、签收或库存。 */
public record MarkException(Long recordVersion, String idempotencyKey, ExceptionType exceptionType,
ReturnDirection returnDirection, String description, List<Long> imageFileIds) {
/** 必填版本用于阻止过期页面覆盖当前排车。 */
public MarkException { recordVersion = requireVersion(recordVersion); }
}
public record Depart(Long recordVersion, String idempotencyKey, String remark) {
/** 实际发车不得接受缺省版本。 */
public Depart { recordVersion = requireVersion(recordVersion); }
@@ -9,6 +9,8 @@ public final class DispatchTypes {
public enum Stage { BEFORE_LOADING, LOADING, AFTER_LOADING, DELIVERING, DELIVERED }
public enum ReviewState { DRAFT, DISPATCH_PENDING, DISPATCH_REJECTED, LOADING_PENDING, LOADING_REJECTED, APPROVED }
public enum ReviewStatus { NOT_SUBMITTED, PENDING, APPROVED, REJECTED }
public enum ExceptionType { REFUSED, PARTIAL_RECEIPT, VEHICLE_EXCEPTION }
public enum ReturnDirection { OVERSEAS_WAREHOUSE, OTHER }
public enum Decision { APPROVED, REJECTED }
public enum ResourceMode { MASTER, EXTERNAL }
public enum ActorMode { PLATFORM, PDA }
@@ -18,6 +20,6 @@ public final class DispatchTypes {
public enum Effect { APPLIED, EXISTING }
public enum Action {
CREATE, EDIT, ADD_CARGO, REMOVE_CARGO, CANCEL, SUBMIT_REVIEW, DISPATCH_REVIEW,
LOADING_REVIEW, RETURN_TO_REVIEW, PICK, LOADING_COMPLETE, ARRANGE_DELIVERY, DEPART
LOADING_REVIEW, RETURN_TO_REVIEW, PICK, LOADING_COMPLETE, ARRANGE_DELIVERY, DEPART, MARK_EXCEPTION
}
}
@@ -44,7 +44,11 @@ public final class DispatchViews {
public record DispatchInfo(String route, Instant plannedDepartureAt, Instant departedAt,
Instant estimatedArrivalAt, List<FbaAppointment> fbaAppointments,
List<BatchReference> appointmentBatches) { }
List<BatchReference> appointmentBatches, List<ExceptionRecord> exceptions) { }
/** 排车级异常记录;退货去向只描述安排,不产生退货入库事实。 */
public record ExceptionRecord(ExceptionType exceptionType, ReturnDirection returnDirection,
String description, List<Long> imageFileIds, Instant occurredAt) { }
public record Documents(String dispatchNo, List<DispatchDeliveryModels.OutboundReference> outbounds,
List<DispatchDeliveryModels.DeliveryReference> deliveries,
@@ -146,7 +146,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public DispatchPlanningModels.CandidateList candidates(Actor actor,DispatchPlanningModels.CandidateQuery query) {
requirePlatform(actor);
if(query==null || query.warehouseId()==null || !query.warehouseId().matches("[1-9][0-9]*")) throw bad("请选择真实海外起运仓");
// 未指定起运仓时按区域浏览;写入草稿仍由来源确定唯一真实起运仓。
if(query==null || query.warehouseId()!=null && !query.warehouseId().matches("[1-9][0-9]*")) throw bad("起运仓筛选无效");
if(query.planIds()!=null && (query.planIds().size()>500 || query.planIds().stream().anyMatch(id->id==null || id<=0))) throw bad("计划筛选不合法");
if(query.page()<1 || query.size()<1 || query.size()>50 || !Set.of("ALL","LAX","NYC").contains(Objects.toString(query.warehouseTab(),"")))throw bad("候选分页或仓库页签不合法");
var normalized=new DispatchPlanningModels.CandidateQuery(query.warehouseId(),query.planIds(),trim(query.orderNo()),
@@ -238,7 +239,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
/** 上传只返回本车证据;待审按当前审核权限授权,客服阶段按维护权限授权。 */
public com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File uploadEvidence(Actor actor,String number,
com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,org.springframework.web.multipart.MultipartFile file) {
requirePlatform(actor);var row=required(actor,number,false);requireEditing(row,row.getRecordVersion());
requirePlatform(actor);var row=required(actor,number,false);
if(kind==com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.EXCEPTION) {
requireExceptionAllowed(actor,row,row.getRecordVersion());
return evidence.upload(row.getId(),kind,actor.operatorId(),file);
}
requireEditing(row,row.getRecordVersion());
Action permission = ReviewState.DISPATCH_PENDING.name().equals(row.getReviewState()) ? Action.DISPATCH_REVIEW
: ReviewState.LOADING_PENDING.name().equals(row.getReviewState()) ? Action.LOADING_REVIEW : Action.EDIT;
if(!hasPermission(actor,permission))throw new BizException(ResultCode.FORBIDDEN,"无当前阶段附件上传权限");
@@ -663,6 +669,40 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
for(var id:ids)evidence.metadata(dispatch,kind,id);
}
/** 仅派送中登记异常;原键重试先恢复,避免追加重复记录。 */
@Transactional(isolation=Isolation.READ_COMMITTED)
public CommandResult markException(Actor actor,String number,DispatchCommands.MarkException request) {
var row=lockPlatform(actor,number);
if(!hasPermission(actor,Action.MARK_EXCEPTION))throw new BizException(ResultCode.FORBIDDEN,"无标记异常权限");
var identity=identity(actor,number,Action.MARK_EXCEPTION,request.recordVersion(),request.idempotencyKey(),request);
var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();
requireExceptionAllowed(actor,row,request.recordVersion());
if(request.exceptionType()==null)throw bad("请选择异常类型");
var description=trim(request.description());
if(description!=null && description.length()>1000)throw bad("异常说明不能超过1000字");
var ids=request.imageFileIds()==null?List.<Long>of():request.imageFileIds();
if(ids.size()>20 || ids.stream().anyMatch(id->id==null || id<=0) || ids.stream().distinct().count()!=ids.size())throw bad("异常图片引用无效或超过20张");
// 文件须属于本车异常类别,不能借用其他车辆或审核附件。
for(var id:ids)evidence.metadata(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.EXCEPTION,id);
var records=new ArrayList<>(exceptionRecords(row));
records.add(new DispatchViews.ExceptionRecord(request.exceptionType(),request.returnDirection(),description,List.copyOf(ids),Instant.now()));
row.setExceptionsJson(write(records));
return saveCommand(row,actor,identity,description);
}
/** 异常登记复用排车维护权限;版本、任务类型与阶段由服务端再次核验。 */
private void requireExceptionAllowed(Actor actor,DispatchAggregate row,long version) {
if(!hasPermission(actor,Action.MARK_EXCEPTION))throw new BizException(ResultCode.FORBIDDEN,"无标记异常权限");
if(row.getRecordVersion()!=version)throw conflict("排车版本已变化,请刷新后再标记异常");
if(!actions(actor,row).get(Action.MARK_EXCEPTION).allowed())throw conflict("仅派送中的仓库派送排车可以标记异常");
}
/** 尚未登记时为空集合,已登记记录按保存顺序完整回看。 */
private List<DispatchViews.ExceptionRecord> exceptionRecords(DispatchAggregate row) {
if(row.getExceptionsJson()==null)return List.of();
return Arrays.asList(read(row.getExceptionsJson(),DispatchViews.ExceptionRecord[].class));
}
/** 同一只读快照返回分页、总数和基集分布,不能在前端过滤当前页。 */
@Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ)
public DispatchViews.Page page(Actor actor, DispatchViews.Query query) {
@@ -699,7 +739,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
var documents=new DispatchViews.Documents(number,repository.outbounds(row.getId()),deliveries,instant(row.getCreatedAt()),instant(row.getUpdatedAt()),row.getRemark());
return new DispatchViews.Detail(number,row.getTaskType()==null?"DELIVERY":row.getTaskType(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()),
reviews(row),counts(row),reconciled(row),actions(actor,row),new DispatchViews.VehicleInfo(resources.vehicle(),resources.driver()),
new DispatchViews.DispatchInfo(row.getRoute(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,batchRefs),
new DispatchViews.DispatchInfo(row.getRoute(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,batchRefs,exceptionRecords(row)),
documents,orderDetails(cargo));
}
@@ -817,6 +857,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
for(var action:Action.values()) {
if(action==Action.ARRANGE_DELIVERY || action==Action.DEPART)continue;
boolean state=switch(action) {
case MARK_EXCEPTION -> active && stage==Stage.DELIVERING;
case CREATE -> actor.mode()==ActorMode.PLATFORM;
case EDIT,ADD_CARGO,REMOVE_CARGO,CANCEL -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0;
case SUBMIT_REVIEW -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0 && canSubmitReview(row.getReviewState());
@@ -73,7 +73,7 @@ public final class DispatchCommandCodec {
case DEPART -> DispatchResults.Departed.class;
case ARRANGE_DELIVERY -> DispatchDeliveryModels.ArrangementResult.class;
case CREATE, EDIT, ADD_CARGO, REMOVE_CARGO, CANCEL, SUBMIT_REVIEW,
DISPATCH_REVIEW, LOADING_REVIEW, RETURN_TO_REVIEW -> DispatchResults.Saved.class;
DISPATCH_REVIEW, LOADING_REVIEW, RETURN_TO_REVIEW, MARK_EXCEPTION -> DispatchResults.Saved.class;
};
}
@@ -37,7 +37,7 @@ public class OutsourcedBusinessService {
public HistoryPage history(OwnedBusinessModels.Actor actor,String number,Integer page,Integer size) {
var result=queries.history(actor,number,page,size,SCOPE);
return new HistoryPage(result.records().stream().map(h->new History(h.eventSource(),h.recordVersion(),h.dispatchRecordVersion(),h.action(),h.operatorName(),
h.occurredAt(),h.reason(),h.operations()==null?null:operations(h.operations()),h.photos().stream().map(this::photo).toList())).toList(),result.page(),result.size(),result.total());
h.occurredAt(),h.reason(),h.operations()==null?null:operations(h.operations()),h.photos().stream().filter(p->"LOADING".equals(p.category())).map(this::photo).toList())).toList(),result.page(),result.size(),result.total());
}
/** 外配只生成金额和到达的规范内部请求,不接收归属或隐藏油耗写入。 */
public Detail save(OwnedBusinessModels.Actor actor,String number,SaveOperations request) {
@@ -71,11 +71,11 @@ public class OutsourcedBusinessService {
}
/** 回执公开形状不带内部里程、油耗或油耗照片摘要。 */
private Detail detail(OwnedBusinessModels.Detail detail) {
return new Detail(row(detail.row(),projection.carrierName(detail)),detail.dispatch(),detail.cargo(),detail.photos().stream().map(this::photo).toList());
return new Detail(row(detail.row(),projection.carrierName(detail)),detail.dispatch(),detail.cargo(),detail.photos().stream().filter(p->"LOADING".equals(p.category())).map(this::photo).toList());
}
/** 临时车辆合法保留空vehicleId;承运商缺失保留null供页面显示破折号。 */
private Row row(OwnedBusinessModels.Row r,String carrierName) {
if(!java.util.Set.of("OWNED","DISPATCHED").contains(r.vehicle().ownership()) || r.photoSummary().fuelCount()!=0)throw conflict("外配运营快照的车辆归属或照片不合法");
if(!java.util.Set.of("OWNED","DISPATCHED").contains(r.vehicle().ownership()))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());
@@ -85,7 +85,7 @@ public class OutsourcedBusinessService {
if(ops.actualDistanceMiles()!=null || ops.fuelConsumptionLitresPer100Km()!=null)throw conflict("外配业务不能包含里程或油耗登记");
return new Operations(ops.money(),ops.arrivals());
}
/** 外配照片枚举只有五框,异常油耗历史不能借复用向外输出。 */
/** 外配照片枚举只有五框,排车油耗图片保留在共用记录中但不在外配入口输出。 */
private Photo photo(OwnedBusinessModels.Photo p) {
SCOPE.requireSlot(p.slot());return new Photo(PhotoSlot.valueOf(p.slot().name()),p.category(),p.fileId(),p.fileName(),p.contentType(),p.sizeBytes(),p.uploadedAt(),p.savedAt());
}
@@ -0,0 +1,76 @@
package com.baiyue.lastleg.fleet.ownedbusiness.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*;
import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope;
import com.baiyue.lastleg.fleet.ownedbusiness.service.*;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
import org.springframework.http.*;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
/** 排车资料入口复用车队原照片记录;签收及BOL仍由派送单管理。 */
@RestController
@Validated
@RequestMapping("/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents")
public class DispatchVehicleDocumentController {
private static final OwnedBusinessScope SCOPE=OwnedBusinessScope.DISPATCH;
private final OwnedBusinessQueryService queries;
private final OwnedBusinessPhotoService photos;
private final OwnedBusinessSupport access;
/** 沿已有服务复用来源校验、文件归属、版本和命令账本。 */
public DispatchVehicleDocumentController(OwnedBusinessQueryService queries,OwnedBusinessPhotoService photos,OwnedBusinessSupport access) {
this.queries=queries;this.photos=photos;this.access=access;
}
public record Documents(String dispatchNo,String recordVersion,boolean editable,List<Photo> photos) {}
/** 只输出油耗资料,避免把车队签收照片当作派送签收文件。 */
private Documents documents(Detail value) {
return new Documents(value.row().dispatchNo(),value.row().recordVersion(),value.row().actions().upload().allowed(),
value.photos().stream().filter(p->"FUEL".equals(p.category())).toList());
}
/** 排车列表权限包含车辆资料只读,不扩大派送单客户访问权限。 */
@GetMapping
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ApiResponse<Documents> get(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) {
return ApiResponse.success(documents(queries.detail(access.actor(user,SCOPE),dispatchNo,SCOPE)));
}
/** 上传和引用保存分开,沿原请求键防止重传。 */
@PostMapping(value="/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<Photo> upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,
@RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) {
return ApiResponse.success(photos.upload(access.actor(user,SCOPE),dispatchNo,slot,idempotencyKey,file,SCOPE));
}
/** 只更新既有附加资料版本,不修改排车阶段或签收状态。 */
@PutMapping("/{slot}")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<Documents> bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@Valid @RequestBody BindPhoto request) {
return ApiResponse.success(documents(photos.bind(access.actor(user,SCOPE),dispatchNo,slot,request,SCOPE)));
}
/** 预览只读取本车当前已经绑定的照片。 */
@GetMapping("/{slot}/files/{fileId}/content")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')")
public ResponseEntity<byte[]> content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@PathVariable @Positive long fileId) {
var value=photos.content(access.actor(user,SCOPE),dispatchNo,slot,fileId,null,SCOPE);
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(value.photo().contentType()))
.header("X-Content-Type-Options","nosniff").body(value.content());
}
/** 恢复仅返回油耗上传结果,不通过排车入口暴露其他运营命令。 */
@GetMapping("/uploads/{idempotencyKey}")
@PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')")
public ApiResponse<Photo> recover(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) {
var actor=access.actor(user,SCOPE);
access.require(actor,SCOPE.uploadPermission());
var command=photos.command(actor,dispatchNo,idempotencyKey,SCOPE);
if(!"UPLOAD_PHOTO".equals(command.action()) || !"SUCCEEDED".equals(command.state()) || command.result()==null)
throw OwnedBusinessSupport.conflict("原上传结果尚未确认,请保留原请求稍后核对");
var value=access.read(access.write(command.result()),Photo.class);SCOPE.requireSlot(value.slot());
return ApiResponse.success(value);
}
}
@@ -7,7 +7,8 @@ import com.baiyue.common.core.api.ResultCode;
/** 仅服务端固定入口选择的作用域;不得成为浏览器请求字段或权限别名。 */
public enum OwnedBusinessScope {
OWNED(false,"platform:fleet:owned-business:"),
OUTSOURCED(true,"platform:fleet:outsourced-business:");
OUTSOURCED(true,"platform:fleet:outsourced-business:"),
DISPATCH(false,"platform:overseas-warehouse:dispatch:");
private final boolean pooledCargo;
private final String prefix;
/** 业务入口按是否实际接入拼车货物分类,与车辆归属独立。 */
@@ -19,9 +20,9 @@ public enum OwnedBusinessScope {
/** 运营写权限独立,外配权限不映射成自有权限。 */
public String editPermission(){return prefix+"edit";}
/** 照片写权限独立,不扩大到原排车。 */
public String uploadPermission(){return prefix+"upload";}
public String uploadPermission(){return prefix+(this==DISPATCH?"edit":"upload");}
/** 外配只支持装车五框,服务调用同样不能绕过。 */
public void requireSlot(PhotoSlot slot){if(slot==null || this==OUTSOURCED && !"LOADING".equals(slot.category()))throw new BizException(ResultCode.BAD_REQUEST,"当前业务不支持该照片框");}
public void requireSlot(PhotoSlot slot){if(slot==null || this==OUTSOURCED && !"LOADING".equals(slot.category()) || this==DISPATCH && !"FUEL".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==DISPATCH?"排车单不存在":"排车单不存在或不属于当前"+(this==OWNED?"自有业务":"外配任务"));}
}
@@ -53,8 +53,7 @@ public class OwnedBusinessProjection {
var resources=codec.read(source.getResourcesJson(),Resources.class);
if(resources.vehicle()==null)throw OwnedBusinessSupport.conflict("原车辆快照缺失");
var ops=operations(record);var photos=photos(record);int pallets=palletCount(source);
if(scope==OwnedBusinessScope.OUTSOURCED && (ops.actualDistanceMiles()!=null || ops.fuelConsumptionLitresPer100Km()!=null
|| photos.stream().anyMatch(p->!"LOADING".equals(p.category()))))throw OwnedBusinessSupport.conflict("外配运营记录含不支持的里程、油耗或照片");
if(scope==OwnedBusinessScope.OUTSOURCED && (ops.actualDistanceMiles()!=null || ops.fuelConsumptionLitresPer100Km()!=null))throw OwnedBusinessSupport.conflict("外配运营记录含不支持的里程、油耗或照片");
var arrivals=new HashMap<String,String>();for(var a:ops.arrivals())arrivals.put(a.destinationKey(),a.actualArrivedAt());
var destinations=new LinkedHashMap<String,Destination>();
for(var item:cargo) {
@@ -93,9 +93,10 @@ public class OwnedBusinessQueryService {
if(source.getOwnedVehicleOwnership()==null)throw conflict("本单缺少装车时车辆归属证据,不能归入"+(scope==OwnedBusinessScope.OWNED?"自有":"外配")+"业务");
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(scope!=OwnedBusinessScope.DISPATCH && 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("本单尚无合法装车完成事实");
if(scope==OwnedBusinessScope.DISPATCH && !Set.of("DELIVERING","DELIVERED").contains(source.getStage()))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());
@@ -1907,6 +1907,7 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_order (
review_state VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '审核子流程当前状态',
review_round BIGINT NOT NULL DEFAULT 0 COMMENT '当前审核轮次',
review_basis_hash CHAR(64) NULL COMMENT '本审核轮次核对的货物和资源依据摘要',
exceptions_json JSON NULL COMMENT '派送异常登记记录及图片引用,不改变派送及库存状态',
dispatch_review_json JSON NULL COMMENT '当前调度审核事实快照',
loading_review_json JSON NULL COMMENT '当前理货审核事实快照',
resources_json JSON NULL COMMENT '本趟主档或临时车辆司机的真实资源快照',
@@ -0,0 +1,379 @@
{
"openapi": "3.0.3",
"info": {
"title": "排车车辆资料",
"version": "1.0.0",
"description": "仅平台有效排车DELIVERING/DELIVERED可用;兼容有无拼车货物,沿装车归属证据校验。共享原车队照片、版本、命令与历史,不新建附件存储。派送文件仍使用delivery-orders documents;不把车队RECEIPT照片当签收。列表权限只读,排车edit独立写;恢复还需list。"
},
"security": [
{
"bearerAuth": []
}
],
"paths": {
"/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents": {
"get": {
"operationId": "getDispatchVehicleDocuments",
"summary": "读取本车油耗图片;复用车队附加记录",
"x-permission-code": "platform:overseas-warehouse:dispatch:list",
"parameters": [
{
"$ref": "#/components/parameters/dispatchNo"
}
],
"responses": {
"200": {
"description": "成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/Documents"
}
}
}
}
}
}
}
}
},
"/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}/uploads": {
"post": {
"operationId": "uploadDispatchVehiclePhoto",
"summary": "准备本车油耗图片;尚未绑定保存",
"x-permission-code": "platform:overseas-warehouse:dispatch:edit",
"parameters": [
{
"$ref": "#/components/parameters/dispatchNo"
},
{
"$ref": "#/components/parameters/slot"
}
],
"responses": {
"200": {
"description": "成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/Photo"
}
}
}
}
}
}
},
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"required": [
"file",
"idempotencyKey"
],
"properties": {
"file": {
"type": "string",
"format": "binary",
"description": "非空JPG/PNG,最大10MiB"
},
"idempotencyKey": {
"type": "string",
"minLength": 8,
"maxLength": 128
}
}
}
}
}
}
}
},
"/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}": {
"put": {
"operationId": "bindDispatchVehiclePhoto",
"summary": "按附加版本绑定原上传文件,不改变排车或签收状态",
"x-permission-code": "platform:overseas-warehouse:dispatch:edit",
"parameters": [
{
"$ref": "#/components/parameters/dispatchNo"
},
{
"$ref": "#/components/parameters/slot"
}
],
"responses": {
"200": {
"description": "成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/Documents"
}
}
}
}
}
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BindPhoto"
}
}
}
}
}
},
"/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}/files/{fileId}/content": {
"get": {
"operationId": "readDispatchVehiclePhoto",
"summary": "只读取本车当前绑定图片",
"x-permission-code": "platform:overseas-warehouse:dispatch:list",
"parameters": [
{
"$ref": "#/components/parameters/dispatchNo"
},
{
"$ref": "#/components/parameters/slot"
},
{
"$ref": "#/components/parameters/fileId"
}
],
"responses": {
"200": {
"description": "no-store图片内容",
"content": {
"image/jpeg": {
"schema": {
"type": "string",
"format": "binary"
}
},
"image/png": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
}
}
}
},
"/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/uploads/{idempotencyKey}": {
"get": {
"operationId": "recoverDispatchVehiclePhoto",
"summary": "只恢复当前操作者原油耗上传成功结果,未知结果拒绝重传",
"x-permission-code": "platform:overseas-warehouse:dispatch:edit",
"parameters": [
{
"$ref": "#/components/parameters/dispatchNo"
},
{
"$ref": "#/components/parameters/idempotencyKey"
}
],
"responses": {
"200": {
"description": "成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"data": {
"$ref": "#/components/schemas/Photo"
}
}
}
}
}
}
}
}
}
},
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
},
"parameters": {
"dispatchNo": {
"name": "dispatchNo",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
"slot": {
"name": "slot",
"in": "path",
"required": true,
"schema": {
"type": "string",
"enum": [
"FUEL_START",
"FUEL_END"
]
}
},
"fileId": {
"name": "fileId",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
},
"idempotencyKey": {
"name": "idempotencyKey",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
},
"schemas": {
"Photo": {
"type": "object",
"required": [
"slot",
"fileId",
"fileName",
"contentType",
"sizeBytes",
"uploadedAt"
],
"properties": {
"slot": {
"type": "string",
"enum": [
"FUEL_START",
"FUEL_END"
]
},
"category": {
"type": "string",
"enum": [
"FUEL"
]
},
"fileId": {
"type": "string"
},
"fileName": {
"type": "string"
},
"contentType": {
"type": "string"
},
"sizeBytes": {
"type": "string"
},
"uploadedAt": {
"type": "string"
},
"savedAt": {
"type": "string",
"nullable": true
}
}
},
"Documents": {
"type": "object",
"required": [
"dispatchNo",
"recordVersion",
"editable",
"photos"
],
"properties": {
"dispatchNo": {
"type": "string"
},
"recordVersion": {
"type": "string"
},
"editable": {
"type": "boolean"
},
"photos": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Photo"
}
}
}
},
"BindPhoto": {
"type": "object",
"required": [
"recordVersion",
"idempotencyKey",
"fileId"
],
"properties": {
"recordVersion": {
"type": "string",
"pattern": "^(0|[1-9][0-9]*)$"
},
"idempotencyKey": {
"type": "string",
"minLength": 8,
"maxLength": 128
},
"fileId": {
"type": "string"
}
}
}
}
}
}
@@ -920,7 +920,8 @@ paths:
parameters:
- name: warehouseId
in: query
required: true
description: 可选真实起运仓;未指定时按 warehouseTab 跨仓分页浏览,草稿及建单仍要求同一起运仓。
required: false
schema:
type: string
- name: planIds
@@ -1682,6 +1683,49 @@ paths:
required: true
schema:
type: string
/api/platform/v1/last-leg/dispatches/{dispatchNo}/exceptions:
post:
summary: 登记派送异常
description: 权限 platform:overseas-warehouse:dispatch:edit;仅有效仓库派送单DELIVERING可登记,保存后阶段不变,不产生退货、签收或库存变化。原键重试恢复原结果。
security:
- bearerAuth: []
responses:
'200':
description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。
content:
application/json:
schema:
type: object
properties:
code:
type: string
message:
type: string
requestId:
type: string
nullable: true
traceId:
type: string
nullable: true
timestamp:
type: string
format: date-time
data:
allOf:
- $ref: '#/components/schemas/DispatchCommandResult'
nullable: true
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DispatchMarkException'
parameters:
- name: dispatchNo
in: path
required: true
schema:
type: string
/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-complete:
post:
summary: 确认装车完成并自动进入派送中
@@ -1880,7 +1924,7 @@ paths:
/api/platform/v1/last-leg/dispatches/{dispatchNo}/evidence/{kind}:
post:
summary: 上传本趟车辆或司机证据
description: 客服阶段使用 platform:overseas-warehouse:dispatch:edit;调度待审/理货待审分别使用 platform:fleet:trip:dispatch-review / platform:fleet:trip:loading-review,无需维护车辆权限。VEHICLE和DRIVER支持PDF/JPG/PNG,DRIVER_PHOTO仅JPG/PNG,非空且不超过10MiB;平台、本车、类型复合归属。审核补充附件随ReviewCommand保存,不修改资源,不重启审核。
description: 客服阶段使用 platform:overseas-warehouse:dispatch:edit;调度待审/理货待审分别使用 platform:fleet:trip:dispatch-review / platform:fleet:trip:loading-review,无需维护车辆权限。VEHICLE和DRIVER支持PDF/JPG/PNG,DRIVER_PHOTO和EXCEPTION仅JPG/PNG;EXCEPTION仅派送中使用排车edit权限,引用随异常登记保存。非空且不超过10MiB;平台、本车、类型复合归属。审核补充附件随ReviewCommand保存,不修改资源,不重启审核。
security:
- bearerAuth: []
responses:
@@ -1935,6 +1979,7 @@ paths:
- VEHICLE
- DRIVER
- DRIVER_PHOTO
- EXCEPTION
/api/platform/v1/last-leg/dispatches/{dispatchNo}/evidence/{kind}/{fileId}:
get:
summary: 读取本车证据元数据
@@ -1981,6 +2026,7 @@ paths:
- VEHICLE
- DRIVER
- DRIVER_PHOTO
- EXCEPTION
- name: fileId
in: path
required: true
@@ -2024,6 +2070,7 @@ paths:
- VEHICLE
- DRIVER
- DRIVER_PHOTO
- EXCEPTION
- name: fileId
in: path
required: true
@@ -5143,6 +5190,7 @@ components:
- LOADING_COMPLETE
- ARRANGE_DELIVERY
- DEPART
- MARK_EXCEPTION
DispatchGate:
type: object
x-java-record: DispatchContracts.Gate
@@ -6315,6 +6363,10 @@ components:
items:
$ref: '#/components/schemas/DispatchBatchReference'
nullable: true
exceptions:
type: array
items:
$ref: '#/components/schemas/DispatchExceptionRecord'
required:
- route
- plannedDepartureAt
@@ -6322,6 +6374,7 @@ components:
- estimatedArrivalAt
- fbaAppointments
- appointmentBatches
- exceptions
DispatchDocuments:
type: object
x-java-record: DispatchViews.Documents
@@ -7941,5 +7994,66 @@ components:
- sourceInboundOrderNo
- sourceInboundCode
- productQuantity
DispatchExceptionType:
type: string
enum: [REFUSED, PARTIAL_RECEIPT, VEHICLE_EXCEPTION]
description: 拒收、部分签收、车辆异常。
DispatchReturnDirection:
type: string
enum: [OVERSEAS_WAREHOUSE, OTHER]
description: 退回海外仓、其他;仅登记去向,不自动退货入库。
DispatchMarkException:
type: object
x-java-record: DispatchCommands.MarkException
required: [recordVersion, idempotencyKey, exceptionType]
properties:
recordVersion:
oneOf:
- type: string
pattern: ^[0-9]+$
- type: integer
minimum: 0
idempotencyKey:
type: string
exceptionType:
$ref: '#/components/schemas/DispatchExceptionType'
returnDirection:
allOf:
- $ref: '#/components/schemas/DispatchReturnDirection'
nullable: true
description:
type: string
maxLength: 1000
nullable: true
imageFileIds:
type: array
maxItems: 20
uniqueItems: true
items:
type: string
pattern: ^[1-9][0-9]*$
DispatchExceptionRecord:
type: object
x-java-record: DispatchViews.ExceptionRecord
required: [exceptionType, returnDirection, description, imageFileIds, occurredAt]
properties:
exceptionType:
$ref: '#/components/schemas/DispatchExceptionType'
returnDirection:
allOf:
- $ref: '#/components/schemas/DispatchReturnDirection'
nullable: true
description:
type: string
nullable: true
imageFileIds:
type: array
items:
type: string
occurredAt:
type: string
format: date-time
securitySchemes:
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
@@ -38,6 +38,9 @@ class SmartDispatchOpenApiContractTest {
assertThat(map(paths.get(base+"/drafts"))).containsKey("get");
var candidate=map(map(paths.get(base+"/candidates")).get("get"));
var parameterNames=((List<?>)candidate.get("parameters")).stream().map(SmartDispatchOpenApiContractTest::map).map(p->p.get("name")).toList();
var warehouseParameter=((List<?>)candidate.get("parameters")).stream().map(SmartDispatchOpenApiContractTest::map)
.filter(p->"warehouseId".equals(p.get("name"))).findFirst().orElseThrow();
assertThat(warehouseParameter.get("required")).isEqualTo(false);
assertThat(parameterNames).contains("warehouseId","destinationWarehouseCode","draftToken","page","size","warehouseTab").doesNotContain("customerId","fbaWarehouseCode");
assertThat(map(map(schemas.get("DispatchCandidateList")).get("properties")).keySet())
.containsExactlyInAnyOrder("candidates","snapshotAt","complete","total","page","size","pages");
@@ -45,6 +45,16 @@ class SmartDispatchControllerTest {
.andExpect(status().isOk());
verify(aggregate).candidates(actor,new CandidateQuery("92",null,null,"LAX9",null,null,1,20,"ALL"));verifyNoInteractions(planning);
}
/** 区域入口无需隐藏默认仓,全部与城市均由服务端分页。 */
@Test void candidateHttpQueryAllowsRegionalBrowsingWithoutWarehouse()throws Exception {
var mvc=MockMvcBuilders.standaloneSetup(controller).setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()).build();
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(platform(),null,List.of()));
for(String tab:List.of("ALL","LAX","NYC")) {
mvc.perform(get("/api/platform/v1/last-leg/dispatches/candidates").param("warehouseTab",tab)).andExpect(status().isOk());
verify(aggregate).candidates(actor,new CandidateQuery(null,null,null,null,null,null,1,20,tab));
}
verifyNoInteractions(planning);
}
/** 每个写入都将冻结DTO原样传递,拥有者只能来自认证主体。 */
@Test void allDraftRoutesDelegateFrozenVersionedRequests(){
var create=new CreateDraft("create","92");var container=new CreateContainer(0L,"container","d",15);
@@ -45,6 +45,15 @@ class HttpDispatchEvidenceGatewayTest {
var pdf=new HttpDispatchEvidenceGateway.Metadata(3L,0L,"OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE","9","driver.pdf","application/pdf",128L);
assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",pdf),9,DispatchEvidenceGateway.Kind.DRIVER_PHOTO,3L)).isInstanceOf(ResponseStatusException.class);
}
/** 异常图片独立归属本车,审核照片及PDF均不能用作异常图片。 */
@Test void exceptionImagesRejectOtherKindsCarsAndPdf() {
var photo=new HttpDispatchEvidenceGateway.Metadata(3L,0L,"OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE","9","exception.png","image/png",128L);
assertThat(HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",photo),9,DispatchEvidenceGateway.Kind.EXCEPTION,3L).fileId()).isEqualTo(3L);
assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",photo),10,DispatchEvidenceGateway.Kind.EXCEPTION,3L)).isInstanceOf(ResponseStatusException.class);
assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",photo),9,DispatchEvidenceGateway.Kind.DRIVER_PHOTO,3L)).isInstanceOf(ResponseStatusException.class);
var pdf=new HttpDispatchEvidenceGateway.Metadata(3L,0L,"OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE","9","exception.pdf","application/pdf",128L);
assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",pdf),9,DispatchEvidenceGateway.Kind.EXCEPTION,3L)).isInstanceOf(ResponseStatusException.class);
}
/** 客户、旧主档或缺失媒体类型均拒绝,不因为文件本身存在而放行。 */
@Test void rejectsForeignTenantMasterScopeAndMalformedMetadata() {
for(var value:java.util.List.of(
@@ -0,0 +1,89 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway;
import com.baiyue.lastleg.dispatch.port.DispatchCommands;
import com.baiyue.lastleg.dispatch.port.DispatchContracts;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 通过真实Mapper和事务验证异常登记,不把登记事实解释成签收或退货。 */
class DispatchExceptionPersistenceTest {
/** 登记使用排车独立维护权限。 */
@BeforeEach void authorize() { SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("operator-test","","platform:overseas-warehouse:dispatch:edit")); }
/** 清除线程身份,避免影响其他权限回归。 */
@AfterEach void clear() { SecurityContextHolder.clearContext(); }
/** 每种原型类型均可保存,原键重试不追加,最终完成后仍恢复原结果。 */
@Test void recordsAllTypesAndReplaysWithoutChangingDeliveryFacts() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();var no=create(f);long version=0;
var before=f.db.queryForMap("SELECT stage,shipped_package_count,departed_at,review_state FROM ll_dispatch_order");
for(var type:ExceptionType.values()) {
var body=new DispatchCommands.MarkException(version,"exception-"+type,type,ReturnDirection.OVERSEAS_WAREHOUSE,"真实异常",List.of());
var saved=f.tx.execute(t->f.service.markException(f.actor,no,body));
var replay=f.tx.execute(t->f.service.markException(f.actor,no,body));
assertThat(saved.stage()).isEqualTo(Stage.DELIVERING);assertThat(replay.replayed()).isTrue();
assertThat(replay.recordVersion()).isEqualTo(saved.recordVersion());version=saved.recordVersion();
}
var records=f.service.detail(f.actor,no).dispatchInfo().exceptions();assertThat(records).hasSize(3);
assertThat(records).allSatisfy(r->{assertThat(r.description()).isEqualTo("真实异常");assertThat(r.occurredAt()).isNotNull();});
assertThat(f.db.queryForMap("SELECT stage,shipped_package_count,departed_at,review_state FROM ll_dispatch_order")).isEqualTo(before);
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class)).isZero();
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_history WHERE action='MARK_EXCEPTION'",Long.class)).isEqualTo(3);
var first=new DispatchCommands.MarkException(0L,"exception-REFUSED",ExceptionType.REFUSED,ReturnDirection.OVERSEAS_WAREHOUSE,"真实异常",List.of());
f.db.update("UPDATE ll_dispatch_order SET stage='DELIVERED'");
assertThat(f.tx.execute(t->f.service.markException(f.actor,no,first)).replayed()).isTrue();
assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,new DispatchCommands.MarkException(0L,"exception-REFUSED",ExceptionType.REFUSED,null,"改了内容",List.of())))).isInstanceOf(RuntimeException.class);
assertThat(f.service.detail(f.actor,no).dispatchInfo().exceptions()).hasSize(3);
}
/** 非派送阶段、过期版本、只读身份、PDA及运输任务不能登记。 */
@Test void rejectsWrongStageVersionPermissionAndTask() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();var no=create(f);var body=request(0,"blocked",List.of());
for(var stage:List.of("BEFORE_LOADING","LOADING","AFTER_LOADING","DELIVERED")) {
f.db.update("UPDATE ll_dispatch_order SET stage=?",stage);
assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,body))).hasMessageContaining("仅派送中");
}
f.db.update("UPDATE ll_dispatch_order SET stage='DELIVERING'");
assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,request(9,"stale",List.of())))).hasMessageContaining("版本");
SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("operator-test","","platform:overseas-warehouse:dispatch:list"));
assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,body))).hasMessageContaining("权限");
authorize();f.db.update("UPDATE ll_dispatch_order SET task_type='LOCAL_TRUCK'");
assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,body))).hasMessageContaining("仅派送中");
f.db.update("UPDATE ll_dispatch_order SET task_type='DELIVERY',status='CANCELLED'");
assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,body))).hasMessageContaining("仅派送中");
assertThatThrownBy(()->f.tx.execute(t->f.service.markException(new DispatchContracts.Actor("pda",ActorMode.PDA,"9"),no,body))).hasMessageContaining("仅平台");
assertThat(f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class)).isZero();
}
/** 图片严格核对当前车和类别;附件失败或缺类型不保存任何业务记录。 */
@Test void verifiesImageOwnershipAndKeepsFailedRequestUnchanged() throws Exception {
var f=new DispatchAggregatePersistenceTest.Fixture();var no=create(f);var id=f.repository.find(no).getId();
var files=mock(DispatchEvidenceGateway.class);ReflectionTestUtils.setField(f.service,"evidence",files);
when(files.metadata(id,DispatchEvidenceGateway.Kind.EXCEPTION,91L)).thenThrow(new IllegalArgumentException("文件不属于当前车"));
assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,request(0,"files",List.of(91L))))).hasMessageContaining("不属于");
assertThat(f.service.detail(f.actor,no).dispatchInfo().exceptions()).isEmpty();
var missingType=new DispatchCommands.MarkException(0L,"no-type",null,null,null,List.of());
assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,missingType))).hasMessageContaining("请选择异常类型");
assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,request(0,"duplicates",List.of(91L,91L))))).hasMessageContaining("图片引用");
doReturn(new DispatchEvidenceGateway.File(91L,"现场.png","image/png",10L)).when(files).metadata(id,DispatchEvidenceGateway.Kind.EXCEPTION,91L);
f.tx.execute(t->f.service.markException(f.actor,no,request(0,"files",List.of(91L))));
assertThat(f.service.detail(f.actor,no).dispatchInfo().exceptions().get(0).imageFileIds()).containsExactly(91L);
verify(files,times(2)).metadata(id,DispatchEvidenceGateway.Kind.EXCEPTION,91L);
}
/** 创建真实排车后仅以SQL模拟已发车前置,不声称此用例执行装车流程。 */
private String create(DispatchAggregatePersistenceTest.Fixture f) {
var created=f.tx.execute(t->f.service.create(f.actor,f.configured("create",List.of())));
f.db.update("UPDATE ll_dispatch_order SET stage='DELIVERING',shipped_package_count=3,departed_at=CURRENT_TIMESTAMP");return created.dispatchNo();
}
/** 构造类型必填、其余选填的最小登记请求。 */
private DispatchCommands.MarkException request(long version,String key,List<Long> images) { return new DispatchCommands.MarkException(version,key,ExceptionType.VEHICLE_EXCEPTION,null,null,images); }
}
@@ -0,0 +1,47 @@
package com.baiyue.lastleg.dispatch.service;
import com.baiyue.lastleg.dispatch.mapper.DispatchCandidatePageMapper;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Actor;
import com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery;
import com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode;
import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 区域候选的读取范围与单仓写入职责分离,不依赖测试环境数据。 */
class DispatchRegionalCandidatesTest {
/** 真实服务接受不指定仓的三种区域,保留服务端分页总数;非法仓和区域仍拒绝。 */
@Test void serviceAcceptsRegionalQueriesAndRejectsMalformedScopes() {
var sources=mock(DispatchService.class);var appointments=mock(DispatchAppointmentAllocations.class);
var service=new DispatchAggregateService(null,null,sources,null,null,null,new com.fasterxml.jackson.databind.ObjectMapper());
ReflectionTestUtils.setField(service,"appointments",appointments);
var actor=new Actor("7",ActorMode.PLATFORM,null);
when(sources.candidatePage(eq("7"),any())).thenReturn(new DispatchService.CandidatePage(List.of(),41));
when(sources.selectionGroups(List.of())).thenReturn(Map.of());when(appointments.candidates(List.of())).thenReturn(Map.of());
for(String tab:List.of("ALL","LAX","NYC")) {
var query=new CandidateQuery(null,null,null,null,null,null,2,20,tab);
var result=service.candidates(actor,query);
assertThat(result.total()).isEqualTo(41);assertThat(result.page()).isEqualTo(2);assertThat(result.pages()).isEqualTo(3);
verify(sources).candidatePage("7",query);
}
assertThatThrownBy(()->service.candidates(actor,new CandidateQuery("bad",null,null,null,null,null,1,20,"ALL"))).hasMessageContaining("起运仓筛选无效");
assertThatThrownBy(()->service.candidates(actor,new CandidateQuery(null,null,null,null,null,null,1,20,"bad"))).hasMessageContaining("仓库页签不合法");
}
/** 解析实际Mapper动态SQL,统计和分页采用相同区域条件,单仓消费者继续绑定真实仓。 */
@Test void countAndPageUseOptionalWarehouseAndTheSameRegionPredicate() {
var config=new Configuration();config.addMapper(DispatchCandidatePageMapper.class);
for(String method:List.of("count","page")) for(String tab:List.of("ALL","LAX","NYC")) for(String warehouse:List.of("ALL_WAREHOUSES","321")) {
var query=new CandidateQuery(warehouse.equals("321")?warehouse:null,null,null,null,null,null,1,20,tab);
var sql=config.getMappedStatement(DispatchCandidatePageMapper.class.getName()+"."+method).getBoundSql(Map.of("query",query,"offset",0)).getSql();
if(query.warehouseId()==null)assertThat(sql).doesNotContain("overseasWarehouseId=?");
else assertThat(sql).contains("overseasWarehouseId=?");
if(tab.equals("LAX"))assertThat(sql).contains("REGEXP 'lax|洛杉矶|los angeles'");
if(tab.equals("NYC"))assertThat(sql).contains("REGEXP 'nyc|jfk|");
if(tab.equals("ALL"))assertThat(sql).doesNotContain("REGEXP 'lax|", "REGEXP 'nyc|");
if(method.equals("page"))assertThat(sql).contains("LIMIT ? OFFSET ?");
}
}
}
+1 -1
View File
@@ -11,7 +11,7 @@
| 类别 | 正式入口 | 管理边界 |
|---|---|---|
| 本机重型检查限流 | `local-check.ps1` | 所有工作目录、对话共用两个用户级执行名额;PowerShell 7,状态/超时/并发与 Job Object 回收规则见外层 `docs/AI工程执行与协作约束.md`;只读状态不申请重型名额 |
| 后端分层检查 | `backend-check.ps1` | 默认指定模块回归,支持测试源码编译和精确测试类;必填模块,自动接入重型限流;全量触发条件见后端根指引,参数及副作用见 README |
| 后端分层检查 | `backend-check.ps1` | 局部任务显式选 Focused;模式缺省为 Module,不是日常必跑;必填模块并接入限流,范围见后端根指引,参数见 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` 只是入库测量与计费专项,不替代全量入口 |
+3 -3
View File
@@ -458,13 +458,13 @@ Admin/Client HTTP 脚本的离线合同只验证脚本结构、参数和模块
.\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、索引等相关静态合同仍按其规则单独执行。
三种模式均使用根 reactor 的 `-pl <modules> -am`,不会安装制品到本地仓库,不默认 `clean`。`Compile` 运行 `test-compile`;`Focused`/`Module` 运行 `test`。`-am` 只补上游构建依赖,HTTP/事件下游消费者须显式列入。局部逻辑使用 `Focused` 覆盖修改行为及必要边界即可交付,不因收尾改跑 `Module`;模块回归只用于定向覆盖不足的服务级影响。公共模块按实际消费者验证。模式缺省值为 `Module`,局部任务须显式选 `Focused`;API、Mapper、索引合同按各自条件执行。
Focused 不接受通配符或方法选择,每个类必须唯一命中所选模块的 `src/test/java`。为允许上游无同名测试,Maven 设置 `surefire.failIfNoSpecifiedTests=false`,随后逐类核对本轮更新的 Surefire XML;报告缺失、陈旧、全部跳过或含失败均不能通过,避免零测试假绿。
+2
View File
@@ -57,6 +57,8 @@ export function planA0LocationIsolation(locations, requiredSlots = 1,
}
const MODULE_DEPENDENCIES = new Map([
['inbound-weight-ocr', ['orders']],
['orders', ['merchant-qualification-fixture']],
['phase5-root-fulfillment', ['merchant-qualification-fixture']],
['pda-loading-link-regression', ['merchant-qualification-fixture']],
['phase4-overseas-return-followups', ['phase4-overseas-return-receipt']],
+3 -1
View File
@@ -4,7 +4,9 @@
固定托盘造数中,`inbound-measurement.mjs` 只复用 `TP-001`、`YW-TP-002` 指定池中的现有托盘;一只可复用,不要求两只同时存在,两只均缺失才阻断,不自动选择池外托盘。仅明确设置内部 `allowAnyExistingPallet` 的通用场景可读取当前仓库已有资源;两者均不创建托盘。
OCR模块必须通过`ADMIN_TEST_OCR_IMAGE`显式指定本轮图片,不依赖个人桌面路径。合成称重测试图只证明上传、识别响应及人工确认协议,不代表现场称重照片或设备验收。费用项目模块按受控编码验证112条正式基线,合法扩展项目保留并单独核对分页和筛选。散板普通状态接口仅验证取消及非法派车拒绝,原单须出现在待匹配需求池;需求池接车到签收另列覆盖缺口,不以手工跳状态冒充履约。
OCR模块必须通过`ADMIN_TEST_OCR_IMAGE`显式指定本轮图片,不依赖个人桌面路径;定向运行自动展开客户资质及订单前置。识别失败的空字段允许省略,但识别成功必须有有效重量与单位;人工修正和确认分别增加版本。合成称重测试图只证明上传、识别响应及人工确认协议,不代表现场称重照片或设备验收。费用项目模块按受控编码验证112条正式基线,合法扩展项目保留并单独核对分页和筛选。散板普通状态接口仅验证取消及非法派车拒绝,原单须出现在待匹配需求池;需求池接车到签收另列覆盖缺口,不以手工跳状态冒充履约。
预约工作查询在本轮业务写入后,仅对服务端明确返回的 A0409“预约批次或运输关联已变化,服务端正在协调完整工作清单”或“已开船工作范围变化,请先完成服务端同步”每 5 秒重读,最多等待 240 秒;超时保留失败,其他错误不重试。这是后台完整扫描的就绪等待,不是页面响应性能验收,也不触发同步写接口。
先运行 `node scripts/acceptance-overview.mjs`(后端根目录,无网络)查看当前默认与可选模块。范围来自真实注册表;本文件描述各模块用途与依赖,不代表所有模块默认执行或已通过。报告解读及跨端/专项边界见 [总索引](../README.md#先看范围再看证据),最新运行状态只查外层部署契约。
@@ -1,7 +1,26 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import { run } from './appointment-work.mjs'
import { run, readAfterSynchronization } from './appointment-work.mjs'
test('同步就绪只重试明确协调状态,超限及其他错误原样保留', async () => {
const coordinating = {http:200,body:{code:'A0409',message:'预约批次或运输关联已变化,服务端正在协调完整工作清单'}}
const success = {http:200,body:{code:'200',data:{}}}
let calls = 0
assert.equal(await readAfterSynchronization({request:async()=>++calls === 1 ? coordinating : success}, '/summary', {attempts:3,delay:async()=>{}}),success)
assert.equal(calls,2)
const scopeChanged = {http:200,body:{code:'A0409',message:'已开船工作范围变化,请先完成服务端同步'}}
calls=0
assert.equal(await readAfterSynchronization({request:async()=>++calls===1?scopeChanged:success}, '/summary', {attempts:3,delay:async()=>{}}),success)
assert.equal(calls,2)
calls=0
assert.equal(await readAfterSynchronization({request:async()=>{calls++;return coordinating}}, '/summary', {attempts:3,delay:async()=>{}}),coordinating)
assert.equal(calls,3)
const other={http:500,body:{code:'S0500'}}
assert.equal(await readAfterSynchronization({request:async()=>other}, '/summary', {delay:async()=>assert.fail('must not retry')}),other)
const conflict={http:200,body:{code:'A0409',message:'预约版本冲突'}}
assert.equal(await readAfterSynchronization({request:async()=>conflict}, '/summary', {delay:async()=>assert.fail('must not retry unrelated conflict')}),conflict)
})
const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
const moduleSource = readFileSync(new URL('./appointment-work.mjs', import.meta.url), 'utf8')
@@ -1,3 +1,18 @@
import { setTimeout as sleep } from 'node:timers/promises'
export async function readAfterSynchronization(context, path, { attempts = 49, delay = () => sleep(5000) } = {}) {
let response
for (let attempt = 1; attempt <= attempts; attempt += 1) {
response = await context.request('GET', path)
const coordinating = response.http === 200 && String(response.body?.code) === 'A0409'
&& ['预约批次或运输关联已变化,服务端正在协调完整工作清单',
'已开船工作范围变化,请先完成服务端同步'].includes(response.body?.message)
if (!coordinating || attempt === attempts) return response
await delay()
}
return response
}
function count(value) {
const number = Number(value)
return Number.isFinite(number) ? number : NaN
@@ -15,7 +30,7 @@ export async function run(context) {
const from = shanghaiDate(-7)
const to = shanghaiDate(7)
const calendarResponse = await context.request('GET', `${base}/calendar?from=${from}&to=${to}`)
const calendarResponse = await readAfterSynchronization(context, `${base}/calendar?from=${from}&to=${to}`)
const calendar = context.apiSuccess(module, '查询预约工作日历', calendarResponse)
const days = Array.isArray(calendar?.days) ? calendar.days : []
const dayTotal = days.reduce((sum, day) => sum + Number(day?.total || 0), 0)
@@ -29,14 +44,14 @@ export async function run(context) {
`date=${day?.date || 'missing'},total=${day?.total ?? 'missing'},typeTotal=${typeTotal}`)
}
const summaryResponse = await context.request('GET', `${base}/summary`)
const summaryResponse = await readAfterSynchronization(context, `${base}/summary`)
const summary = context.apiSuccess(module, '查询预约工作汇总', summaryResponse)
const summaryFields = ['all', 'followToday', 'overdue', 'undated', 'review']
context.assert(module, '工作汇总五项计数完整', summaryResponse,
summaryFields.every((field) => Number.isFinite(Number(summary?.[field]))),
summaryFields.map((field) => `${field}=${summary?.[field] ?? 'missing'}`).join(','))
const pageResponse = await context.request('GET', `${base}/items?view=ALL&page=1&size=20`)
const pageResponse = await readAfterSynchronization(context, `${base}/items?view=ALL&page=1&size=20`)
const page = context.apiSuccess(module, '查询预约工作事项首页', pageResponse)
const records = Array.isArray(page?.records) ? page.records : []
const total = count(page?.total)
@@ -1,8 +1,10 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
import {
validRecognition, sameOptionalWeight, confirmedVersion,
confirmedNetSummary,
isTransientGatewayFailure,
normalizeWeightPalletOptionPage,
@@ -14,7 +16,22 @@ import {
workspaceWeightMatchesBaseline,
} from './inbound-weight-ocr.mjs'
test('OCR failure permits absent optional values but recognition requires a valid weight; correction and confirmation each advance version', () => {
assert.equal(validRecognition({ recognitionStatus: 'FAILED' }), true)
assert.equal(validRecognition({ recognitionStatus: 'RECOGNIZED' }), false)
assert.equal(validRecognition({ recognitionStatus: 'RECOGNIZED', weightValue: 45, weightUnit: 'KG', confidence: 0.9 }), true)
assert.equal(validRecognition({ recognitionStatus: 'RECOGNIZED', weightValue: 45, weightUnit: 'KG', confidence: 2 }), false)
assert.equal(sameOptionalWeight(undefined, null), true)
assert.equal(sameOptionalWeight(undefined, 0), false)
assert.equal(sameOptionalWeight(5, 6), false)
assert.equal(confirmedVersion(3, false), 4)
assert.equal(confirmedVersion(3, true), 5)
})
test('retries only transient gateway failures for the unauthenticated upload probe', () => {
const modules = normalizeModuleOrder(['inbound-weight-ocr'])
assert.ok(modules.indexOf('merchant-qualification-fixture') < modules.indexOf('orders'))
assert.ok(modules.indexOf('orders') < modules.indexOf('inbound-weight-ocr'))
assert.equal(isTransientGatewayFailure({ http: 0 }), true)
assert.equal(isTransientGatewayFailure({ http: 502 }), true)
assert.equal(isTransientGatewayFailure({ http: 503 }), true)
@@ -22,6 +22,22 @@ function closeNumber(actual, expected, tolerance = 0.0005) {
return Number.isFinite(Number(actual)) && Math.abs(Number(actual) - Number(expected)) < tolerance
}
export function validRecognition(result) {
if (!result || !OCR_STATUSES.has(result.recognitionStatus)) return false
if (result.confidence != null && (!Number.isFinite(Number(result.confidence)) || Number(result.confidence) < 0 || Number(result.confidence) > 1)) return false
if (result.recognitionStatus === 'FAILED') return result.weightValue == null
return result.weightValue != null && Number(result.weightValue) > 0
&& normalizedGrossKg(result.weightValue, result.weightUnit) != null
}
export function sameOptionalWeight(actual, expected) {
return actual == null || expected == null ? actual == null && expected == null : closeNumber(actual, expected)
}
export function confirmedVersion(version, hasAdjustment) {
return Number(version) + (hasAdjustment ? 2 : 1)
}
function palletSnapshotComplete(pallet) {
return Boolean(id(pallet?.palletId))
&& typeof pallet?.palletCode === 'string' && pallet.palletCode.length > 0
@@ -254,10 +270,7 @@ async function createUploadedResult(context, target, pallet, image, overrides =
Boolean(fileId) && typeof upload?.fileId === 'string',
`fileIdType=${typeof upload?.fileId}, status=${upload?.recognitionStatus ?? 'missing'}`, SCENARIO_OPTIONS)
context.assert(MODULE, 'OCR 上传响应字段完整且置信度受限', uploadResponse,
upload && OCR_STATUSES.has(upload.recognitionStatus)
&& Object.hasOwn(upload, 'weightValue') && Object.hasOwn(upload, 'weightUnit')
&& Object.hasOwn(upload, 'confidence')
&& (upload.confidence == null || (Number(upload.confidence) >= 0 && Number(upload.confidence) <= 1)),
validRecognition(upload),
`status=${upload?.recognitionStatus ?? 'missing'}, hasWeight=${upload?.weightValue != null}, confidence=${upload?.confidence ?? 'null'}`,
SCENARIO_OPTIONS)
if (!fileId) throw new Error('称重图片上传未返回 fileId,无法登记和清理测试资源')
@@ -269,8 +282,7 @@ async function createUploadedResult(context, target, pallet, image, overrides =
const retry = context.apiSuccess(MODULE, '重试当前称重图片 OCR', retryResponse,
`fileId=${fileId}`, SCENARIO_OPTIONS)
context.assert(MODULE, 'OCR 重试保持文件身份并返回识别契约', retryResponse,
id(retry?.fileId) === fileId && OCR_STATUSES.has(retry?.recognitionStatus)
&& Object.hasOwn(retry || {}, 'weightValue') && Object.hasOwn(retry || {}, 'confidence'),
id(retry?.fileId) === fileId && validRecognition(retry),
`sameFile=${id(retry?.fileId) === fileId}, status=${retry?.recognitionStatus ?? 'missing'}`,
SCENARIO_OPTIONS)
if (retry) recognized = retry
@@ -474,8 +486,8 @@ export async function run(context) {
const registeredRow = registered.workspace?.weightResults?.find((row) => id(row.id) === original.resultId)
context.assert(MODULE, '工作区保留本轮 OCR 原值且未覆盖历史结果', registered.response,
registeredRow && !baselineIds.has(original.resultId)
&& closeNumber(registeredRow.ocrWeightValue, original.result.ocrWeightValue)
&& registeredRow.ocrWeightUnit === original.result.ocrWeightUnit,
&& sameOptionalWeight(registeredRow.ocrWeightValue, original.result.ocrWeightValue)
&& (registeredRow.ocrWeightUnit ?? null) === (original.result.ocrWeightUnit ?? null),
`resultFound=${Boolean(registeredRow)}, baselineCount=${baselineIds.size}, currentCount=${registered.workspace?.weightResults?.length ?? 'n/a'}`,
SCENARIO_OPTIONS)
@@ -506,7 +518,7 @@ export async function run(context) {
const confirmedTare = Number(confirmed?.palletTareWeightKg)
const confirmedNet = Number(confirmed?.netWeightKg)
context.assert(MODULE, '确认结果返回托盘快照、毛重和去皮净重', confirmResponse,
confirmed?.recognitionStatus === 'CONFIRMED' && Number(confirmed?.version) === original.version + 1
confirmed?.recognitionStatus === 'CONFIRMED' && Number(confirmed?.version) === confirmedVersion(original.version, !canConfirmOriginal)
&& id(confirmed?.palletId) === id(pallet.palletId)
&& confirmed?.palletCode === pallet.palletCode && confirmed?.palletType === pallet.palletType
&& closeNumber(confirmed?.palletCapacityCbm, pallet.capacityCbm)
@@ -53,7 +53,7 @@ test('商户夹具通过正式 File 和 IAM 审核流程建立下单资格', ()
assert.match(fixture, /MERCHANT_TEST_PASSWORD/)
assert.match(fixture, /context\.options\?\.customerName/)
assert.match(fixture, /tenantName/)
assert.match(fixture, /expectedProcurementPrefix: customerName \? null : 'QLYSXDKH'/)
assert.doesNotMatch(fixture, /expectedProcurementPrefix/)
assert.match(acceptance, /administrator-phone-availability\?phone=/)
assert.match(acceptance, /attempt < 100/)
assert.doesNotMatch(acceptance, /phoneSequence % 10/)
@@ -120,10 +120,8 @@ export async function run(context, { publishEnvironment = true } = {}) {
}
context.context.merchantQualificationFixture = {
tenantId, tenantName, username, password, marker: context.marker,
expectedProcurementPrefix: customerName ? null : 'QLYSXDKH',
}
context.createdResources.merchantQualificationFixture = {
tenantId, tenantName, username, marker: context.marker,
expectedProcurementPrefix: customerName ? null : 'QLYSXDKH',
}
}
@@ -111,4 +111,8 @@ test('船期日历模块注册为默认模块并核对月历、渠道、模板
assert.ok(ctx.calls[3].path.includes('/sailing-assignments/exceptions'))
assert.ok(ctx.calls[4].path.endsWith('/11'))
assert.ok(ctx.calls[5].path.endsWith('/999999999999999'))
delete sailing.etaAt
await runSailingCalendar(ctx)
sailing.etaAt = 'invalid-eta'
await assert.rejects(runSailingCalendar(ctx), /月历记录、汇总与筛选选项结构完整/)
})
@@ -103,7 +103,7 @@ export async function run(context) {
const clearance = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}/clearance`)
context.assert(MODULE, '国内未出仓时目的港清关不生成国外工作台数据', clearance,
clearance.http === 200 && String(clearance?.body?.code) === 'A0404'
&& String(clearance?.body?.message || '').includes('国外工作台货柜不存在'),
&& clearance?.body?.message === '货柜尚未离仓或实际开船,不能维护目的港清关资料',
`HTTP=${clearance.http}, code=${clearance?.body?.code ?? 'missing'}, containerBookingId=${containerId}`, OPTIONS)
const customs = await context.request('GET', `/api/platform/v1/first-leg/loading-lists/${encodeURIComponent(planId)}/customs?warehouseId=${encodeURIComponent(warehouseId)}`)
if (fixture?.finalStatus === 'LOADED') {
@@ -55,6 +55,7 @@ test('overview regression requires workflowStatus and distinguishes 100 percent
test('pre-departure overview rejects destination workbench documents explicitly', () => {
assert.match(overviewAcceptance, /国内未出仓时 \$\{documentType\} 不生成国外工作台数据/)
assert.match(overviewAcceptance, /国内未出仓时目的港清关不生成国外工作台数据/)
assert.match(overviewAcceptance, /货柜尚未离仓或实际开船,不能维护目的港清关资料/)
assert.match(overviewAcceptance, /国外工作台货柜不存在/)
})
@@ -207,7 +207,8 @@ function fixture(options = {}) {
test('module is context injected, blocks missing prerequisites and excludes forbidden surfaces', () => {
assert.match(source, /export async function run\(context\)/)
assert.doesNotMatch(source, /https?:\/\/|process\.env|fetch\(/)
assert.doesNotMatch(source, /addCoverageGap|coverageGap\(/)
assert.equal((source.match(/addCoverageGap\(/g) || []).length, 1)
assert.match(source, /looseboard-pool-fulfillment/)
assert.match(source, /deploymentBlocker: true/)
assert.match(source, /contactPhone: '\d+'/)
assert.doesNotMatch(source, /contactPhone: '\+[^']+'/)
@@ -5,6 +5,28 @@ import test from 'node:test'
import { run, vehicleCapacityMatches } from './phase4-overseas-master-data.mjs'
import { run as runCare } from './fleet-vehicle-care.mjs'
test('平台用户手机号已存在只换号重试,未知创建结果不得重发', async () => {
for (const collision of [true, false]) {
const { context } = fakeContext({ softAssertions: true })
const original = context.request.bind(context)
const phones = []
let sequence = 0
context.findAvailablePhone = async () => `1380000${String(++sequence).padStart(4, '0')}`
context.request = async (method, path, body, ...rest) => {
if (method === 'POST' && path === '/api/platform/v1/iam/users') {
phones.push(body.phone)
if (phones.length === 1) return collision
? {http:200,body:{code:'A0409',message:'手机号已存在'}}
: {http:500,body:{code:'S0500'}}
}
return original(method,path,body,...rest)
}
await run(context)
assert.equal(phones.length, collision ? 2 : 1)
if (collision) assert.notEqual(phones[0],phones[1])
}
})
test('运维专项接受NON_NULL真实响应但仍完成全部六类记录和两车停用', async () => {
const { context, state } = fakeContext()
const request = context.request.bind(context)
@@ -375,13 +375,19 @@ async function verifyCanonicalPermissionDenial(context, resource) {
if (!roleId) throw new Error('无主数据权限角色未返回ID')
resource.permissionProbe = { roleId, membershipId: null, roleStatus: 'ACTIVE', userStatus: null }
const phone = await context.findAvailablePhone(MODULE)
const username = `p4_md_deny_${suffix}`.slice(0, 64).toLowerCase()
const password = `P4md${suffix.slice(-8)}A1!`
const userResponse = await context.request('POST', '/api/platform/v1/iam/users', {
username, displayName: `四期主数据拒绝-${suffix}`, phone,
email: `${username}@example.com`, tenantId: '0', roleIds: [roleId], password, status: 'ACTIVE',
})
let userResponse
for (let attempt = 0; attempt < 3; attempt += 1) {
const phone = await context.findAvailablePhone(MODULE)
userResponse = await context.request('POST', '/api/platform/v1/iam/users', {
username, displayName: `四期主数据拒绝-${suffix}`, phone,
email: `${username}@example.com`, tenantId: '0', roleIds: [roleId], password, status: 'ACTIVE',
})
// 客户管理员候选检查不覆盖平台用户;仅明确未创建的手机号冲突允许换号。
if (!(userResponse.http === 200 && String(userResponse.body?.code) === 'A0409'
&& userResponse.body?.message === '手机号已存在')) break
}
const user = requireData(context, '创建无主数据权限用户', userResponse)
const membershipId = asId(user?.membershipId)
if (!membershipId) throw new Error('无主数据权限用户未返回membershipId')
@@ -10,9 +10,10 @@ const pda = readFileSync(new URL('./phase4-overseas-pda.mjs', import.meta.url),
test('采购单号按北京时间日期执行精确合同', () => {
const instant = new Date('2026-09-05T16:30:00.000Z')
assert.equal(beijingDate(instant), '20260906')
assert.equal(matchesProcurementNo('QLYSXDKH202609060001', 'QLYSXDKH', instant), true)
assert.equal(matchesProcurementNo('QLYSXDKH202609050001', 'QLYSXDKH', instant), false)
assert.equal(matchesProcurementNo('QLYSXDKH20260906001', 'QLYSXDKH', instant), false)
assert.equal(matchesProcurementNo('CG202609060001', instant), true)
assert.equal(matchesProcurementNo('CG202609050001', instant), false)
assert.equal(matchesProcurementNo('CG20260906001', instant), false)
assert.equal(matchesProcurementNo('QLYSXDKH202609060001', instant), false)
})
test('钱包闭环覆盖充值、授信、采购冻结结算和换标结算释放', () => {
@@ -28,9 +28,8 @@ export function beijingDate(now = new Date()) {
return `${parts.year}${parts.month}${parts.day}`
}
export function matchesProcurementNo(orderNo, prefix, now = new Date()) {
return typeof prefix === 'string' && prefix.length > 0
&& new RegExp(`^${prefix}${beijingDate(now)}\\d{4}$`).test(String(orderNo || ''))
export function matchesProcurementNo(orderNo, now = new Date()) {
return new RegExp(`^CG${beijingDate(now)}\\d{4}$`).test(String(orderNo || ''))
}
async function poll(action, predicate, attempts = 60, intervalMs = 1_000) {
@@ -133,8 +132,8 @@ async function procurementFlow(context, fixture, token, warehouseId) {
customerName: fixture.tenantName, customerToken: token, warehouseId, upload,
readWallet: async () => { const result = await platformAccount(context, customerId); if (!success(result.response) || !result.account) throw new Error('本轮客户钱包查询失败'); return result.account } })
assert(context, '采购正常全链路及资金完成', null, proof.status === 'PASSED', `orderNo=${proof.orderNo}`)
assert(context, '采购单号日期和客户前缀正确', null,
fixture.expectedProcurementPrefix ? matchesProcurementNo(proof.orderNo, fixture.expectedProcurementPrefix) : /^[A-Z0-9]{1,8}\d{8}\d{4}$/.test(proof.orderNo), `orderNo=${proof.orderNo}`)
assert(context, '采购单号为CG加北京时间日期和流水', null,
matchesProcurementNo(proof.orderNo), `orderNo=${proof.orderNo}`)
return { orderNo: proof.orderNo, walletBusinessNos: proof.walletBusinessNos, settledAmountCny: proof.settledAmountCny }
} finally {
for (const check of context.createdResources.procurementSample?.checks || []) assert(context, check.label, null, check.passed)
+101 -37
View File
@@ -141,6 +141,10 @@ $backendAgentsPath = Join-Path $backendRoot 'AGENTS.md'
$rootAgents = Read-RequiredText $rootAgentsPath
$businessIndexPath = Join-Path $workspaceRoot 'docs/业务快速索引.md'
$businessIndex = Read-RequiredText $businessIndexPath
$businessRulesPath = Join-Path $workspaceRoot 'docs/业务实现边界.md'
$businessRules = Read-RequiredText $businessRulesPath
# 业务断言检查实际正文,不再要求导航表复制规则;路由校验仍使用短索引。
$businessFacts = $businessIndex + "`n" + $businessRules
Assert-Contains $rootAgents 'docs/业务快速索引.md' '根按需业务索引入口'
Assert-MarkdownLinks $rootAgentsPath $rootAgents '根导航与文档链接'
Assert-MarkdownTableUnique $businessIndexPath '## 业务快速路由表' 0 '详细业务路由表'
@@ -273,6 +277,66 @@ Write-Host "定向检索样本:$($lookupSamples.Count) 项,命中行共 $loo
$scriptsAgents = Read-RequiredText (Join-Path $backendRoot 'scripts/AGENTS.md')
$engineeringConstraints = Read-RequiredText (Join-Path $workspaceRoot 'docs/AI工程执行与协作约束.md')
$docsIndex = Read-RequiredText (Join-Path $workspaceRoot 'docs/README.md')
# 只检查明确列出的当前入口和迁移正文,不递归扫描历史资料。
$overseasDocEntryPath = Join-Path $workspaceRoot 'docs/海外仓业务/README.md'
$overseasDocEntry = Read-RequiredText $overseasDocEntryPath
$overseasDocRulesPath = Join-Path $workspaceRoot 'docs/海外仓业务/业务规则.md'
$overseasDocRules = Read-RequiredText $overseasDocRulesPath
$financeDocEntryPath = Join-Path $workspaceRoot 'docs/财务管理/README.md'
$financeDocEntry = Read-RequiredText $financeDocEntryPath
$financeP1Path = Join-Path $workspaceRoot 'docs/财务管理/财务对账结算P1闭环.md'
$financeP1 = Read-RequiredText $financeP1Path
foreach ($document in @(
@{ Name = '业务导航'; Path = $businessIndexPath; Text = $businessIndex; Limit = 12500 },
@{ Name = '文档总入口'; Path = (Join-Path $workspaceRoot 'docs/README.md'); Text = $docsIndex; Limit = 3500 },
@{ Name = '海外仓文档入口'; Path = $overseasDocEntryPath; Text = $overseasDocEntry; Limit = 2000 },
@{ Name = '财务文档入口'; Path = $financeDocEntryPath; Text = $financeDocEntry; Limit = 1800 }
)) {
Assert-MarkdownLinks $document.Path $document.Text $document.Name
$checked++
if (($document.Text -replace "`r`n", "`n").Length -gt $document.Limit) {
Add-Failure "$($document.Name) 超出入口预算 $($document.Limit),请将正文归回主题主文档"
}
}
foreach ($document in @(
@{ Name = '跨端实现边界'; Path = $businessRulesPath; Text = $businessRules },
@{ Name = '海外仓业务正文'; Path = $overseasDocRulesPath; Text = $overseasDocRules },
@{ Name = '财务当前闭环'; Path = $financeP1Path; Text = $financeP1 }
)) { Assert-MarkdownLinks $document.Path $document.Text $document.Name }
Assert-TableRowWidth $businessIndexPath '## 业务快速路由表' 260 '详细业务导航表'
$businessRouteSection = (($businessIndex -split '(?m)^## 业务快速路由表\r?$', 2)[1] -split '(?m)^## ', 2)[0]
foreach ($topic in [regex]::Matches($businessRules, '(?m)^## (?<name>.+?)\r?$')) {
$prefix = '| ' + $topic.Groups['name'].Value + ' |'
$hits = @($businessRouteSection -split '\r?\n' | Where-Object { $_.StartsWith($prefix) })
$checked++
if ($hits.Count -ne 1) { Add-Failure "业务正文必须有唯一导航入口:$($topic.Groups['name'].Value)" }
}
Assert-Contains $rootAgents '备选导航' '索引不是串行读取清单'
Assert-Contains $rootAgents '命中正文即停止扩展' '正文命中后的读取停止条件'
Assert-Contains $financeDocEntry '财务对账结算P1闭环.md#客户资金账户' '当前资金账户正文入口'
Assert-NotContains $financeDocEntry '钱包不做' '财务入口不混入历史排除范围'
Assert-Contains $financeP1 '首批固定为 CNY' '资金账户币种规则迁入现行正文'
Assert-Contains $financeP1 '充值先归还已用授信或负余额' '资金账户充值规则迁入现行正文'
Assert-Contains $financeP1 '新账户默认授信为 `0.00`' '资金账户默认授信规则迁入现行正文'
$docLookupCharacters = 0
$docLookupSamples = @(
@{ Key = '客户头程订单批量导入'; Text = $businessIndex; Entry = 'apps/client-web/src/modules/first-leg/AGENTS.md' },
@{ Key = '客户资金账户、充值、授信与业务扣费'; Text = $businessIndex; Entry = 'apps/client-web/AGENTS.md' },
@{ Key = '客户账单、客户对账单、账单确认'; Text = $businessIndex; Entry = 'apps/client-web/src/modules/finance/views/FinanceBillsView.vue' },
@{ Key = '客户资金账户、充值、授信、冻结与业务扣费'; Text = $financeDocEntry; Entry = '财务对账结算P1闭环.md#客户资金账户' },
@{ Key = '海外排车、出库与派送改造'; Text = $overseasDocEntry; Entry = '排车列表统一作业改造契约.md' },
@{ Key = '本土卡车与散板表单'; Text = $overseasDocEntry; Entry = '业务规则.md#本土散板简化下单' }
)
foreach ($sample in $docLookupSamples) {
$hits = @($sample.Text -split '\r?\n' | Where-Object { $_.StartsWith('| ' + $sample.Key + ' |') })
$checked++
if ($hits.Count -ne 1) { Add-Failure "文档路由样本必须唯一命中:$($sample.Key)"; continue }
Assert-Contains $hits[0] $sample.Entry "文档路由样本 $($sample.Key)"
$docLookupCharacters += $hits[0].Length
}
Write-Host "文档路由样本:$($docLookupSamples.Count) 项,命中行共 $docLookupCharacters 字符(不含上层指引或按需正文,不代表端到端耗时)。"
$testTaskRegistry = Read-RequiredText (Join-Path $workspaceRoot 'docs/独立任务协作登记.md')
$devTaskRegistry = Read-RequiredText (Join-Path $workspaceRoot 'docs/开发环境独立任务协作登记.md')
$mapperScanContract = Read-RequiredText (Join-Path $backendRoot 'scripts/mapper-scan-contract.mjs')
@@ -282,7 +346,7 @@ Assert-Contains $rootAgents '源码结构、接口与 DTO、字段、状态机
Assert-Contains $rootAgents '数据库记录、上传文件、缓存、会话、消息、搜索索引和其他运行状态均视为可丢弃' '根开发运行状态范围'
Assert-Contains $rootAgents '后端 MyBatis Mapper 新增、移动或扫描遗漏' '根 Mapper 快速入口'
Assert-Contains $rootAgents 'node scripts/mapper-scan-contract.mjs' '根 Mapper 静态门禁'
Assert-Contains $businessIndex '脚本管理、全量验证与测试造数' '根脚本管理快速入口'
Assert-Contains $businessFacts '脚本管理、全量验证与测试造数' '根脚本管理快速入口'
Assert-Contains $rootAgents 'baiyue-firstleg/scripts/AGENTS.md' '根脚本最近指引'
Assert-Contains $engineeringConstraints '禁止为旧代码、旧调用或开发环境存量保留旧接口/旧字段别名' '工程无兼容执行规则'
Assert-Contains $engineeringConstraints '不授权自动重置数据库、删除数据/文件、执行 SQL、部署或修改环境' '无兼容安全边界'
@@ -340,7 +404,7 @@ foreach ($businessDocsEntry in @(
'docs/国内货柜履约/README.md',
'docs/海外仓业务/README.md'
)) {
Assert-Contains $businessIndex $businessDocsEntry "根业务域入口 $businessDocsEntry"
Assert-Contains $businessFacts $businessDocsEntry "根业务域入口 $businessDocsEntry"
}
foreach ($removedPhaseDocsEntry in @('docs/一期/', 'docs/二期/', 'docs/三期/', 'docs/四期/')) {
Assert-NotContains $rootAgents $removedPhaseDocsEntry "根已删除阶段目录 $removedPhaseDocsEntry"
@@ -348,18 +412,18 @@ foreach ($removedPhaseDocsEntry in @('docs/一期/', 'docs/二期/', 'docs/三
Assert-NotContains $rootAgents '不含出库和派送' '根海外仓全流程范围'
# 新增稳定业务能力必须从根表和目标部署单元直接命中。
Assert-Contains $businessIndex '入库扣货、解除与凭证' '根入库扣货路由'
Assert-Contains $businessFacts '入库扣货、解除与凭证' '根入库扣货路由'
Assert-Contains $adminAgents '入库扣货、解除与凭证历史' 'Admin 入库扣货路由'
Assert-Contains $backendAgents '入库单扣货、解除、凭证与履约门禁' '后端入库扣货路由'
Assert-Contains $businessIndex '客户资金账户、充值、授信与业务扣费' '根客户资金账户路由'
Assert-Contains $businessFacts '客户资金账户、充值、授信与业务扣费' '根客户资金账户路由'
Assert-Contains $adminAgents '客户资金账户与柜级公共费用分摊' 'Admin 客户资金账户路由'
Assert-Contains $clientAgents '客户资金账户、授信与充值申请' 'Client 客户资金账户路由'
Assert-Contains $backendAgents '客户资金账户、充值、授信、冻结、结算与跨区资金指令' '后端客户资金账户路由'
Assert-Contains $businessIndex '顶层授权快照' '根海外顶层授权快照'
Assert-Contains $businessFacts '顶层授权快照' '根海外顶层授权快照'
Assert-Contains $backendAgents '顶层授权快照' '后端海外顶层授权快照'
Assert-Contains $businessIndex '目的港码头拖车与还空预警' '根目的港码头拖车路由'
Assert-Contains $businessIndex '与订舱级拖车预约是不同生命周期' '根两类拖车边界'
Assert-Contains $businessIndex '尾程订单控制与柜级入仓计划转换' '根尾程订单控制路由'
Assert-Contains $businessFacts '目的港码头拖车与还空预警' '根目的港码头拖车路由'
Assert-Contains $businessFacts '与订舱级拖车预约是不同生命周期' '根两类拖车边界'
Assert-Contains $businessFacts '尾程订单控制与柜级入仓计划转换' '根尾程订单控制路由'
Assert-Contains $adminAgents '目的港码头拖车与还空预警' 'Admin 目的港码头拖车路由'
Assert-Contains $adminAgents '尾程订单控制与柜级入仓计划转换' 'Admin 尾程订单控制路由'
Assert-Contains $backendAgents '目的港码头拖车与还空预警' '后端目的港码头拖车路由'
@@ -385,7 +449,7 @@ $firstlegAgents = $firstlegEntry + "`n" + $firstlegIndex
$lastlegAgents = $lastlegEntry + "`n" + $lastlegIndex
$warehouseAgents = Read-RequiredText (Join-Path $backendRoot 'warehouse/AGENTS.md')
$coloadOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/coload-groups-v1.yaml')
Assert-Contains $businessIndex 'modules/unified-orders/api/coLoadGroups.ts' '根同柜不可拆组入口'
Assert-Contains $businessFacts 'modules/unified-orders/api/coLoadGroups.ts' '根同柜不可拆组入口'
Assert-Contains $adminAgents '成员数是入仓片段数,不等于订单票数' 'Admin 同柜成员语义'
Assert-Contains $backendAgents 'loading/coload/controller/PlatformColoadGroupController.java' '后端同柜不可拆组入口'
Assert-Contains $firstlegAgents '服务端允许待审核、待测量/测量中、待分柜及装柜中订单' 'Firstleg 同柜建组边界'
@@ -409,7 +473,7 @@ Assert-Contains $lastlegAgents '/api/platform/v1/last-leg/inbound-plans/convert-
# 本轮新增的批量下单、测量计费、智能装柜、账单联查与导出重试必须从最近指引直达。
$fileAgents = Read-RequiredText (Join-Path $backendRoot 'file/AGENTS.md')
$financeAgents = Read-RequiredText (Join-Path $backendRoot 'finance/AGENTS.md')
Assert-Contains $businessIndex '客户头程订单批量导入' '根批量下单快速路由'
Assert-Contains $businessFacts '客户头程订单批量导入' '根批量下单快速路由'
Assert-Contains $orderAgents 'MerchantBatchImportService.java' 'Order 批量导入入口'
Assert-Contains $orderAgents '仓库目录必须翻页读取全部候选' 'Order 批量模板全量仓库约束'
Assert-Contains $clientAgents '中文批量导入' 'Client 批量导入入口'
@@ -435,7 +499,7 @@ $orderDeclarationService = Read-RequiredText (Join-Path $backendRoot 'order/src/
$unifiedOrderQueryService = Read-RequiredText (Join-Path $backendRoot 'order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java')
$firstlegDeclarationService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/OrderDeclarationSyncService.java')
$adminDeclarationEditor = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/unified-orders/components/OrderDeclarationEditorDialog.vue')
Assert-Contains $businessIndex '平台修改客户原始申报与履约渠道' '根订单申报快速路由'
Assert-Contains $businessFacts '平台修改客户原始申报与履约渠道' '根订单申报快速路由'
Assert-Contains $adminAgents '客户原始申报资料编辑与渠道变更' 'Admin 订单申报快速路由'
Assert-Contains $backendAgents '平台修改客户原始申报、履约渠道变更与跨服务同步' '后端订单申报快速路由'
Assert-Contains $orderAgents '平台版本化修改客户原始申报' 'Order 申报编辑入口'
@@ -448,7 +512,7 @@ Assert-Contains $orderDeclarationService 'ORDER_DECLARATION_UPDATED_V1' 'Order
Assert-Contains $firstlegDeclarationService 'mapper.byEvent(event.tenantId(), event.eventId())' 'Firstleg 申报 eventId 幂等'
Assert-Contains $firstlegDeclarationService 'event.version() < current.orderVersion()' 'Firstleg 申报版本单调性'
Assert-Contains $adminDeclarationEditor '仓库/PDA 实测数据不会被覆盖' 'Admin 申报事实边界提示'
Assert-Contains $businessIndex '统一列表仍固定显示 `PENDING_REVIEW` 并提供审核动作' '根待审核订单优先状态'
Assert-Contains $businessFacts '统一列表仍固定显示 `PENDING_REVIEW` 并提供审核动作' '根待审核订单优先状态'
Assert-Contains $orderAgents '统一主状态/下一动作仍固定为 `PENDING_REVIEW/APPROVE_ORDER`' 'Order 待审核优先状态'
Assert-Contains $unifiedOrderQueryService 'order.getStatus() == OrderStatus.SUBMITTED' 'Order 待审核状态判断'
Assert-Contains $unifiedOrderQueryService 'pendingReview ? "PENDING_REVIEW" : completed ? "COMPLETED" : summary.mainStatus()' 'Order 待审核主状态优先于完成态与履约投影'
@@ -461,14 +525,14 @@ $fbaPricingService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/mai
$inboundFeeDetailService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java')
$adminUnifiedOrdersApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/unified-orders/api/unifiedOrders.ts')
$adminCarrierRatesView = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue')
Assert-Contains $businessIndex '头程订单、批量审核预览与入仓码关系' '根批量审核预览路由'
Assert-Contains $businessFacts '头程订单、批量审核预览与入仓码关系' '根批量审核预览路由'
Assert-Contains $adminAgents '订单列表、批量审核预览、详情、商品图片与入库费用' 'Admin 批量审核预览路由'
Assert-Contains $backendAgents '平台订单批量审核预览' '后端批量审核预览路由'
Assert-Contains $orderAgents '平台批量审核预览' 'Order 批量审核预览入口'
Assert-Contains $orderOpenApi '/api/platform/v1/orders/first-leg/batch-review-preview:' 'Order 批量审核预览 OpenAPI'
Assert-Contains $batchReviewPreviewService '固定两次批量查询生成审核弹窗快照' 'Order 批量审核禁止逐票查询'
Assert-Contains $adminUnifiedOrdersApi '/platform/v1/orders/first-leg/batch-review-preview' 'Admin 批量审核预览 API'
Assert-Contains $businessIndex '客户专属报价、柜级阶梯运费与费用调整' '根客户专属报价路由'
Assert-Contains $businessFacts '客户专属报价、柜级阶梯运费与费用调整' '根客户专属报价路由'
Assert-Contains $adminAgents '客户专属价格簿、柜级阶梯运费与受控调价' 'Admin 客户专属报价路由'
Assert-Contains $backendAgents '客户专属价格簿、柜级阶梯运费与受控调价' '后端客户专属报价路由'
Assert-Contains $firstlegAgents '客户专属价格簿、柜级阶梯运费与调价边界' 'Firstleg 客户专属报价入口'
@@ -485,8 +549,8 @@ Assert-Path 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricing/
# 海外异常处理必须贯通证据、原出库来源和库存恢复,不能退化为仅修改派送状态。
$exceptionOrderService = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java')
$inventoryCommandService = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java')
Assert-Contains $businessIndex '海外出库、排车、派送、异常与回访' '根海外异常库存恢复路由'
Assert-Contains $businessIndex 'exceptionorder/' '根海外异常服务入口'
Assert-Contains $businessFacts '海外出库、排车、派送、异常与回访' '根海外异常库存恢复路由'
Assert-Contains $businessFacts 'exceptionorder/' '根海外异常服务入口'
Assert-Contains $lastlegIndex '退回海外仓须把实收按原出库来源恢复到服务端校验的真实托盘/库位' 'Lastleg 海外异常库存恢复规则'
Assert-Contains $adminOverseasAgents '库存恢复、异常完成与派送 `RETURNED` 由服务端原子推进' 'Admin 海外异常原子边界'
Assert-Contains $backendAgents '海外异常退回、库存恢复与客户回访' '后端海外异常库存恢复路由'
@@ -503,9 +567,9 @@ $pendingReviewOrderService = Read-RequiredText (Join-Path $backendRoot 'order/sr
$merchantInboundBillingService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java')
$clientSession = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/api/session.ts')
$clientHttp = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/api/http.ts')
Assert-Contains $businessIndex '`PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING`' '根智能装柜四态候选'
Assert-Contains $businessIndex '能否加入以服务端 `canAssign` 为准' '根智能装柜资格边界'
Assert-Contains $businessIndex '人工加入允许跨渠道,智能推荐仍按柜次渠道' '根智能装柜渠道边界'
Assert-Contains $businessFacts '`PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING`' '根智能装柜四态候选'
Assert-Contains $businessFacts '能否加入以服务端 `canAssign` 为准' '根智能装柜资格边界'
Assert-Contains $businessFacts '人工加入允许跨渠道,智能推荐仍按柜次渠道' '根智能装柜渠道边界'
Assert-Contains $backendAgents '/pending-review-assignments' '后端待审核装柜占位入口'
Assert-Contains $backendAgents '/unpicked-items' '后端未拣整批移除入口'
Assert-Contains $firstlegAgents '任一成员已拣则整组保留' 'Firstleg 同柜组整批移除边界'
@@ -516,7 +580,7 @@ Assert-Contains $firstlegOpenApi '/api/platform/v1/first-leg/smart-loading/plans
Assert-Contains $firstlegOpenApi '/api/platform/v1/first-leg/smart-loading/plans/{planId}/unpicked-items:' '未拣整批移除 OpenAPI'
Assert-Contains $pendingReviewOrderService 'orders.pendingReviewPage' 'Order 待审核候选权威查询'
Assert-Contains $pendingReviewOrderService '"PENDING_REVIEW"' 'Order 待审核候选状态映射'
Assert-Contains $businessIndex '客户入库结果与头程费用确认' '根客户入库费用确认入口'
Assert-Contains $businessFacts '客户入库结果与头程费用确认' '根客户入库费用确认入口'
Assert-Contains $firstlegAgents 'expectedFeeVersion' 'Firstleg 客户费用版本确认'
Assert-Contains $clientAgents 'components/InboundBillingPanel.vue' 'Client 入库费用确认入口'
Assert-Contains $firstlegOpenApi '/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing/confirm:' '客户费用确认 OpenAPI'
@@ -536,7 +600,7 @@ $smartLoadingService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/m
$inboundSchema = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/db/phase2-inbound-schema.sql')
$adminWeightPanel = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/inbounds/weight/components/WeightOcrPanel.vue')
$clientOrdersView = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/modules/first-leg/views/FirstLegOrdersView.vue')
Assert-Contains $businessIndex '称重确认、整票库位块与部分拣货资源结算' '根称重库位块路由'
Assert-Contains $businessFacts '称重确认、整票库位块与部分拣货资源结算' '根称重库位块路由'
Assert-Contains $backendAgents '称重确认、整票库位块与 PDA 资源结算' '后端称重库位块路由'
Assert-Contains $firstlegAgents 'MeasurementLocationOccupationService.java' 'Firstleg 称重库位块入口'
Assert-Contains $firstlegAgents '最新测量批次的正数 `totalVolumeCbm/originalVolumeCbm` 取较大值' 'Firstleg 库位块测量批次体积选择'
@@ -556,7 +620,7 @@ Assert-Contains $inboundSchema 'UNIQUE KEY uk_pallet_allocation_weight (tenant_i
Assert-Contains $adminWeightPanel 'row.locationCode' 'Admin 称重卡片库位消费'
Assert-Contains $adminWeightPanel 'putawayStatusLabel(row.slotStatus)' 'Admin 称重卡片槽位状态'
Assert-NotContains $adminWeightPanel 'class="putaway-guide"' 'Admin 已移除整票预留引导区'
Assert-Contains $businessIndex '`totalPackageCount/remainingPackageCount`' '根智能装柜暂估数量'
Assert-Contains $businessFacts '`totalPackageCount/remainingPackageCount`' '根智能装柜暂估数量'
Assert-Contains $firstlegAgents '`totalPackageCount/remainingPackageCount`' 'Firstleg 智能装柜暂估数量'
Assert-Contains $adminAgents '`totalPackageCount/remainingPackageCount`' 'Admin 智能装柜暂估数量'
Assert-Contains $firstlegOpenApi 'required: [id, packageCount, totalPackageCount, remainingPackageCount' '智能装柜计划数量 OpenAPI'
@@ -575,7 +639,7 @@ $iamMemberService = Read-RequiredText (Join-Path $backendRoot 'iam/src/main/java
$adminCustomersView = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/customers/pages/CustomersView.vue')
$clientCompanyProfile = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/modules/tenant-management/views/CompanyProfileView.vue')
$clientMembersView = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/modules/tenant-management/views/MembersView.vue')
Assert-Contains $businessIndex '公司联系电话/邮箱是企业资料,不作为成员登录标识' '根企业联系人边界'
Assert-Contains $businessFacts '公司联系电话/邮箱是企业资料,不作为成员登录标识' '根企业联系人边界'
Assert-Contains $adminAgents '公司联系电话只作企业联系信息,不修改管理员/成员登录手机号' 'Admin 企业联系人边界'
Assert-Contains $clientAgents '成员登录手机号可选,修改时同步 Auth、撤销该账号会话并审计' 'Client 成员登录手机号边界'
Assert-Contains $authAgents '登录标识与企业联系人解耦' 'Auth 登录标识入口'
@@ -589,7 +653,7 @@ Assert-Contains $clientCompanyProfile '不会修改任何成员的登录手机
Assert-Contains $clientMembersView '用于该成员手机号登录;修改后会同步认证账号并退出该成员现有会话' 'Client 成员登录手机号提示'
$fileAgents = Read-RequiredText (Join-Path $backendRoot 'file/AGENTS.md')
$qualificationOpenApi = Read-RequiredText (Join-Path $backendRoot 'file/src/main/resources/openapi/qualification-required-package-v1.yaml')
Assert-Contains $businessIndex '法人身份证选填,可在已有营业执照后单独补传或替换' '根法人身份证单独上传语义'
Assert-Contains $businessFacts '法人身份证选填,可在已有营业执照后单独补传或替换' '根法人身份证单独上传语义'
Assert-Contains $fileAgents '法人身份证选填,客户可在已有营业执照后通过 `/api/merchant/v1/files/qualifications` 单独新增或替换' 'File 法人身份证单独上传入口'
Assert-Contains $qualificationOpenApi '/api/merchant/v1/files/qualifications:' '客户单文件资质 OpenAPI'
Assert-Contains $qualificationOpenApi 'LEGAL_PERSON_ID 可在已有营业执照后单独新增或替换' '客户法人身份证单独上传契约'
@@ -605,8 +669,8 @@ $adminLastMileOrders = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-w
$loadingListsOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/loading-lists-v1.yaml')
$pdaLoadingApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/api/pda-loading.js')
$cabinetEvidenceService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceService.java')
Assert-Contains $businessIndex '拖车预约由 `RELEASED` 订舱拥有,不按客户、柜次、装柜计划或真实柜号归属' '根订舱级拖车语义'
Assert-Contains $businessIndex '证据以 `taskId + warehouseId` 进入任务专用 API' '根柜级证据入口'
Assert-Contains $businessFacts '拖车预约由 `RELEASED` 订舱拥有,不按客户、柜次、装柜计划或真实柜号归属' '根订舱级拖车语义'
Assert-Contains $businessFacts '证据以 `taskId + warehouseId` 进入任务专用 API' '根柜级证据入口'
Assert-Contains $adminAgents '订舱级拖车预约' 'Admin 拖车快速入口'
Assert-Contains $adminAgents '不选择或提交客户、柜次、装柜计划或真实集装箱号' 'Admin 拖车订舱级边界'
Assert-Contains $backendAgents '订舱级拖车' '后端拖车快速入口'
@@ -645,7 +709,7 @@ Assert-Contains $adminAgents '确认预装和创建 PDA 任务前必须补齐' '
Assert-Contains $pdaAgents '当前 `taskId + warehouseId` 走任务专用 API' '国内 PDA 柜级证据入口'
Assert-Contains $pdaLoadingApi '/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/evidence?warehouseId=' '国内 PDA 证据查询 API'
Assert-NotContains $pdaLoadingApi '/inbounds/${encodeURIComponent(inboundId)}/loading/evidence' '国内 PDA 旧入仓证据 API'
Assert-Contains $businessIndex '上传以幂等键绑定包含文件内容的请求哈希' '根装柜证据上传重放边界'
Assert-Contains $businessFacts '上传以幂等键绑定包含文件内容的请求哈希' '根装柜证据上传重放边界'
Assert-Contains $firstlegAgents '完全重放返回当前视图且不得再次上传 File' 'Firstleg 装柜证据重放边界'
Assert-Contains $cabinetEvidenceService 'uploadHash(warehouseId, expectedVersion, evidenceType, sealNo, remark, multipart)' '装柜证据上传请求哈希'
Assert-Contains $cabinetEvidenceService 'digest.update(multipart.getBytes())' '装柜证据文件内容哈希'
@@ -819,7 +883,7 @@ foreach ($overseasFulfillmentRoute in @(
foreach ($overseasFulfillmentRootEntry in @(
'OutboundsView.vue', 'DeliveryOrdersView.vue', 'ExceptionOrdersView.vue', 'CustomerVisitsView.vue'
)) {
Assert-Contains $businessIndex $overseasFulfillmentRootEntry '根海外履约正式业务路由'
Assert-Contains $businessFacts $overseasFulfillmentRootEntry '根海外履约正式业务路由'
}
Assert-Contains $adminAgents '海外排车、出库与派送' 'Admin 海外出库派送入口'
Assert-Contains $adminAgents '海外异常退回与客户回访' 'Admin 海外异常回访入口'
@@ -936,8 +1000,8 @@ $containerCustomsOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/s
$adminContainerCustomsApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/container-loading/api/platformContainerCustoms.ts')
$clientContainerCustomsApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/modules/first-leg/api/customs.ts')
$customsFileOpenApi = Read-RequiredText (Join-Path $backendRoot 'file/src/main/resources/openapi/customs-file-and-export-v1.yaml')
Assert-Contains $businessIndex 'ContainerCustomsPanel.vue' '根整柜报关 Admin 入口'
Assert-Contains $businessIndex 'CustomsDocumentsDialog.vue' '根整柜报关 Client 入口'
Assert-Contains $businessFacts 'ContainerCustomsPanel.vue' '根整柜报关 Admin 入口'
Assert-Contains $businessFacts 'CustomsDocumentsDialog.vue' '根整柜报关 Client 入口'
Assert-Contains $adminAgents 'components/ContainerCustomsPanel.vue' 'Admin 整柜报关入口'
Assert-Contains $adminAgents 'complete-all-buy' 'Admin 全买单柜入口'
Assert-Contains $clientAgents 'components/CustomsDocumentsDialog.vue' 'Client 退税报关资料入口'
@@ -981,7 +1045,7 @@ Assert-Contains $lastlegBusinessOpenApi 'snapshotVersion' 'Lastleg 整柜计划
Assert-Contains $lastlegBusinessOpenApi 'snapshotHash' 'Lastleg 整柜计划快照哈希'
Assert-Contains $lastlegBusinessOpenApi 'sourceContainerBookingId' 'Lastleg 稳定来源柜身份'
Assert-Contains $adminOverseasAgents '/orders/container-summaries' 'Admin 柜级摘要目标入口'
Assert-Contains $businessIndex '尾程订单控制与柜级入仓计划转换' '根柜级计划入口'
Assert-Contains $businessFacts '尾程订单控制与柜级入仓计划转换' '根柜级计划入口'
Assert-Contains $backendAgents '分页返回全部稳定来源柜' '后端全柜工作台范围'
Assert-Contains $adminOverseasAgents '服务端分页返回全部稳定来源柜' 'Admin 全柜工作台范围'
Assert-Contains $lastlegAgents '分页返回全部稳定来源柜' 'Lastleg 全柜工作台范围'
@@ -1027,12 +1091,12 @@ $financeEventConsumer = Read-RequiredText (Join-Path $backendRoot 'finance/src/m
$financePhase1Service = Read-RequiredText (Join-Path $backendRoot 'finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java')
$overseasFinancePhase0ContractPath = Join-Path $workspaceRoot 'docs/财务管理/海外仓费用统一接入阶段0技术契约.md'
$overseasFinancePhase0Contract = Read-RequiredText $overseasFinancePhase0ContractPath
Assert-Contains $businessIndex '财务管理与柜级公共费用分摊' '根柜费分摊快速路由'
Assert-Contains $businessIndex '海外业务费用来源与客户财务' '根海外费用快速路由'
Assert-Contains $businessFacts '财务管理与柜级公共费用分摊' '根柜费分摊快速路由'
Assert-Contains $businessFacts '海外业务费用来源与客户财务' '根海外费用快速路由'
Assert-Contains $adminAgents '财务、客户资金账户与柜级公共费用分摊' 'Admin 柜费分摊快速路由'
Assert-Contains $backendAgents '柜级公共费用跨客户分摊' '后端柜费分摊快速路由'
Assert-Contains $backendAgents '海外业务费用来源跨区入池' '后端海外费用快速路由'
Assert-Contains $businessIndex '海外仓费用统一接入阶段0技术契约.md' '根海外费用阶段 0 按需入口'
Assert-Contains $businessFacts '海外仓费用统一接入阶段0技术契约.md' '根海外费用阶段 0 按需入口'
Assert-Contains $financeAgents '海外仓费用统一接入阶段0技术契约.md' 'Finance 海外费用阶段 0 按需入口'
Assert-Contains $lastlegAgents '海外仓费用统一接入阶段0技术契约.md' 'Lastleg 海外费用阶段 0 按需入口'
Assert-Contains $lastlegAgents '不能因文档存在宣称已实现' '阶段 0 设计与代码事实边界'
@@ -1066,16 +1130,16 @@ Assert-Contains $financeBusinessClient '/internal/finance/v1/business-fee-source
Assert-Contains $financeBusinessClient '/internal/finance/v1/wallet-command-events' 'Finance 资金内部入口'
Assert-Contains $financeAgents '`eventId + payloadHash` 防重放冲突' 'Finance 海外事件幂等边界'
Assert-Contains $financeAgents '`PAYABLE + DIRECT_PAID` 审核版本必须生成 `BUSINESS_DIRECT_PAID`' 'Finance 直接已付隔离边界'
Assert-Contains $businessIndex '入库扣货、解除与凭证' '根入库扣货快速路由'
Assert-Contains $businessFacts '入库扣货、解除与凭证' '根入库扣货快速路由'
Assert-Contains $adminAgents '入库扣货、解除与凭证历史' 'Admin 入库扣货快速路由'
Assert-Contains $backendAgents '入库单扣货、解除、凭证与履约门禁' '后端入库扣货快速路由'
Assert-Contains $firstlegAgents '入库单扣货、履约门禁与凭证' 'Firstleg 入库扣货入口'
Assert-Contains $businessIndex '客户资金账户、充值、授信与业务扣费' '根客户钱包快速路由'
Assert-Contains $businessFacts '客户资金账户、充值、授信与业务扣费' '根客户钱包快速路由'
Assert-Contains $backendAgents '客户资金账户、充值、授信、冻结、结算与跨区资金指令' '后端客户钱包快速路由'
Assert-Contains $lastlegAgents '海外业务资金指令与结果' 'Lastleg 跨区钱包入口'
Assert-Contains $lastlegAgents '拆柜打托成本与客户应收分摊' 'Lastleg 拆柜打托费用入口'
Assert-Contains $clientAgents '换标申请必须提交正数 CNY `chargeAmount`' 'Client 换标钱包金额边界'
Assert-Contains $businessIndex '统一认证与服务通信契约.md' '根统一认证契约入口'
Assert-Contains $businessFacts '统一认证与服务通信契约.md' '根统一认证契约入口'
Assert-Contains $backendAgents '各端共用 Gateway 和 Redis 会话' '后端统一认证目标'
$unifiedCommunication = Read-RequiredText (Join-Path $workspaceRoot 'docs/统一认证与服务通信契约.md')
Assert-Contains $unifiedCommunication 'JWT 不携带角色或权限集合' '统一 JWT 最小身份边界'
@@ -17,7 +17,8 @@ const serviceCompose = (await readFile(new URL('../deploy/docker-compose.yml', i
test('both pipelines reject the placeholder registry from failed build 662 before Maven or Docker', () => {
for (const source of [coordinatorPipeline, pipeline]) {
assert.match(source, /string\(name: 'IMAGE_REPOSITORY', defaultValue: ''/)
assert.match(source, /string\(name: 'IMAGE_REPOSITORY', defaultValue: params\.IMAGE_REPOSITORY \?: ''/,
'declarative parameter refresh must retain this job\'s configured registry instead of resetting it')
const guard = source.match(/(?:requestedRepository|imageRepository)\.tokenize\('\/'\)\[0\] ==~ \/\(\?i\)([^\n]+)\//)
assert.ok(guard, 'registry host must reject reserved example domains')
const reservedHost = new RegExp(`^(?:${guard[1]})$`, 'i')