diff --git a/AGENTS.md b/AGENTS.md index 39dcd403f..0a0e87928 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,12 @@ - 当前一期、二期、三期及财务统一进入全量验收;按本次链路读取对应阶段/专题文档,不以历史阶段终点阻断跨服务联调或回归。 - `auth`、`iam`、`warehouse`、`order`、`firstleg`、`file`、`finance`、`integration-service` 及出仓相关能力按各自模块契约协作;状态、权限、数据库和 OpenAPI 以当前实现及已确认契约为准。 +## 国内头程与海外仓协同 + +- 国内头程 Auth 是唯一用户账号和 Token 签发方;Admin/Client 共用一套前端和用户体系,海外仓不得另建登录或权限模型。 +- 统一用户 Token 使用 RS256,海外服务通过 JWKS 公钥验证;国内服务可继续叠加 Redis 会话校验,海外服务不得访问国内 Redis,使用短时 Claims 完成本地认证和应用域/客户范围校验。 +- 头程与海外仓的入库单、装柜结果、费用来源和财务回传走 MQ Outbox/Inbox 事件链;事件不携带用户 JWT,服务身份由 MQ TLS/ACL 或内部服务令牌负责。跨区事件契约、幂等和死信分别查 `common-event-contract/`、`event-bridge-service/` 与目标消费者。 + ## 契约、数据库与验证入口 - 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。 @@ -46,13 +52,13 @@ - 平台后台存在正式列表页时,`*:list` 同时授权列表、详情及只读辅助接口;`create`、`edit`、`delete`、`status`、`approve`、`review`、`export`、`upload`、`complete` 等操作保持独立。当前未上线,不兼容旧独立 `*:view`,Controller 不得使用 `hasAnyAuthority(list, view)`,OpenAPI 和角色初始化只保留 `*:list`。 - 供应商查询接口固定只使用 `platform:supplier:list`;IAM 中 `platform:supplier:list` 是正式菜单资源,`platform:supplier:view` 必须停用/移除,不得重新提升或兼容。 - 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。 -- 数据库只访问所属服务库。模块迁移位于 `/src/main/resources/db/`;测试/存量环境唯一部署入口是 `deploy/sql/头程数据库统一增量-MySQL8.sql`,说明见 `deploy/sql/README.md`,历史日期脚本仅追溯。 +- 数据库只访问所属服务库。模块表结构位于 `/src/main/resources/db/`;当前开发阶段直接更新最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql`,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作现有结构来源和开发追溯,部署说明见 `deploy/sql/README.md`。 - 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。 - Java 签名变更的最低编译门禁是 `mvn -pl -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。 ## 全量 HTTP 验收脚本路由 -- 全量验收唯一指向测试环境 HTTP:平台端执行 `scripts/admin-test-env-acceptance.mjs`,客户端执行 `scripts/client-test-env-acceptance.mjs`;二期兼容场景才单独执行 `scripts/client-phase2-test-env-acceptance.mjs`。 +- 全量验收唯一指向测试环境 HTTP:平台端执行 `scripts/admin-test-env-acceptance.mjs`,客户端执行 `scripts/client-test-env-acceptance.mjs`;二期专项场景才单独执行 `scripts/client-phase2-test-env-acceptance.mjs`,不替代客户端全量入口。 - Admin 总入口默认按模块列表覆盖平台全链路;需要定位单个业务时使用 `--modules `,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。 - 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。 - 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。 diff --git a/auth/AGENTS.md b/auth/AGENTS.md index 2b49bd724..b1c685f90 100644 --- a/auth/AGENTS.md +++ b/auth/AGENTS.md @@ -12,7 +12,8 @@ |---|---| | 登录/刷新/退出/改密 | `src/main/java/com/baiyue/auth/controller/AuthController.java` | | 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` | -| 小 JWT 签发 | `service/AuthService.java`;JWT 只承载稳定身份、应用域和版本声明,角色/权限随 Redis 会话加载 | +| 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`、JWKS 公钥发布 Controller;Auth 是国内与海外仓共用的唯一用户 Token 签发方 | +| 小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;JWT 只承载稳定身份、应用域和版本声明,国内服务用 Redis 会话校验,海外服务不访问国内 Redis | | Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` | | 注册协作 | `service/MerchantRegistrationService.java` | | IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` | @@ -22,7 +23,7 @@ ## 修改约束 -- 密码、Token、敏感 Claim 不得写日志;小 JWT 不嵌入角色和权限全集,实时授权来自 Redis 会话中的 IAM 授权快照。修改 Claim、Redis Key、权限快照或过期策略时联查 `common-security/`、`gateway/`、`iam/`。 +- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。国内小 JWT 的实时权限来自 Redis 会话快照,海外服务使用短时 Claims,不访问国内 Redis。修改 Claim、JWKS、权限快照或过期策略时联查 `common-security/`、`gateway/`、`iam/` 和海外服务消费者。 - 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。 - 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。 diff --git a/common-core/AGENTS.md b/common-core/AGENTS.md index 56599c468..277a9fa7d 100644 --- a/common-core/AGENTS.md +++ b/common-core/AGENTS.md @@ -18,7 +18,7 @@ ## 修改与验证 -- 公共响应、错误码或基础实体变化会影响所有服务;先检索消费者并保持序列化和数据库兼容。 +- 公共响应、错误码或基础实体变化会影响所有服务;先检索并同步修改全部消费者、测试和契约,当前开发阶段不保留旧序列化或数据库兼容分支。 - `ApiResponse.successful()` 只认字符串 `"200"`。不得恢复旧成功码 `"0"`,也不得把 `code` 改成数字;前端和 Feign 调用方都按字符串严格判断。 - `ResultCode.httpStatus` 保留错误分类元数据,但普通业务异常的实际 HTTP 状态由 `common-web/GlobalExceptionHandler` 统一收敛为 200;真实 HTTP 状态例外见 `../docs/统一响应约定改造方案-HTTP200.md`。 - 新能力只有被多个模块稳定复用时才放入本模块。 diff --git a/common-event-contract/AGENTS.md b/common-event-contract/AGENTS.md index 0b00b4c7f..ec95783fb 100644 --- a/common-event-contract/AGENTS.md +++ b/common-event-contract/AGENTS.md @@ -14,12 +14,13 @@ | 事件 ID 与业务键 | `EventIdentifiers.java` | | Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` | | 事件类型与区域 | `EventTypes.java`、`RegionCode.java` | +| 头程装柜完成 V1 | `FirstLegLoadingPlanCompletedV1.java`;国内生产者见 `../firstleg/service/LoadingCompletedOutboxService.java` | | 契约说明 | `package-info.java` | -| 兼容性测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java` | +| 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java` | ## 修改与验证 -- 所有字段与事件类型都视为跨仓公共契约;优先新增可选字段和新版本,禁止无迁移直接改名、改语义或删除。 -- 同步检查 `event-bridge-service/`、国内生产者以及固定 BOM 版本的尾程消费者。 -- 哈希算法或规范化规则变化必须保留跨语言、跨版本确定性测试。 +- 所有字段与事件类型都视为跨仓公共契约;当前开发阶段不保留旧事件字段、旧版本或兼容解析,目标契约变化时直接修改当前模型,并同步更新 `event-bridge-service/`、国内生产者、尾程消费者和全部契约测试。 +- 哈希算法或规范化规则变化必须同步更新全部生产者和消费者,并保留跨语言确定性测试。 +- MQ 事件不携带用户 Access Token;生产者和消费者使用 MQ TLS/ACL 或内部服务身份,事件信封负责事件 ID、业务键、来源/目标区域、版本和 Payload 哈希。 - 从父目录运行 `mvn -pl common-event-contract -am test`。 diff --git a/common-openapi/AGENTS.md b/common-openapi/AGENTS.md index a98583deb..59eed2647 100644 --- a/common-openapi/AGENTS.md +++ b/common-openapi/AGENTS.md @@ -18,6 +18,6 @@ - 调整分组、标题或安全方案时检查所有 MVC 服务的 Swagger 输出;Gateway 的 WebFlux 聚合配置在 `gateway/`。 - 普通业务成功和失败在 OpenAPI 中统一描述为 HTTP 200,并提示调用方检查 body `code`;只为 Security、协议、基础设施、Warehouse 503、Audit 202 和 Event Bridge 内部鉴权等真实状态边界声明非 200 响应。 -- 配置项变化优先保持旧键兼容,并同步 Nacos 模板。 +- 配置项变化直接同步代码、全部消费者和 Nacos 模板,当前开发阶段不保留旧配置键兼容。 - 从父目录运行 `mvn -pl common-openapi -am test`。 - 所有 Java `Long/long` 在 OpenAPI 中统一声明为字符串,与 HTTP JSON 序列化保持一致。 diff --git a/common-security/AGENTS.md b/common-security/AGENTS.md index 666aab4ce..80232c3b5 100644 --- a/common-security/AGENTS.md +++ b/common-security/AGENTS.md @@ -9,17 +9,19 @@ | 功能 | 入口 | |---|---| -| 自动配置 | `src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java`、`HmacJwtDecoderAutoConfiguration.java` | +| 安全链自动配置 | `src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java` | +| JWT 解码模式 | `JwksJwtDecoderAutoConfiguration.java`(国内与海外统一 RS256/JWKS);`HmacJwtDecoderAutoConfiguration.java` 仅作当前开发阶段清理前的旧实现定位,不得作为跨区域共享密钥方案 | | JWT 过滤与声明映射 | `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java` | | 当前用户与认证对象 | `AuthenticatedUser.java`、`BaiyueJwtAuthenticationToken.java` | -| Redis 会话校验 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`、`RedisTokenKeys.java` | +| 会话与授权来源 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`(国内即时失效)、`ClaimsAccessTokenSessionValidator.java`(海外本地短时 Claims)、`RedisTokenKeys.java` | | 401/403 响应 | `JsonAuthenticationEntryPoint.java`、`JsonAccessDeniedHandler.java` | | 自动配置注册 | `src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` | | 安全回归测试 | `src/test/java/com/baiyue/common/security/` | ## 修改与验证 -- Token Claim、Redis Key 或认证对象变化必须同步检查 `auth/`、`gateway/` 和所有业务服务。 +- 所有资源服务统一使用 `jwt-mode=jwks`、RS256、Issuer、Audience 和 JWKS;国内使用 `session-mode=redis` 叠加即时失效,海外使用 `session-mode=claims` 且不得访问国内 Redis。不得重新引入跨区域共享 HS256 密钥。 +- Token Claim、Redis Key、Issuer、Audience、JWKS、会话模式或认证对象变化必须同步检查 `auth/`、`gateway/`、Nacos/部署配置和所有实际业务服务。 - `JsonAuthenticationEntryPoint` 与 `JsonAccessDeniedHandler` 属于统一 HTTP 200 业务契约的例外:认证入口仍返回真实 401/403,供前端刷新 Token 和区分授权失败。 - 不降低服务端租户、会话或权限校验;密钥只从配置中心或环境变量读取。 - 从父目录运行 `mvn -pl common-security -am test`。 diff --git a/common-web/AGENTS.md b/common-web/AGENTS.md index c19ff581f..23b2f7310 100644 --- a/common-web/AGENTS.md +++ b/common-web/AGENTS.md @@ -22,6 +22,6 @@ - 普通 `BizException`、参数校验和兜底系统异常统一返回 HTTP 200 + 非 `"200"` body code;查询参数/消息解析失败使用 `A0400`,Bean Validation 使用 `A0422`,不要再按 HTTP 400/422 判断业务失败。 - 网关/Servlet Security 的真实 401/403、协议或基础设施错误、Warehouse readiness 503、Audit 202 与 Event Bridge 内部鉴权仍保留真实 HTTP 状态;完整边界见 `../docs/统一响应约定改造方案-HTTP200.md`。 - 修改自动配置时必须验证 `audit-service`、`event-bridge-service` 等只扫描自身包的应用仍能加载 `GlobalExceptionHandler` 和 `CorrelationIdFilter`。 -- 修改 Correlation ID 时检查请求头兼容、线程上下文清理和日志链路。 +- 修改 Correlation ID 时同步检查当前请求头契约、线程上下文清理和日志链路。 - 从父目录运行 `mvn -pl common-web -am test`。 - 所有对外 `Long/long` 值统一序列化为 JSON 字符串,避免 19 位雪花 ID 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 786107859..600871730 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -515,7 +515,7 @@ CREATE TABLE IF NOT EXISTS ord_order ( id BIGINT UNSIGNED NOT NULL COMMENT '订单ID(雪花ID)', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', order_no VARCHAR(32) NOT NULL COMMENT '客户范围内唯一订单号', - inbound_code VARCHAR(40) NOT NULL COMMENT '订单创建时生成并固定的入仓码:物流前缀+YYMMDD+全局票序', + inbound_code VARCHAR(40) NOT NULL COMMENT '订单创建时生成并固定的入仓码:物流前缀+YYMMDD+当天全平台票序', customer_id BIGINT UNSIGNED NULL COMMENT '客户逻辑ID', customer_name VARCHAR(128) NOT NULL COMMENT '下单时客户名称快照', status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订单业务状态:DRAFT草稿、SUBMITTED已提交、APPROVED审核通过、ACCEPTED已接单、REJECTED审核驳回、PROCESSING履约中、COMPLETED已完成、CANCELLED已取消', @@ -561,11 +561,13 @@ CREATE TABLE IF NOT EXISTS ord_order ( KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id) ) ENGINE=InnoDB COMMENT='头程订单主表:保存商户委托、目的仓、运输方式和订单当前状态'; -CREATE TABLE IF NOT EXISTS ord_inbound_code_sequence ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '全平台入仓码票序', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '票序分配时间', - PRIMARY KEY (id) -) ENGINE=InnoDB COMMENT='入仓码全局票序;不同客户和物流渠道共用且不按日期清零'; +CREATE TABLE IF NOT EXISTS ord_inbound_code_daily_sequence ( + business_date DATE NOT NULL COMMENT '上海时区业务日期', + current_value BIGINT UNSIGNED NOT NULL COMMENT '当天全平台已分配票数', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '当天首次分配时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最近分配时间', + PRIMARY KEY (business_date) +) ENGINE=InnoDB COMMENT='入仓码每日票序;不同客户和物流渠道共用,按上海日期重新从1开始'; CREATE TABLE IF NOT EXISTS ord_order_party ( id BIGINT UNSIGNED NOT NULL COMMENT '参与方快照ID', diff --git a/event-bridge-service/AGENTS.md b/event-bridge-service/AGENTS.md index f54206504..59c29eeec 100644 --- a/event-bridge-service/AGENTS.md +++ b/event-bridge-service/AGENTS.md @@ -17,6 +17,7 @@ | Inbox 抽象与 MyBatis 实现 | `service/InboxStore.java`、`MybatisInboxStore.java` | | Inbox 数据模型 | `domain/BridgeInbox.java`、`mapper/BridgeInboxMapper.java` | | 内部服务令牌 | `security/InternalServiceTokenGuard.java` | +| 国内装柜完成生产链 | `../firstleg/service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`client/EventBridgeClient.java`;Payload 为 `../common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV1.java` | | 表结构 | `src/main/resources/db/event-bridge.sql` | | 测试 | `src/test/java/com/baiyue/eventbridge/security/`、`service/` | | 架构边界 | `../AGENTS.md` 的仓库边界、`../docs/头程项目目录结构与服务说明.md` 的主要调用关系与开发拆分约束 | @@ -28,5 +29,6 @@ - 跨区域接口必须鉴权、限流并最小化载荷,禁止携带密钥或非必要客户敏感数据。 - Event Bridge 内部令牌失败和接收基础设施不可用属于统一 HTTP 200 业务契约的明确例外,保留真实 401/503;已经通过 Controller 进入公共异常处理的普通业务失败才使用 HTTP 200 + body code。 - 本服务只扫描 `com.baiyue.eventbridge`,统一异常和 Correlation 能力依赖 `common-web/CommonWebAutoConfiguration` 自动装配;调整公共 Web 配置后追加本模块上下文测试。 -- 契约变化先遵守 `../common-event-contract/AGENTS.md` 的兼容规则,并检查国内外消费者。 +- 契约变化按 `../common-event-contract/AGENTS.md` 直接同步当前模型、国内生产者和国外消费者,不保留旧事件兼容分支。 +- Event Bridge 只负责事件接收、校验和 Inbox 幂等,不负责用户登录或 JWT 转发;MQ 投递链路的服务身份与用户 Token 分离。 - 从父目录运行 `mvn -pl event-bridge-service -am test`。 diff --git a/file/AGENTS.md b/file/AGENTS.md index f392a6bf6..fa856c528 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -12,6 +12,7 @@ | 商户/平台资质 | `controller/QualificationFileController.java`、`PlatformQualificationFileController.java` | | 导出中心 | `controller/PlatformExportTaskController.java`、`service/ExportStateService.java`、`mq/` | | 测量图片/证据 | `controller/PlatformMeasurementImageController.java`、`InternalMeasurementImageController.java`、`service/MeasurementImageService.java` | +| 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` | | 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` | | 下游 OCR | `client/WeightOcrClient.java` | | SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` | diff --git a/finance/AGENTS.md b/finance/AGENTS.md index 5e8227ef2..6eb33fee2 100644 --- a/finance/AGENTS.md +++ b/finance/AGENTS.md @@ -13,6 +13,7 @@ | 平台账户、费用池、账单、收款与成本 API | `controller/PlatformFinanceController.java`,统一前缀 `/api/platform/finance/v1` | | 客户只读账单、收款与应收 API | `controller/ClientFinanceController.java`,统一前缀 `/api/client/finance/v1` | | 一期账户、费用来源审核与结算纳入 | `controller/FinancePhase1OperationController.java`、`service/FinancePhase1OperationService.java` | +| 客户财务账户汇总 | `service/FinanceCustomerAccountService.java`、`client/IamCustomerClient.java`;客户主档事实仍归 IAM | | 客户对账单 | `controller/ReconciliationStatementController.java`、`service/ReconciliationStatementService.java` | | 应收/应付结算单 | `controller/FinanceSettlementController.java`、`service/FinanceSettlementService.java` | | 五页产品 OpenAPI | `../../docs/财务管理/openapi/finance-phase1-five-pages-v1.yaml`;人工财务补充契约 `finance-f1-manual-v1.yaml` | diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 15502383a..f930399c2 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -2,7 +2,7 @@ ## 职责与边界 -- 负责履约任务/分配、入库单、测量/OCR、审核、FBA 计费、完成入库编排、客户在途柜量统计及国内出仓履约链路。 +- 负责履约任务/分配、入库单、测量/OCR、审核后初始计价、FBA 计费、客户在途柜量统计及国内出仓履约链路。 - 订单主数据归 `order/`,托盘/库位归 `warehouse/`,文件对象归 `file/`,OCR 供应商归 `integration-service/`;通过客户端 DTO 和内部令牌协作。 - 入库、分柜、装柜、PDA 和出仓能力均在本模块按对应服务端契约协作;`WAIT_SPLIT + APPROVED + CONFIRMED` 是入库链路状态节点,不是全量验收的范围终点。财务状态仍归 `finance`,本模块只生产不可变费用来源和柜次成本快照。 @@ -15,7 +15,7 @@ | 平台测量/OCR | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java` | | 箱唛箱码生成与版本化 | `repository/InboundCartonLabelRepository.java`、`service/InboundCartonLabelService.java`;基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql` | | 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` | -| 审核/自动完成 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`InboundCompletionOrchestrator.java` | +| 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` | | FBA 主数据/计费 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java` | | 入库费用明细 | `controller/PlatformInboundFeeDetailController.java`、`service/InboundFeeDetailService.java`、`dto/InboundFeeDetailDtos.java`;Admin 消费端见 `../../apps/admin-web/src/modules/unified-orders/api/inboundFeeDetails.ts` | | 客户费用查询 | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java` | @@ -27,11 +27,12 @@ | 分柜与预装 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java` | | 货柜与智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`loading/` | | PDA 拣货与装柜 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java` | +| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`client/EventBridgeClient.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 海运订舱与船期 | `oceanbooking/controller/PlatformOceanBookingController.java`、`portschedule/controller/PlatformCarrierScheduleQueryController.java`、`PlatformPortScheduleController.java` | | 拖车与出仓调度 | `controller/PlatformTruckAppointmentController.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java` | | 报关协同 | `customs/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` | -| 跨模型兼容桥 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` | -| 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java` | +| 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` | +| 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java`、`LoadingEvidenceFileClient.java`、`EventBridgeClient.java` | | SQL/OpenAPI | `src/main/resources/db/phase2-*.sql`、`src/main/resources/db/phase3-*.sql`、`src/main/resources/openapi/`;部署统一 `../deploy/sql/头程数据库统一增量-MySQL8.sql` | | 测试/验证 | `src/test/java/com/baiyue/firstleg/`;`mvn -pl firstleg -am test` | @@ -47,11 +48,11 @@ - 智能装柜候选只能按同一国内中转仓合柜;推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。 - PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;首次打印冻结箱码映射,重复打印和墨家测量复用同一箱码,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。 - PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。 -- `releaseMode=AUTO` 时,释放资源数为 `ceil(实际拣货箱数 × 原资源数 / 原可用箱数)`;`MANUAL` 按 `locationIds` 选择,空数组表示明确释放 0 个。未传模式时,`locationIds=null` 兼容为 AUTO。 +- `releaseMode=AUTO` 时,释放资源数为 `ceil(实际拣货箱数 × 原资源数 / 原可用箱数)`;`MANUAL` 按 `locationIds` 选择,空数组表示明确释放 0 个。当前契约中未传模式且 `locationIds=null` 时按 AUTO 处理。 - 头程快照必须保存仓库 `reservation_id`。A0 是共享临时库位,多个托盘可具有相同 `location_id`,跨服务结算、转移、释放必须使用 `reservationId`,不得把 `locationId` 当作唯一资源标识。 - 部分拣货资源结算必须调用 Warehouse 的 `/internal/warehouse/v1/pallet-locations/settlements` 原子接口;禁止拆成独立释放和转移调用。全部释放但子单仍有数量时,由仓库服务重新选择同仓普通库位,不足才落 A0。 -- 数据库字段变更同时维护 `src/main/resources/db/phase3-pda-release-allocation-migration.sql` 和 `../deploy/sql/头程数据库统一增量-MySQL8.sql`。 +- 数据库字段变更直接维护当前模块基线与 `../deploy/sql/头程数据库统一增量-MySQL8.sql`;已有 `phase3-pda-release-allocation-migration.sql` 只作为当前结构来源,不为历史开发数据再新增兼容迁移。 ## 全量验收导航 -本模块承载一期履约、二期入库测量计费、三期订单编排/柜次/排柜/订舱/拖车/PDA/`LOADED` 门禁等全量链路;按本次验收范围读取对应阶段文档、Controller、DTO、OpenAPI、迁移和调用方,不以历史阶段终点阻断联调。PDA 按 uni-app 在线更新安排开发与验收,不另设设备型号门禁。 +本模块承载一期履约、二期入库测量计费、三期订单编排/柜次/排柜/订舱/拖车/PDA/`LOADED` 门禁等全量链路;按本次验收范围读取对应阶段文档、Controller、DTO、OpenAPI、当前 SQL 定义和调用方,不以历史阶段终点阻断联调。PDA 按 uni-app 在线更新安排开发与验收,不另设设备型号门禁。 diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 3e1194788..093b8d4f2 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -356,7 +356,7 @@ paths: type: object required: [boxCode, length, width, height, machineNo, picture] properties: - boxCode: {type: string, maxLength: 128, example: 'ZBYHK2608140897#4-1'} + boxCode: {type: string, maxLength: 128, example: 'ZBYHK2608140001#4-1'} shipment_id: {type: string, maxLength: 64, example: 'FBA19L5KMY2CU000002', description: '可选;设备从Amazon FBA箱标实际扫描得到,有则原样上传;系统只保存,不生成且不覆盖客户下单值'} length: {type: number, format: decimal, minimum: 0.001} width: {type: number, format: decimal, minimum: 0.001} @@ -982,7 +982,7 @@ components: tenantId: {type: string, pattern: '^[1-9][0-9]*$', description: 当前客户隔离 ID} orderId: {type: string, pattern: '^[1-9][0-9]*$'} orderNo: {type: string, nullable: true} - inboundCode: {type: string, minLength: 1, maxLength: 40, example: ZBYHK2608140897} + inboundCode: {type: string, minLength: 1, maxLength: 40, example: ZBYHK2608140001} customerId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} customerName: {type: string, minLength: 1} warehouseId: {type: string, pattern: '^[1-9][0-9]*$'} @@ -1217,7 +1217,7 @@ components: eventNo: {type: string, maxLength: 128, pattern: '^[A-Za-z0-9._:-]+$', example: M202608010001} externalDetailNo: {type: string, maxLength: 128, pattern: '^[A-Za-z0-9._:-]+$', example: BOX-20260801-0001} warehouseCode: {type: string, maxLength: 50, example: CN-YW-01} - inboundCode: {type: string, maxLength: 40, example: ZBYHK2608140897} + inboundCode: {type: string, maxLength: 40, example: ZBYHK2608140001} inboundDetailId: {type: string, example: '190000000000000001', description: 柏悦订单货物明细ID} deviceCode: {type: string, maxLength: 64, example: DEVICE-01} measuredAt: {type: string, format: date-time, example: '2026-08-01T10:30:00+08:00'} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java index dd53f1372..868af9ece 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundCartonLabelServiceTest.java @@ -60,8 +60,8 @@ class InboundCartonLabelServiceTest { assertThat(batch.totalCount()).isEqualTo(5); assertThat(batch.labels()).extracting(label -> label.boxCode()) - .containsExactly("PCHK2608040174#5-1", "PCHK2608040174#5-2", "PCHK2608040174#5-3", - "PCHK2608040174#5-4", "PCHK2608040174#5-5"); + .containsExactly("PCHK2608140001#5-1", "PCHK2608140001#5-2", "PCHK2608140001#5-3", + "PCHK2608140001#5-4", "PCHK2608140001#5-5"); assertThat(batch.labels()).extracting(label -> label.inboundDetailId()) .containsExactly(80001L, 80001L, 80001L, 80002L, 80002L); assertThat(batch.labels().get(0).boxSpecification()).isEqualTo("60x40x30cm"); @@ -102,8 +102,8 @@ class InboundCartonLabelServiceTest { .thenReturn(ApiResponse.success(List.of(cargo(80001L, 1, 2, "60", "40", "30")))); var result = service.getOrCreate(10001L, 70001L); assertThat(result.labels()).extracting(label -> label.boxCode()) - .containsExactly("PCHK2608040174#5-1", "PCHK2608040174#5-2", "PCHK2608040174#5-3", - "PCHK2608040174#5-4", "PCHK2608040174#5-5"); + .containsExactly("PCHK2608140001#5-1", "PCHK2608140001#5-2", "PCHK2608140001#5-3", + "PCHK2608140001#5-4", "PCHK2608140001#5-5"); verify(repository, never()).insert(any()); } @@ -122,7 +122,7 @@ class InboundCartonLabelServiceTest { assertThat(result.totalCount()).isEqualTo(2); assertThat(result.labels()).extracting(label -> label.boxCode()) - .containsExactly("PCHK2608040174#2-1", "PCHK2608040174#2-2"); + .containsExactly("PCHK2608140001#2-1", "PCHK2608140001#2-2"); } @Test @@ -154,35 +154,35 @@ class InboundCartonLabelServiceTest { var result = service.getOrCreate(10001L, 70001L); assertThat(result.labels()).extracting(label -> label.boxCode()) - .containsExactly("PCHK2608040174#1-1"); + .containsExactly("PCHK2608140001#1-1"); verify(repository, never()).insert(any()); } @Test void resolvesBoxCodeInsideCustomerBoundary() { InboundCartonLabelEntity label = labels(inbound(), 1).get(0); - when(repository.findByBoxCode(10001L, "PCHK2608040174#1-1")).thenReturn(label); + when(repository.findByBoxCode(10001L, "PCHK2608140001#1-1")).thenReturn(label); - var resolved = service.resolveByBoxCode(10001L, " PCHK2608040174#1-1 "); + var resolved = service.resolveByBoxCode(10001L, " PCHK2608140001#1-1 "); assertThat(resolved.inboundId()).isEqualTo(70001L); assertThat(resolved.inboundDetailId()).isEqualTo(80001L); - assertThat(resolved.inboundCode()).isEqualTo("PCHK2608040174"); + assertThat(resolved.inboundCode()).isEqualTo("PCHK2608140001"); assertThat(resolved.cartonIndex()).isEqualTo(1); assertThat(resolved.totalCartons()).isEqualTo(1); assertThat(resolved.boxSpecification()).isEqualTo("60x40x30cm"); - verify(repository).findByBoxCode(10001L, "PCHK2608040174#1-1"); + verify(repository).findByBoxCode(10001L, "PCHK2608140001#1-1"); } @Test void exposesDetailedResolutionForFirstlegConsumers() { InboundCartonLabelEntity label = labels(inbound(), 1).get(0); - when(repository.findByBoxCode(10001L, "PCHK2608040174#1-1")).thenReturn(label); + when(repository.findByBoxCode(10001L, "PCHK2608140001#1-1")).thenReturn(label); - var resolved = service.resolveDetailsByBoxCode(10001L, "PCHK2608040174#1-1"); + var resolved = service.resolveDetailsByBoxCode(10001L, "PCHK2608140001#1-1"); assertThat(resolved.orderId()).isEqualTo(60001L); - assertThat(resolved.boxCode()).isEqualTo("PCHK2608040174#1-1"); + assertThat(resolved.boxCode()).isEqualTo("PCHK2608140001#1-1"); assertThat(resolved.cargoItemNo()).isEqualTo(1); } @@ -190,9 +190,9 @@ class InboundCartonLabelServiceTest { void rejectsInactiveBoxCodeDuringScanResolution() { InboundCartonLabelEntity label = labels(inbound(), 1).get(0); label.setBatchStatus("VOID"); - when(repository.findByBoxCode(10001L, "PCHK2608040174#1-1")).thenReturn(label); + when(repository.findByBoxCode(10001L, "PCHK2608140001#1-1")).thenReturn(label); - assertThatThrownBy(() -> service.resolveByBoxCode(10001L, "PCHK2608040174#1-1")) + assertThatThrownBy(() -> service.resolveByBoxCode(10001L, "PCHK2608140001#1-1")) .isInstanceOf(BizException.class) .hasMessage("箱码已失效,请重新打印"); } @@ -233,7 +233,7 @@ class InboundCartonLabelServiceTest { @Test void repairsMissingHistoricalInboundFromOwnedOrderSnapshot() { var snapshot = new com.baiyue.firstleg.dto.OrderApprovedInboundCommand( - 10001L, 60001L, "FL60001", "PCHK2608040174", 1L, "Customer", + 10001L, 60001L, "FL60001", "PCHK2608140001", 1L, "Customer", 41L, "Warehouse", null, null, null, "EXPORT_SEA", null, null, null, null, null, null, null, false, "FBA", null, null, null, null, null, null, 2); @@ -251,7 +251,7 @@ class InboundCartonLabelServiceTest { inbound.setId(70001L); inbound.setTenantId(10001L); inbound.setOrderId(60001L); - inbound.setInboundCode("PCHK2608040174"); + inbound.setInboundCode("PCHK2608140001"); inbound.setProcessStatus("WAIT_MEASURE"); inbound.setWarehouseName("MCC1"); inbound.setChannelName("洛杉矶普船海卡"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java index c69848b76..c51f8619d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderCreationServiceTest.java @@ -48,12 +48,12 @@ class InboundOrderCreationServiceTest { @Test void shouldUseSubmittedInboundCodeAndKeepReplayIdempotentWithoutCarrier() { - OrderApprovedInboundCommand command = approvedCommand("RK-CUSTOM-77"); + OrderApprovedInboundCommand command = approvedCommand("PCHK2608140001"); InboundOrderEntity existing = new InboundOrderEntity(); existing.setId(900L); existing.setTenantId(10001L); existing.setOrderId(77L); - existing.setInboundCode("RK-CUSTOM-77"); + existing.setInboundCode("PCHK2608140001"); existing.setTaskId(700L); when(orders.selectOne(any(Wrapper.class))).thenReturn(null, existing); when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L)); @@ -64,7 +64,7 @@ class InboundOrderCreationServiceTest { ArgumentCaptor inboundCaptor = ArgumentCaptor.forClass(InboundOrderEntity.class); verify(orders, times(1)).insert(inboundCaptor.capture()); verify(histories, times(1)).insert(any(InboundStatusHistoryEntity.class)); - assertThat(inboundCaptor.getValue().getInboundCode()).isEqualTo("RK-CUSTOM-77"); + assertThat(inboundCaptor.getValue().getInboundCode()).isEqualTo("PCHK2608140001"); assertThat(inboundCaptor.getValue().getTaskId()).isEqualTo(700L); assertThat(inboundCaptor.getValue().getCarrierId()).isNull(); assertThat(inboundCaptor.getValue().getTaxMode()).isEqualTo("BUY_DECLARATION"); @@ -88,25 +88,25 @@ class InboundOrderCreationServiceTest { @Test void shouldConvergeOnConcurrentDuplicateInsertWinner() { - InboundOrderEntity winner = inbound(901L, "RK-CUSTOM-77", "UNPRICED", null, null); + InboundOrderEntity winner = inbound(901L, "PCHK2608140001", "UNPRICED", null, null); winner.setTaskId(700L); when(orders.selectOne(any(Wrapper.class))).thenReturn(null, winner); when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L)); doThrow(new org.springframework.dao.DuplicateKeyException("concurrent insert")) .when(orders).insert(any(InboundOrderEntity.class)); - assertThat(service.createFromApprovedOrder(approvedCommand("RK-CUSTOM-77"))).isEqualTo(901L); + assertThat(service.createFromApprovedOrder(approvedCommand("PCHK2608140001"))).isEqualTo(901L); verify(histories, never()).insert(any(InboundStatusHistoryEntity.class)); } @Test void shouldBackfillTaskLinkForInboundCreatedBeforeTaskProvisioning() { - InboundOrderEntity existing = inbound(900L, "RK-CUSTOM-77", "UNPRICED", null, null); + InboundOrderEntity existing = inbound(900L, "PCHK2608140001", "UNPRICED", null, null); when(orders.selectOne(any(Wrapper.class))).thenReturn(existing); when(orders.update(any(), any(Wrapper.class))).thenReturn(1); when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L)); - assertThat(service.createFromApprovedOrder(approvedCommand("RK-CUSTOM-77"))).isEqualTo(900L); + assertThat(service.createFromApprovedOrder(approvedCommand("PCHK2608140001"))).isEqualTo(900L); assertThat(existing.getTaskId()).isEqualTo(700L); verify(orders).update(any(), any(Wrapper.class)); @@ -115,14 +115,14 @@ class InboundOrderCreationServiceTest { @Test void shouldPrepareDraftIdempotentlyAndUpgradeSameInboundOnApproval() { - InboundOrderEntity prepared = inbound(900L, "RK-CUSTOM-77", "UNPRICED", null, null); + InboundOrderEntity prepared = inbound(900L, "PCHK2608140001", "UNPRICED", null, null); prepared.setProcessStatus("PREPARING"); when(orders.selectOne(any(Wrapper.class))).thenReturn(null, prepared, prepared); when(orders.update(any(), any(Wrapper.class))).thenReturn(1); when(fulfillmentTasks.provision(any(), any())).thenReturn(task(700L)); - long preparedId = service.prepareFromDraft(approvedCommand("RK-CUSTOM-77")); - long approvedId = service.createFromApprovedOrder(approvedCommand("RK-CUSTOM-77")); + long preparedId = service.prepareFromDraft(approvedCommand("PCHK2608140001")); + long approvedId = service.createFromApprovedOrder(approvedCommand("PCHK2608140001")); assertThat(preparedId).isNotNull(); assertThat(approvedId).isEqualTo(900L); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java index 7f251c6e7..16f671151 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantPriceBookServiceTest.java @@ -45,6 +45,7 @@ class MerchantPriceBookServiceTest { @Test void shouldResolveNormalAndOverseasInboundCodePrefixesFromChannelCatalog() { when(repository.activeVersionId()).thenReturn(7L); + when(repository.channelAvailable(7L, 10L, "MATSON_CLX_TRUCK")).thenReturn(true); when(repository.channels(7L, 10L)).thenReturn(List.of( channel(7L, "MATSON_CLX_TRUCK", PriceModel.FBA_WAREHOUSE_RATE))); when(repository.channelPrefix("MATSON_CLX_TRUCK")) @@ -59,6 +60,7 @@ class MerchantPriceBookServiceTest { @Test void shouldFallBackToNormalPrefixWhenChannelHasNoOverseasVariant() { when(repository.activeVersionId()).thenReturn(7L); + when(repository.channelAvailable(7L, 10L, "OA_HOUSTON_TRUCK")).thenReturn(true); when(repository.channels(7L, 10L)).thenReturn(List.of( channel(7L, "OA_HOUSTON_TRUCK", PriceModel.FBA_WAREHOUSE_RATE))); when(repository.channelPrefix("OA_HOUSTON_TRUCK")) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiServiceTest.java index bb99f3f61..d35d4a7a9 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MojiaMeasurementOpenApiServiceTest.java @@ -50,7 +50,7 @@ class MojiaMeasurementOpenApiServiceTest { } @Test - void fixedAppKeyJsonIsConvertedToTheExistingCartonMeasurementFlow() throws Exception { + void generatedDailyInboundCartonCodeFlowsThroughMojiaMeasurement() throws Exception { MeasurementRequestAuthenticator authenticator = mock(MeasurementRequestAuthenticator.class); CartonMeasurementService cartons = mock(CartonMeasurementService.class); MeasurementImageClient images = mock(MeasurementImageClient.class); @@ -62,17 +62,17 @@ class MojiaMeasurementOpenApiServiceTest { when(authenticator.authenticate("POST", MojiaMeasurementOpenApiService.CALLBACK_PATH, headers, json(), false)).thenReturn(new AuthenticatedMeasurementApp("mojia", 10001L, 90001L, Map.of())); when(cartons.upsert(eq(10001L), eq(90001L), any())).thenReturn(new CartonMeasurementResult( - 1L, 1, 2L, "RK-001#4-1", "FBA19L5KMY2CU000002", 1, + 1L, 1, 2L, "PCHK2608140001#5-1", "FBA19L5KMY2CU000002", 1, new BigDecimal("60.1"), new BigDecimal("40.2"), new BigDecimal("30.3"), new BigDecimal("12.5"), 3L, "MJ-01", LocalDateTime.now(), true)); UploadResponse result = service.receive(headers, json()); assertThat(result.matched()).isTrue(); - assertThat(result.boxCode()).isEqualTo("RK-001#4-1"); + assertThat(result.boxCode()).isEqualTo("PCHK2608140001#5-1"); ArgumentCaptor command = ArgumentCaptor.forClass(CartonMeasurementCommand.class); verify(cartons).upsert(eq(10001L), eq(90001L), command.capture()); - assertThat(command.getValue().boxCode()).isEqualTo("RK-001#4-1"); + assertThat(command.getValue().boxCode()).isEqualTo("PCHK2608140001#5-1"); assertThat(command.getValue().scannedShipmentId()).isEqualTo("FBA19L5KMY2CU000002"); assertThat(command.getValue().lengthCm()).isEqualByComparingTo("60.1"); assertThat(command.getValue().image().getContentType()).isEqualTo("image/jpeg"); @@ -108,7 +108,7 @@ class MojiaMeasurementOpenApiServiceTest { ArgumentCaptor receipt = ArgumentCaptor.forClass(MojiaMeasurementReceiptEntity.class); verify(receipts).insert(receipt.capture()); - assertThat(receipt.getValue().getBoxCode()).isEqualTo("RK-001#4-1"); + assertThat(receipt.getValue().getBoxCode()).isEqualTo("PCHK2608140001#5-1"); assertThat(receipt.getValue().getScannedShipmentId()).isNull(); assertThat(receipt.getValue().getLengthCm()).isEqualByComparingTo("60.1"); assertThat(receipt.getValue().getImageFileId()).isEqualTo(3L); @@ -117,7 +117,7 @@ class MojiaMeasurementOpenApiServiceTest { private String json() { return """ - {"boxCode":"RK-001#4-1","shipment_id":"FBA19L5KMY2CU000002", + {"boxCode":"PCHK2608140001#5-1","shipment_id":"FBA19L5KMY2CU000002", "length":60.1,"width":40.2,"height":30.3, "weight":12.5,"machineNo":"MJ-01","picture":"data:image/jpeg;base64,/9j/2Q=="} """; diff --git a/order/AGENTS.md b/order/AGENTS.md index 0e414ffb1..d2430c64c 100644 --- a/order/AGENTS.md +++ b/order/AGENTS.md @@ -12,6 +12,9 @@ |---|---| | 平台订单 API | `controller/PlatformFirstLegOrderController.java` | | 订单编排/状态/幂等 | `service/OrderApplicationService.java`、`entity/OrderStatus.java` | +| 商户订单列表与批量草稿 | `service/MerchantInboundOrderListService.java`、`service/MerchantBatchDraftService.java`、`service/MerchantBatchDraftItemService.java` | +| 入库号生成 | `entity/InboundCodeSequenceEntity.java`、`mapper/InboundCodeSequenceMapper.java`、`repository/OrderRepository.java` | +| 下单计价渠道校验 | `service/OrderPricingChannelGuard.java`、`client/FirstlegPricingClient.java` | | 统一订单七状态与操作映射 | `../../docs/统一订单主状态与操作契约.md`;`LOADED` 硬门禁和 `DELIVERED` 可信回传同时核对三期需求确认表与开工门禁 | | 货物内部查询 | `controller/InternalOrderCargoController.java` | | 快照/Outbox | `service/`、`repository/`、`event/` | diff --git a/order/src/main/java/com/baiyue/order/entity/InboundCodeSequenceEntity.java b/order/src/main/java/com/baiyue/order/entity/InboundCodeSequenceEntity.java deleted file mode 100644 index 14f55aa6d..000000000 --- a/order/src/main/java/com/baiyue/order/entity/InboundCodeSequenceEntity.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.baiyue.order.entity; - -import com.baomidou.mybatisplus.annotation.IdType; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; -import lombok.Getter; -import lombok.Setter; - -/** 全平台入仓码票序;自增主键保证并发创建时不会重复。 */ -@Getter -@Setter -@TableName("ord_inbound_code_sequence") -public class InboundCodeSequenceEntity { - @TableId(type = IdType.AUTO) - private Long id; -} diff --git a/order/src/main/java/com/baiyue/order/mapper/InboundCodeSequenceMapper.java b/order/src/main/java/com/baiyue/order/mapper/InboundCodeSequenceMapper.java index de1192f37..87443a9ac 100644 --- a/order/src/main/java/com/baiyue/order/mapper/InboundCodeSequenceMapper.java +++ b/order/src/main/java/com/baiyue/order/mapper/InboundCodeSequenceMapper.java @@ -1,13 +1,25 @@ package com.baiyue.order.mapper; -import com.baiyue.order.entity.InboundCodeSequenceEntity; import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.time.LocalDate; @Mapper public interface InboundCodeSequenceMapper { - @Insert("INSERT INTO ord_inbound_code_sequence (created_at) VALUES (CURRENT_TIMESTAMP(3))") - @Options(useGeneratedKeys = true, keyProperty = "id") - int allocate(InboundCodeSequenceEntity value); + @Insert(""" + INSERT INTO ord_inbound_code_daily_sequence + (business_date, current_value, created_at, updated_at) + VALUES + (#{businessDate}, 1, CURRENT_TIMESTAMP(3), CURRENT_TIMESTAMP(3)) + ON DUPLICATE KEY UPDATE + current_value = current_value + 1, + updated_at = CURRENT_TIMESTAMP(3) + """) + int increment(@Param("businessDate") LocalDate businessDate); + + @Select("SELECT current_value FROM ord_inbound_code_daily_sequence WHERE business_date = #{businessDate}") + Long currentValue(@Param("businessDate") LocalDate businessDate); } diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index 7993a9be1..cc2aa189a 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -13,6 +13,8 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import java.util.List; @@ -35,12 +37,14 @@ public class OrderRepository { @Resource private OrderIdempotencyRecordMapper idempotencyMapper; @Resource private InboundCodeSequenceMapper inboundCodeSequenceMapper; - public long nextInboundCodeSequence() { - InboundCodeSequenceEntity value = new InboundCodeSequenceEntity(); - if (inboundCodeSequenceMapper.allocate(value) != 1 || value.getId() == null) { + @Transactional(propagation = Propagation.MANDATORY) + public long nextInboundCodeSequence(java.time.LocalDate businessDate) { + if (businessDate == null || inboundCodeSequenceMapper.increment(businessDate) < 1) { throw new IllegalStateException("Failed to allocate inbound code sequence"); } - return value.getId(); + Long value = inboundCodeSequenceMapper.currentValue(businessDate); + if (value == null || value < 1) throw new IllegalStateException("Failed to read inbound code sequence"); + return value; } public void insertOrder(OrderEntity order) { orderMapper.insert(order); } diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index f6ace511a..75b7693b4 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -163,7 +163,8 @@ public class OrderApplicationService { public PageResult page(Long tenantId, OrderRequests.QueryRequest query) { return repository.page(tenantId, query); } String nextInboundCode(String prefix) { - return formatInboundCode(prefix, LocalDate.now(INBOUND_CODE_ZONE), repository.nextInboundCodeSequence()); + LocalDate businessDate = LocalDate.now(INBOUND_CODE_ZONE); + return formatInboundCode(prefix, businessDate, repository.nextInboundCodeSequence(businessDate)); } static String formatInboundCode(String prefix, LocalDate date, long sequence) { diff --git a/order/src/main/resources/db/order.sql b/order/src/main/resources/db/order.sql index a3e568914..0bb0438f9 100644 --- a/order/src/main/resources/db/order.sql +++ b/order/src/main/resources/db/order.sql @@ -9,7 +9,7 @@ CREATE TABLE IF NOT EXISTS ord_order ( id BIGINT UNSIGNED NOT NULL COMMENT '订单ID(雪花ID)', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', order_no VARCHAR(32) NOT NULL COMMENT '客户范围内唯一订单号', - inbound_code VARCHAR(40) NOT NULL COMMENT '订单创建时生成并固定的入仓码:物流前缀+YYMMDD+全局票序', + inbound_code VARCHAR(40) NOT NULL COMMENT '订单创建时生成并固定的入仓码:物流前缀+YYMMDD+当天全平台票序', customer_id BIGINT UNSIGNED NULL COMMENT '客户逻辑ID', customer_name VARCHAR(128) NOT NULL COMMENT '下单时客户名称快照', status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订单业务状态:DRAFT草稿、SUBMITTED已提交、APPROVED审核通过、ACCEPTED已接单、REJECTED审核驳回、PROCESSING履约中、COMPLETED已完成、CANCELLED已取消', @@ -60,11 +60,13 @@ CREATE TABLE IF NOT EXISTS ord_order ( KEY idx_order_tenant_warehouse (tenant_id, destination_warehouse_id) ) ENGINE=InnoDB COMMENT='头程订单主表:保存商户委托、目的仓、运输方式和订单当前状态'; -CREATE TABLE IF NOT EXISTS ord_inbound_code_sequence ( - id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '全平台入仓码票序', - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '票序分配时间', - PRIMARY KEY (id) -) ENGINE=InnoDB COMMENT='入仓码全局票序;不同客户和物流渠道共用且不按日期清零'; +CREATE TABLE IF NOT EXISTS ord_inbound_code_daily_sequence ( + business_date DATE NOT NULL COMMENT '上海时区业务日期', + current_value BIGINT UNSIGNED NOT NULL COMMENT '当天全平台已分配票数', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '当天首次分配时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最近分配时间', + PRIMARY KEY (business_date) +) ENGINE=InnoDB COMMENT='入仓码每日票序;不同客户和物流渠道共用,按上海日期重新从1开始'; CREATE TABLE IF NOT EXISTS ord_order_fulfillment_split ( id BIGINT UNSIGNED NOT NULL COMMENT '拆分台账ID', diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index c5081cec3..d07b022f4 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -11,7 +11,7 @@ paths: summary: 平台统一订单列表 description: >- 统一返回七态产品主状态、下一步动作及测量/预装/装柜/派送摘要,需要 platform:order:list。 - 订单创建时按物流渠道生成固定 inboundCode,格式为物流前缀+YYMMDD+全局票序;有入库单时同时返回 + 订单创建时按物流渠道生成固定 inboundCode,格式为物流前缀+YYMMDD+当天全平台票序;每日按上海日期从0001重新开始;有入库单时同时返回 inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion 才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。 tenantId 省略时跨全部客户;mainStatus 省略时按订单创建时间分页,指定 PENDING_REVIEW 时由 Order 查询, @@ -620,7 +620,7 @@ components: success: {type: boolean} orderId: {type: string, pattern: '^[1-9][0-9]*$'} orderNo: {type: string} - inboundCode: {type: string, pattern: '^[A-Z]{4,6}[0-9]{10,}$', example: ZBYHK2608140897} + inboundCode: {type: string, pattern: '^[A-Z]{4,6}[0-9]{10,}$', example: ZBYHK2608140001} errorCode: {type: string} errorMessage: {type: string} FirstLegOrderCargo: diff --git a/order/src/test/java/com/baiyue/order/repository/InboundCodeSequenceRepositoryTest.java b/order/src/test/java/com/baiyue/order/repository/InboundCodeSequenceRepositoryTest.java new file mode 100644 index 000000000..e4f722ba5 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/repository/InboundCodeSequenceRepositoryTest.java @@ -0,0 +1,35 @@ +package com.baiyue.order.repository; + +import com.baiyue.order.mapper.InboundCodeSequenceMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class InboundCodeSequenceRepositoryTest { + @Mock private InboundCodeSequenceMapper mapper; + + @Test + void incrementsAndReadsTheCounterForTheSameShanghaiBusinessDate() { + LocalDate businessDate = LocalDate.of(2026, 8, 14); + OrderRepository repository = new OrderRepository(); + ReflectionTestUtils.setField(repository, "inboundCodeSequenceMapper", mapper); + when(mapper.increment(businessDate)).thenReturn(2); + when(mapper.currentValue(businessDate)).thenReturn(2L); + + assertThat(repository.nextInboundCodeSequence(businessDate)).isEqualTo(2L); + + InOrder ordered = inOrder(mapper); + ordered.verify(mapper).increment(businessDate); + ordered.verify(mapper).currentValue(businessDate); + } +} diff --git a/order/src/test/java/com/baiyue/order/repository/InboundCodeSequenceSqlContractTest.java b/order/src/test/java/com/baiyue/order/repository/InboundCodeSequenceSqlContractTest.java new file mode 100644 index 000000000..2daa67983 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/repository/InboundCodeSequenceSqlContractTest.java @@ -0,0 +1,59 @@ +package com.baiyue.order.repository; + +import com.baiyue.order.mapper.InboundCodeSequenceMapper; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; + +class InboundCodeSequenceSqlContractTest { + @Test + void mapperAtomicallyIncrementsOneCounterPerBusinessDate() throws Exception { + Method increment = InboundCodeSequenceMapper.class.getMethod("increment", LocalDate.class); + String incrementSql = normalize(String.join(" ", increment.getAnnotation(Insert.class).value())); + Method current = InboundCodeSequenceMapper.class.getMethod("currentValue", LocalDate.class); + String currentSql = normalize(String.join(" ", current.getAnnotation(Select.class).value())); + + assertThat(incrementSql) + .contains("business_date, current_value") + .contains("values (#{businessdate}, 1") + .contains("on duplicate key update") + .contains("current_value = current_value + 1"); + assertThat(currentSql) + .contains("where business_date = #{businessdate}") + .doesNotContain("channel_code") + .doesNotContain("tenant_id"); + } + + @Test + void baselineAndUnifiedSqlDefineTheShanghaiDailyCounter() throws Exception { + String baseline = new ClassPathResource("db/order.sql").getContentAsString(StandardCharsets.UTF_8); + String unified = Files.readString(resolveUnified(), StandardCharsets.UTF_8); + + for (String sql : java.util.List.of(baseline, unified)) { + assertThat(sql) + .contains("CREATE TABLE IF NOT EXISTS ord_inbound_code_daily_sequence") + .contains("business_date DATE NOT NULL") + .contains("current_value BIGINT UNSIGNED NOT NULL") + .contains("PRIMARY KEY (business_date)") + .contains("按上海日期重新从1开始"); + } + } + + private static String normalize(String sql) { + return sql.replaceAll("\\s+", " ").trim().toLowerCase(); + } + + private static Path resolveUnified() { + Path direct = Path.of("deploy/sql/头程数据库统一增量-MySQL8.sql"); + return Files.exists(direct) ? direct : Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"); + } +} diff --git a/order/src/test/java/com/baiyue/order/repository/MerchantInboundOrderQueryRepositoryTest.java b/order/src/test/java/com/baiyue/order/repository/MerchantInboundOrderQueryRepositoryTest.java index c58744782..e82c22c66 100644 --- a/order/src/test/java/com/baiyue/order/repository/MerchantInboundOrderQueryRepositoryTest.java +++ b/order/src/test/java/com/baiyue/order/repository/MerchantInboundOrderQueryRepositoryTest.java @@ -3,6 +3,7 @@ package com.baiyue.order.repository; import com.baiyue.order.dto.MerchantInboundOrderQuery; import com.baiyue.order.entity.OrderEntity; import com.baiyue.order.entity.OrderStatus; +import com.baiyue.order.mapper.InboundCodeSequenceMapper; import com.baiyue.order.mapper.OrderCargoMapper; import com.baiyue.order.mapper.OrderChildPurgeMapper; import com.baiyue.order.mapper.OrderIdempotencyRecordMapper; @@ -38,6 +39,7 @@ class MerchantInboundOrderQueryRepositoryTest { @MockBean private OutboxEventMapper outboxMapper; @MockBean private OrderChildPurgeMapper purgeMapper; @MockBean private OrderIdempotencyRecordMapper idempotencyMapper; + @MockBean private InboundCodeSequenceMapper inboundCodeSequenceMapper; @Resource private OrderRepository repository; @Test @SuppressWarnings({"rawtypes", "unchecked"}) diff --git a/order/src/test/java/com/baiyue/order/repository/PlatformOrderPageRepositoryTest.java b/order/src/test/java/com/baiyue/order/repository/PlatformOrderPageRepositoryTest.java index 3fac52237..8aea2d775 100644 --- a/order/src/test/java/com/baiyue/order/repository/PlatformOrderPageRepositoryTest.java +++ b/order/src/test/java/com/baiyue/order/repository/PlatformOrderPageRepositoryTest.java @@ -2,6 +2,7 @@ package com.baiyue.order.repository; import com.baiyue.order.dto.OrderRequests; import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.mapper.InboundCodeSequenceMapper; import com.baiyue.order.mapper.OrderCargoMapper; import com.baiyue.order.mapper.OrderChildPurgeMapper; import com.baiyue.order.mapper.OrderIdempotencyRecordMapper; @@ -34,6 +35,7 @@ class PlatformOrderPageRepositoryTest { @MockBean private OutboxEventMapper outboxMapper; @MockBean private OrderChildPurgeMapper purgeMapper; @MockBean private OrderIdempotencyRecordMapper idempotencyMapper; + @MockBean private InboundCodeSequenceMapper inboundCodeSequenceMapper; @Resource private OrderRepository repository; private final OrderRequests.QueryRequest query = new OrderRequests.QueryRequest( null, null, null, null, null, null, 1, 20); diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index 92b00fb77..505d38937 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -58,7 +58,7 @@ class OrderApplicationServiceStateTest { assertThat(orderId).isNotNull(); verify(eligibility, never()).requireApproved(anyLong()); verify(repository).insertOrder(argThat(order -> order.getStatus() == OrderStatus.DRAFT - && order.getInboundCode().matches("PCHWK\\d{6}0897"))); + && order.getInboundCode().matches("PCHWK\\d{6}0001"))); } @Test @@ -247,7 +247,7 @@ class OrderApplicationServiceStateTest { .transit(20002L, 2L, "u", 1L, OrderStatus.SUBMITTED, null); assertThat(order.getStatus()).isEqualTo(OrderStatus.SUBMITTED); - assertThat(order.getInboundCode()).matches("PCHWK\\d{6}0897"); + assertThat(order.getInboundCode()).matches("PCHWK\\d{6}0001"); assertThat(order.getSubmittedAt()).isNotNull(); verify(repository).saveOrder(20002L, order); } @@ -490,11 +490,15 @@ class OrderApplicationServiceStateTest { } @Test - void inboundCodeUsesChannelPrefixShanghaiDateAndGlobalTicketSequence() { - assertThat(OrderApplicationService.formatInboundCode("ZBYHK", LocalDate.of(2026, 8, 14), 897L)) - .isEqualTo("ZBYHK2608140897"); - assertThat(OrderApplicationService.formatInboundCode("HDHK", LocalDate.of(2026, 8, 13), 10000L)) - .isEqualTo("HDHK26081310000"); + void inboundCodeUsesChannelPrefixShanghaiDateAndDailyTicketSequence() { + assertThat(OrderApplicationService.formatInboundCode("PCHK", LocalDate.of(2026, 8, 14), 1L)) + .isEqualTo("PCHK2608140001"); + assertThat(OrderApplicationService.formatInboundCode("PCHK", LocalDate.of(2026, 8, 14), 2L)) + .isEqualTo("PCHK2608140002"); + assertThat(OrderApplicationService.formatInboundCode("PCHK", LocalDate.of(2026, 8, 15), 1L)) + .isEqualTo("PCHK2608150001"); + assertThat(OrderApplicationService.formatInboundCode("HDHK", LocalDate.of(2026, 8, 14), 10000L)) + .isEqualTo("HDHK26081410000"); } private OrderApplicationService service(OrderRepository repository, OrderEligibilityGuard eligibility, @@ -505,7 +509,7 @@ class OrderApplicationServiceStateTest { ReflectionTestUtils.setField(service, "masterDataGuard", masterData); OrderPricingChannelGuard pricing = mock(OrderPricingChannelGuard.class); when(pricing.requireAvailable(anyLong(), anyLong(), anyString(), anyString())).thenReturn("PCHWK"); - when(repository.nextInboundCodeSequence()).thenReturn(897L); + when(repository.nextInboundCodeSequence(any(LocalDate.class))).thenReturn(1L); ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing); ReflectionTestUtils.setField(service, "productImageGuard", mock(ProductImageGuard.class)); ReflectionTestUtils.setField(service, "json", new ObjectMapper()); diff --git a/platform-bom/AGENTS.md b/platform-bom/AGENTS.md index 052a3daa0..76ff7aa0a 100644 --- a/platform-bom/AGENTS.md +++ b/platform-bom/AGENTS.md @@ -16,5 +16,5 @@ ## 修改与验证 - 新增条目前先确认制品确实需要被其他仓库按固定版本消费;业务服务依赖不要放入 BOM。 -- 版本变化会影响所有 BOM 消费者,并可能需要尾程仓同步升级;保持已发布坐标兼容。 +- 版本变化会影响所有 BOM 消费者;当前开发阶段直接同步本仓与尾程仓消费者,不保留旧坐标或旧版本兼容。 - 从父目录运行 `mvn -pl platform-bom -am validate`。 diff --git a/scripts/README.md b/scripts/README.md index 21fec795b..003fe3b7b 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -6,7 +6,7 @@ |---|---|---|---| | 平台后台全模块 HTTP 验收 | `scripts/admin-test-env-acceptance.mjs` | 会创建、更新或最终停用测试数据 | `--modules` 默认列表中的平台客户、仓储、订单、入库、分柜、装柜、船期、拖车、报关、财务、通知、统计等模块 | | 客户工作台完整 HTTP 链路验收 | `scripts/client-test-env-acceptance.mjs` | 会创建测试企业、订单、入库、权限和站内消息数据 | 注册/审核、客户隔离、订单、入库计费、文件、财务、客户侧权限,以及我的消息未读/已读/全部已读链路 | -| Client 二期兼容 HTTP 验收 | `scripts/client-phase2-test-env-acceptance.mjs` | 会写入测试环境业务数据 | 二期客户入库、测量、费用和权限兼容场景;不是替代 Client 全量入口 | +| Client 二期专项 HTTP 验收 | `scripts/client-phase2-test-env-acceptance.mjs` | 会写入测试环境业务数据 | 二期客户入库、测量、费用和权限专项场景;不是替代 Client 全量入口 | 执行前必须确认目标环境、账号和数据清理/停用策略,并获得当前对话的测试环境验收授权。`admin-test-env-modules/*contract.test.mjs` 只验证模块脚本结构,不能代替 HTTP 验收。 @@ -23,6 +23,14 @@ | PDA 本地页面和状态测试 | `../apps/pda-uniapp/tests/*.test.mjs` | | 本地数据库 SQL 静态/受控验收 | `scripts/local-db-full-acceptance.ps1` | +Admin/Client HTTP 脚本的离线合同只验证脚本结构、参数和模块边界,不访问测试环境: + +| 合同范围 | 入口 | +|---|---| +| Admin 订单、箱码、三期装柜/PDA、订舱、拖车、通知 | `scripts/admin-test-env-modules/*contract.test.mjs`;模块映射见 `scripts/admin-test-env-modules/README.md` | +| Client 消息中心调用 | `scripts/client-notifications-contract.test.mjs` | +| 验收入口与模块注册 | `scripts/full-regression-entry-contract.test.mjs`、`scripts/controller-pipeline-contract.test.mjs` | + ## 后端全量回归 ## 入口 diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 7459def79..8bc936577 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -45,7 +45,8 @@ export async function run(context) { | 商户资质与下单身份 | `merchant-qualification-fixture.mjs` | `merchant-qualification-fixture-contract.test.mjs` | | 订单创建、可计价主数据与审核异步收敛 | `orders.mjs` | `orders-pricing-fixture-contract.test.mjs`、`orders-approval-convergence-contract.test.mjs` | | 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` | -| 三期柜次、装柜、PDA 与证据/出仓门禁 | `phase3-container-loading.mjs`、`phase3-loading.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-outbound-dispatch.mjs` | `phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` | +| 三期柜次概览、装柜、PDA 与证据/出仓门禁 | `phase3-container-loading.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-outbound-dispatch.mjs` | `phase3-container-regression-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` | +| 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` | | 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs` | `ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` | | 拖车预约 | `truck-appointments.mjs` | `truck-appointments-contract.test.mjs` | | 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` | diff --git a/scripts/admin-test-env-modules/phase3-container-overview.mjs b/scripts/admin-test-env-modules/phase3-container-overview.mjs index de1ca2d1b..1deeaebff 100644 --- a/scripts/admin-test-env-modules/phase3-container-overview.mjs +++ b/scripts/admin-test-env-modules/phase3-container-overview.mjs @@ -6,9 +6,7 @@ function id(value) { return value == null ? '' : String(value) } export function controlledPhotoDownload(downloadPath, response) { const controlledPath = typeof downloadPath === 'string' - && downloadPath.startsWith('/api/') - && !downloadPath.startsWith('//') - && !downloadPath.includes('://') + && /^\/api\/platform\/v1\/containers\/[1-9]\d*\/pda\/photos\/[1-9]\d*\/download$/.test(downloadPath) const contentType = String(response?.contentType || '').toLowerCase() return controlledPath && response?.http === 200 diff --git a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs index a8159c9f3..802b1fec9 100644 --- a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs @@ -17,6 +17,7 @@ const truckAttachmentDialog = read(`${adminRoot}trucking/components/TruckAttachm const truckList = read(`${adminRoot}trucking/pages/TruckAppointmentsView.vue`) const truckForm = read(`${adminRoot}trucking/components/TruckAppointmentForm.vue`) const containerList = read(`${adminRoot}container-loading/pages/ContainersView.vue`) +const containerOverviewPanel = read(`${adminRoot}container-loading/components/ContainerOverviewPanel.vue`) const containerLoadingAcceptance = read('phase3-container-loading.mjs') const truckAcceptance = read('truck-appointments.mjs') const matrix = read('../../../docs/三期/货柜管理全链验收矩阵-2026-08-13.md') @@ -41,23 +42,27 @@ test('overview regression requires workflowStatus and distinguishes 100 percent test('every overview photo uses its server-provided controlled downloadPath', () => { assert.match(overviewOpenApi, /\/api\/platform\/v1\/containers\/\{id\}\/pda\/photos\/\{fileId\}\/download/) assert.match(overviewAcceptance, /context\.requestBinary\('GET', downloadPath\)/) - assert.equal(controlledPhotoDownload('/api/platform/v1/files/1', { + assert.equal(controlledPhotoDownload('/api/platform/v1/containers/7/pda/photos/23/download', { http: 200, bytes: new Uint8Array([1]), contentType: 'image/png', }), true) for (const [path, response] of [ ['https://example.test/file', { http: 200, bytes: new Uint8Array([1]), contentType: 'image/png' }], - ['/api/platform/v1/files/1', { http: 200, bytes: new Uint8Array([1]), contentType: 'application/json' }], - ['/api/platform/v1/files/1', { http: 404, bytes: new Uint8Array(), contentType: 'image/png' }], + ['/api/platform/v1/files/loading-evidence/21/22/23', { http: 200, bytes: new Uint8Array([1]), contentType: 'image/png' }], + ['/api/platform/v1/containers/7/pda/photos/23/download', { http: 200, bytes: new Uint8Array([1]), contentType: 'application/json' }], + ['/api/platform/v1/containers/7/pda/photos/23/download', { http: 404, bytes: new Uint8Array(), contentType: 'image/png' }], ]) assert.equal(controlledPhotoDownload(path, response), false) }) -test('truck attachment API is frozen while Admin add-reference entry remains blocked', () => { +test('truck attachment API and Admin upload-reference-delete flow are frozen', () => { assert.match(truckApi, /referenceTruckAppointmentAttachment/) + assert.match(truckApi, /uploadTruckAppointmentAttachment/) assert.match(truckApi, /deleteTruckAppointmentAttachment/) - assert.doesNotMatch(truckAttachmentDialog, /referenceTruckAppointmentAttachment/) + assert.match(truckAttachmentDialog, /uploadTruckAppointmentAttachment/) + assert.match(truckAttachmentDialog, /上传并关联/) + assert.match(truckAttachmentDialog, /confirmButtonText: '确认', cancelButtonText: '取消'/) assert.match(truckAcceptance, /删除后回查拖车预约附件列表/) assert.match(truckAcceptance, /!remainingAttachments\.some/) - assert.match(matrix, /拖车附件新增入口缺失[\s\S]*BLOCKED_CONTRACT/) + assert.match(matrix, /拖车附件闭环[\s\S]*`LANDED`/) }) test('existing container number replacement keeps container plan and PDA identities stable', () => { @@ -78,6 +83,10 @@ test('customer public display uses tenantName without a legalName fallback', () assert.match(matrix, /客户公开名称一致性[\s\S]*已统一 `tenantName`[\s\S]*`LANDED`/) }) -test('Chinese status dialogs and 390px layout remain explicit Admin contract blockers', () => { - assert.match(matrix, /中文状态、弹框与 390px 布局[\s\S]*BLOCKED_CONTRACT/) +test('Chinese status dialogs and 390px layout are landed', () => { + assert.match(releaseView, /确认放舱/) + assert.match(containerOverviewPanel, /AWAITING_REVIEW: '待人工复核'/) + assert.match(containerOverviewPanel, /PHOTO_STEP_EMPTY_CONTAINER: '空柜照片'/) + assert.match(matrix, /中文状态、弹框与 390px 布局[\s\S]*`LANDED`/) + assert.doesNotMatch(matrix, /拖车附件新增入口缺失|中文状态、弹框与 390px 布局[^\n]*BLOCKED_CONTRACT/) })