优化
This commit is contained in:
@@ -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 或权限契约的改动。
|
||||
- 数据库只访问所属服务库。模块迁移位于 `<module>/src/main/resources/db/`;测试/存量环境唯一部署入口是 `deploy/sql/头程数据库统一增量-MySQL8.sql`,说明见 `deploy/sql/README.md`,历史日期脚本仅追溯。
|
||||
- 数据库只访问所属服务库。模块表结构位于 `<module>/src/main/resources/db/`;当前开发阶段直接更新最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql`,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作现有结构来源和开发追溯,部署说明见 `deploy/sql/README.md`。
|
||||
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
|
||||
- Java 签名变更的最低编译门禁是 `mvn -pl <module> -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl <module> -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
|
||||
|
||||
## 全量 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 <name>`,模块名称和静态合同先查 `scripts/admin-test-env-modules/README.md`。
|
||||
- 这些脚本会创建、更新或最终停用测试数据,必须在当前对话明确授权测试环境验收后执行,并保存 JSON 报告;未授权不得请求 Gateway。
|
||||
- 后端 Maven、前端 `pnpm test/typecheck`、PDA `node --test` 和数据库 Dry-run 都是本地质量检查,不属于全量 HTTP 验收。
|
||||
|
||||
+3
-2
@@ -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。
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
## 修改与验证
|
||||
|
||||
- 公共响应、错误码或基础实体变化会影响所有服务;先检索消费者并保持序列化和数据库兼容。
|
||||
- 公共响应、错误码或基础实体变化会影响所有服务;先检索并同步修改全部消费者、测试和契约,当前开发阶段不保留旧序列化或数据库兼容分支。
|
||||
- `ApiResponse.successful()` 只认字符串 `"200"`。不得恢复旧成功码 `"0"`,也不得把 `code` 改成数字;前端和 Feign 调用方都按字符串严格判断。
|
||||
- `ResultCode.httpStatus` 保留错误分类元数据,但普通业务异常的实际 HTTP 状态由 `common-web/GlobalExceptionHandler` 统一收敛为 200;真实 HTTP 状态例外见 `../docs/统一响应约定改造方案-HTTP200.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`。
|
||||
|
||||
@@ -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 序列化保持一致。
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
+8
-7
@@ -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 在线更新安排开发与验收,不另设设备型号门禁。
|
||||
|
||||
@@ -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'}
|
||||
|
||||
+17
-17
@@ -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("洛杉矶普船海卡");
|
||||
|
||||
+10
-10
@@ -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<InboundOrderEntity> 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);
|
||||
|
||||
@@ -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"))
|
||||
|
||||
+6
-6
@@ -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<CartonMeasurementCommand> 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<MojiaMeasurementReceiptEntity> 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=="}
|
||||
""";
|
||||
|
||||
@@ -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/` |
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -163,7 +163,8 @@ public class OrderApplicationService {
|
||||
public PageResult<OrderEntity> 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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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:
|
||||
|
||||
+35
@@ -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);
|
||||
}
|
||||
}
|
||||
+59
@@ -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");
|
||||
}
|
||||
}
|
||||
+2
@@ -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"})
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
## 修改与验证
|
||||
|
||||
- 新增条目前先确认制品确实需要被其他仓库按固定版本消费;业务服务依赖不要放入 BOM。
|
||||
- 版本变化会影响所有 BOM 消费者,并可能需要尾程仓同步升级;保持已发布坐标兼容。
|
||||
- 版本变化会影响所有 BOM 消费者;当前开发阶段直接同步本仓与尾程仓消费者,不保留旧坐标或旧版本兼容。
|
||||
- 从父目录运行 `mvn -pl platform-bom -am validate`。
|
||||
|
||||
+9
-1
@@ -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` |
|
||||
|
||||
## 后端全量回归
|
||||
|
||||
## 入口
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user