diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3fd5c31fe..4742c078c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -89,7 +89,6 @@ OpenAPI有效性: expire_in: 7 days paths: - gateway/target/*.jar - - gateway-overseas/target/*.jar - auth/target/*.jar - iam/target/*.jar - order/target/*.jar @@ -102,4 +101,3 @@ OpenAPI有效性: - print/target/*.jar - audit-service/target/*.jar - integration-service/target/*.jar - - event-bridge-service/target/*.jar diff --git a/AGENTS.md b/AGENTS.md index 57fb7d324..28b316cf6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,17 +1,21 @@ # 头程与海外仓后端项目指引 +> 当前为单 TEST、统一 Gateway/Auth 会话/Nacos 与业务 Outbox 直接投递。架构规则及部署/验收状态唯一见 `../docs/测试环境单机部署与配置契约.md`、`../docs/统一认证与服务通信契约.md`。模块旧双区域说明不能覆盖这两份契约;构建、部署与整体验收分别核对。 + +根 POM 当前聚合 20 个模块(7 个公共/依赖模块、13 个服务),部署映射见 `deploy/service-map.yml`。`gateway-overseas/` 与 `event-bridge-service/` 为退役待清理源码,不是当前构建或部署单元。 + ## 适用范围与定位 - 本文件适用于 `baiyue-firstleg/` Maven 多模块仓库,包含国内头程与海外区域服务;进入具体服务后继续读取该服务的 `AGENTS.md`。 - 本层只负责:服务边界、跨服务契约、数据库部署总入口、阶段路由和通用验证。Controller、Service、DTO、SQL 细节以模块级指引为准。 - 普通任务先读本文件和目标模块指引;聚合构建/架构问题再读 `README.md`、`pom.xml`、`docs/头程项目目录结构与服务说明.md`。 -- 平台/客户层级与权限公共语义见 `../docs/平台客户层级与权限契约.md`;国内/海外 Token、双区域 API 和跨区事件公共契约见 `../docs/跨区域认证与服务通信契约.md`。本文件只保留后端执行所需摘要。 +- 平台/客户层级与权限公共语义见 `../docs/平台客户层级与权限契约.md`;统一 Token、API 和可靠直接投递公共契约见 `../docs/统一认证与服务通信契约.md`。本文件只保留后端执行所需摘要。 ## 不可突破的产品与数据边界 - 当前产品只有“平台—客户”两级。`tenant_id=0` 是平台技术作用域;`tenant_id>0` 是客户隔离键,不得新增租户切换、租户选择或租户级 RBAC。 - 公共仓库、往来单位、快递、托盘、库位统一归属 `tenant_id=0`;订单、文件、费用等客户数据按客户 `tenant_id>0` 隔离。不得跨库 Join 或信任客户端客户 ID。 -- 国内订单交易状态归 `order`,头程履约/入库状态归 `firstleg`,财务账期、账单、收款、成本与核销归 `finance`;全部仓库 ID、类型、状态、地址和能力主档归国内 `warehouse`,海外 `warehouse-overseas-service` 只维护引用同一国内 warehouseId 的库位、托盘和资源锁。海外尾程订单、入仓计划、库存业务与海外本土业务归 `lastleg-service`,海外公网入口归 `gateway-overseas`。文件对象归 `file`,第三方适配归 `integration-service`;跨服务通过内部客户端、DTO、事件和服务令牌协作。 +- 国内订单交易状态归 `order`,头程履约/入库状态归 `firstleg`,财务账期、账单、收款、成本与核销归 `finance`;全部仓库 ID、类型、状态、地址和能力主档归国内 `warehouse`,海外 `warehouse-overseas-service` 只维护引用同一国内 warehouseId 的库位、托盘和资源锁。海外尾程订单、入仓计划、库存业务与海外本土业务归 `lastleg-service`,所有端公网入口归 `gateway`。文件对象归 `file`,第三方适配归 `integration-service`;跨服务通过内部客户端、DTO、事件和服务令牌协作。 - 普通业务成功/失败使用 HTTP 200 + body `code`(成功为字符串 `"200"`);认证、基础设施和网关拒绝可使用真实 401/403/503。 ## 服务路由(按关键词定位) @@ -23,7 +27,7 @@ - 单服务 Controller/Service/Mapper 任务:本文件、目标服务 `AGENTS.md`、目标类及其直接调用点。 - 跨服务 API/事件任务:在上述基础上增加直接调用方、被调用方和对应 Gateway/OpenAPI;不因业务关键词自动加载整个业务专题。 - 只有状态、权限、数据库或验收边界无法从上述入口确定时,才读取 `docs/业务快速索引.md`、专题 README 或完整契约。 -- 后端指引累计超过约 250 行时优先用 `rg` 读取命中段;聚合构建或架构任务才全文读取 `README.md`、`pom.xml` 和目录结构文档。 +- 读取预算遵循工作区根指引:超过约 250 行或 8000 字符时先读职责、硬边界和验证章节,再检索任务命中段;不得仅因行数少而全文加载长业务表。聚合构建或架构任务才读取相关 `README.md`、`pom.xml` 和目录结构文档,已知单模块任务不增加概览文档。 | 任务关键词 | 首选服务 | |---|---| @@ -49,9 +53,9 @@ | 订舱资料/打印、船期、订舱级拖车、报关 | `firstleg/` | | 目的港码头拖车与还空预警 | `firstleg/` | | 客户账期、费用池、账单、入仓码联查、收款、成本、核销 | `finance/` | -| 客户资金账户、充值、授信、冻结、结算与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/`、`event-bridge-service/` | +| 客户资金账户、充值、授信、冻结、结算与跨区资金指令 | `finance/`、`lastleg-service/`、`common-event-contract/` | | 柜级公共费用跨客户分摊 | `firstleg/`、`finance/` | -| 海外业务费用来源跨区入池 | `lastleg-service/`、`common-event-contract/`、`event-bridge-service/`、`finance/` | +| 海外业务费用来源跨区入池 | `lastleg-service/`、`common-event-contract/`、`finance/` | | 入库/柜次应付、直接已付、付款流水与凭证 | `firstleg/`、`finance/`、`file/` | | 仓库、往来单位、快递、托盘、库位 | `warehouse/` | | 供应商、国内整柜库存与出入库 | `warehouse/` | @@ -60,8 +64,8 @@ | 资质、导出、导出退避重试、测量图片、文件下载 | `file/` | | 路由、CORS、JWT 入口、访问域 | `gateway/` | | 百度/Amazon/OCR/第三方适配 | `integration-service/` | -| 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | -| 海外 IAM 状态与权限版本投影 | `lastleg-service/security/` | +| 统一 API 路由、Token、Audience/JWKS | `gateway/`、`auth/`、`common-security/` | +| 统一会话与账号/客户权限撤销 | `auth/`、`iam/`、`common-security/` | | 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | | 尾程订单控制、柜级预览与入仓计划转换 | `lastleg-service/`、`warehouse-overseas-service/` | | 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | @@ -74,7 +78,7 @@ | 国内船节点向海外接柜门禁事件 | `firstleg/`、`common-event-contract/`、`lastleg-service/` | | 海外仓库目录、海外库位、海外托盘、资源锁 | 国内仓库目录归 `warehouse/`,海外资源归 `warehouse-overseas-service/` | | Lastleg 内部模块化与依赖基线 | `lastleg-service/` | -| PDF 标签、审计、跨区双向事件 | `print/`、`audit-service/`、`event-bridge-service/` | +| PDF 标签、审计、业务事件直投 | `print/`、`audit-service/`、`common-event-contract/` | ## 公共模块快速索引 @@ -95,10 +99,9 @@ ## 国内头程与海外仓协同 -- 国内头程 Auth 是唯一用户账号和 Token 签发方;Admin/Client 共用一套前端和用户体系,海外仓不得另建登录或权限模型。 -- 统一使用 RS256/JWKS。国内登录/刷新时由 Auth 同时签发国内 Token 和短时海外 Token;前端统一认证层按国内/海外 API Base 自动选择。国内服务叠加 Redis 会话校验;海外 Token 只携带稳定身份、客户/仓库范围和版本声明,不携带角色或权限集。海外 Gateway 本地验签后必须通过 Lastleg 安全投影取得当前角色/权限并失败关闭,再清除外部伪造头、写入受内部服务令牌保护的可信身份上下文;海外服务不得访问国内 Redis,海外业务请求不得经国内 Gateway 中转。 -- 头程装柜完成向海外建尾程单、海外 PDA 卸柜完成向国内回写货柜报空事实,以及后续费用来源和财务回传,统一走 MQ Outbox/Inbox 事件链;事件不携带用户 JWT,服务身份由 MQ TLS/ACL 或内部服务令牌负责。跨区事件契约、幂等和死信分别查 `common-event-contract/`、`event-bridge-service/` 与目标消费者。 -- 海外浏览器请求只进入 `gateway-overseas/`,由其路由到 `lastleg-service/`、`warehouse-overseas-service/` 及海外部署的公共服务;不得经国内 `gateway/` 中转。`lastleg-service` 不读取头程数据库,`warehouse-overseas-service` 不读取尾程订单库。 +- Auth 是唯一用户账号与 Token 签发方;目标为各端共用 Gateway 和 Redis 会话,保留 RS256/JWKS、客户隔离、权限撤销、PDA 仓绑定及服务身份校验。当前源码已移除旧海外会话验证与 Lastleg 安全投影,改为 Auth 权威会话校验;本地验证不等于测试环境部署验收。 +- 装柜、船节点、卸柜、暂扣、派送、费用与资金按统一通信契约的逐事件表直接投递。保留本地 Outbox、消费幂等、重试与失败处置,不携带用户 JWT;旧 Bridge 源码已退出聚合和发布清单,退役待清理;实际停机仍须替代链验证与授权,不得据源码状态认定已部署。 +- `lastleg-service` 不读取头程数据库,`warehouse-overseas-service` 不读取尾程订单库。共用测试 MySQL 实例不改变服务数据归属。准确路由、ACK、撤销矩阵和切换门槛只维护在新的两份主契约中。 ## 契约、数据库与验证入口 @@ -112,7 +115,7 @@ - 权限语义变更必须同步 Controller `@PreAuthorize`、OpenAPI、模块权限 SQL、IAM 菜单资源、统一增量脚本和契约测试。并行模块任务不得覆盖菜单与权限总控已写入共享 SQL 或权限契约的改动。 - 数据库只访问所属服务库。模块表结构位于 `/src/main/resources/db/`;开发环境按最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql` 收敛,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作结构来源和追溯,部署说明见 `deploy/sql/README.md`;生产升级另按明确发布规则处理。 - 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。 -- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。 +- 通用配置在 `config/nacos/test/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。 - Java 签名变更的最低编译门禁是 `mvn -pl -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。 - 同一批 `AGENTS.md` 索引修改完成后从后端根统一运行一次 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,不访问 HTTP、数据库或部署环境。 - Mapper 注册静态合同为 `scripts/mapper-scan-contract.mjs`、`mapper-scan-contract.test.mjs`:自动发现聚合模块、生产 `*Mapper` 接口、服务启动类和分散扫描配置,不启动 Spring、不连接数据库;遗漏注解、扫描根漂移或重新引入子域扫描时直接失败并输出类与文件路径。 @@ -138,7 +141,7 @@ - 订单与客户:`../docs/订单与客户/README.md`、`../docs/订单与客户/订单与客户头程订单闭环设计.md` - 入库测量与计费:`../docs/入库测量与计费/README.md`;流程细节按 `../docs/头程到尾程/README.md` 指向的当前阶段文档读取,需求确认和联调报告仅在历史追溯或验收证据任务中读取 - 国内货柜履约:`../docs/国内货柜履约/README.md`、`../docs/国内货柜履约/报关资料与船期价格接入梳理.md`;开发规划、开工门禁、需求确认和验收材料已归档,只有历史追溯或验收证据任务才读取 -- 海外仓业务:`../docs/海外仓业务/README.md`;海外入口 `gateway-overseas/`,尾程与入仓库存 `lastleg-service/`,海外仓储资源 `warehouse-overseas-service/` +- 海外仓业务:`../docs/海外仓业务/README.md`;统一入口 `gateway/`,尾程与入仓库存 `lastleg-service/`,海外仓储资源 `warehouse-overseas-service/` - 海外仓全流程:以正式 Controller、前端路由、权限、SQL 与跨区事件契约为范围依据;海外接柜、入仓、库存、库内服务、排车、出库、派送、一件代发、异常退回和客户回访进入 `lastleg-service/`,国内 SKU/箱唛及暂扣协同进入 `order/`、`firstleg/`、`common-event-contract/` - 财务:`../docs/财务管理/README.md` 中已确认的范围、对象、状态和接口约束 diff --git a/Jenkinsfile b/Jenkinsfile index 861c71258..36bbea19e 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,11 +1,10 @@ def serviceModules = [ - 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' + 'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'audit-service' ] def serviceCheckboxes = [ 'gateway': 'SERVICE_GATEWAY', - 'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS', 'auth': 'SERVICE_AUTH', 'iam': 'SERVICE_IAM', 'firstleg': 'SERVICE_FIRSTLEG', @@ -17,20 +16,19 @@ def serviceCheckboxes = [ 'integration-service': 'SERVICE_INTEGRATION', 'lastleg-service': 'SERVICE_LASTLEG', 'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS', - 'event-bridge-service': 'SERVICE_EVENT_BRIDGE', 'audit-service': 'SERVICE_AUDIT' ] def fullRebuildPaths = [ 'pom.xml', 'platform-bom/', 'common-core/', 'deploy/docker/', - 'deploy/docker-compose.yml', 'deploy/service-map.yml' + 'deploy/docker-compose.yml', 'deploy/service-map.yml', 'config/nacos/test/', 'deploy/scripts/', 'Jenkinsfile' ] def commonPathServices = [ 'common-web/': [ 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', 'integration-service', 'lastleg-service', 'warehouse-overseas-service', - 'event-bridge-service', 'audit-service' + 'audit-service' ], 'common-security/': [ 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', @@ -39,20 +37,16 @@ def commonPathServices = [ 'common-openapi/': [ 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', 'integration-service', 'lastleg-service', 'warehouse-overseas-service', - 'event-bridge-service', 'audit-service' + 'audit-service' ], 'common-audit/': [ 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'warehouse-overseas-service', 'audit-service' ], 'common-event-contract/': [ - 'firstleg', 'iam', 'lastleg-service', 'event-bridge-service' - ], - 'deploy/docker-compose.overseas.yml': [ - 'gateway-overseas', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service' + 'firstleg', 'finance', 'lastleg-service' ], 'deploy/docker-compose.audit.yml': ['audit-service'], - 'deploy/docker-compose.rocketmq.yml': ['event-bridge-service'] ] def databaseMigrationPaths = ['deploy/sql/'] @@ -63,45 +57,6 @@ def downstreamResults = [:] def downstreamBuildUrls = [:] def downstreamBuildNumbers = [:] -def sendProductionWeComNotification(String title, String status, String color, String detail) { - node('java-builder') { - withCredentials([string(credentialsId: 'wecom-prod-webhook', variable: 'WECOM_WEBHOOK')]) { - String sourceVersion = env.IMAGE_TAG ?: env.SOURCE_COMMIT ?: env.GIT_COMMIT ?: '未解析' - String duration = currentBuild.durationString?.replace(' and counting', '') ?: '—' - String content = """## ${title} -> 应用:后端服务 -> Job:${env.JOB_NAME} -> 构建:[#${env.BUILD_NUMBER}](${env.BUILD_URL}) -> 提交:${sourceVersion} -> 状态:${status} -${detail} -> 耗时:${duration} -> 时间:${new Date().format('yyyy-MM-dd HH:mm:ss', TimeZone.getTimeZone('Asia/Shanghai'))} -""" - String payloadFile = 'wecom-production-notification.json' - writeFile( - file: payloadFile, - text: groovy.json.JsonOutput.toJson([ - msgtype: 'markdown', - markdown: [content: content] - ]), - encoding: 'UTF-8' - ) - sh ''' - set -eu - payload_file='wecom-production-notification.json' - trap 'rm -f "$payload_file"' EXIT HUP INT TERM - response="$(curl --fail --silent --show-error \ - -H 'Content-Type: application/json' \ - --data-binary @"$payload_file" \ - "$WECOM_WEBHOOK")" - printf '%s\n' "$response" - printf '%s' "$response" | grep -Eq '"errcode"[[:space:]]*:[[:space:]]*0([[:space:]]*[,}])' - ''' - } - } -} - def parseAutoBuildState(String description, List allowedServices) { if (!description?.startsWith('AUTO ')) return null int markerIndex = description.indexOf(' | ') @@ -154,8 +109,6 @@ def environmentChoicesForJob(String jobBaseName) { def fixedEnvironmentForJob(String jobBaseName) { switch (jobBaseName) { - case 'backend-java-dev': - return 'development' case 'backend-java': return 'test' case 'backend-java-prod': @@ -167,8 +120,6 @@ def fixedEnvironmentForJob(String jobBaseName) { def fixedDownstreamJobPrefixForJob(String jobBaseName) { switch (jobBaseName) { - case 'backend-java-dev': - return 'baiyue-firstleg-dev-' case 'backend-java': return 'baiyue-firstleg-' case 'backend-java-prod': @@ -195,9 +146,8 @@ pipeline { parameters { choice(name: 'BUILD_MODE', choices: ['AUTO', 'SELECT', 'ALL'], description: 'AUTO 按 Git 变更识别;SELECT 勾选服务;ALL 构建全部服务') booleanParam(name: 'SERVICE_GATEWAY', defaultValue: false, description: 'Gateway 网关服务(gateway)') - booleanParam(name: 'SERVICE_GATEWAY_OVERSEAS', defaultValue: false, description: '海外 Gateway 网关服务(gateway-overseas)') booleanParam(name: 'SERVICE_AUTH', defaultValue: false, description: '统一认证服务(auth)') - booleanParam(name: 'SERVICE_IAM', defaultValue: false, description: '租户与权限服务(iam)') + booleanParam(name: 'SERVICE_IAM', defaultValue: false, description: '客户与权限服务(iam)') booleanParam(name: 'SERVICE_FIRSTLEG', defaultValue: false, description: '头程履约服务(firstleg)') booleanParam(name: 'SERVICE_FINANCE', defaultValue: false, description: '财务服务(finance)') booleanParam(name: 'SERVICE_ORDER', defaultValue: false, description: '订单服务(order)') @@ -207,14 +157,12 @@ pipeline { booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)') booleanParam(name: 'SERVICE_LASTLEG', defaultValue: false, description: '海外尾程与入仓服务(lastleg-service)') booleanParam(name: 'SERVICE_WAREHOUSE_OVERSEAS', defaultValue: false, description: '海外仓资源服务(warehouse-overseas-service)') - booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service;默认同时部署国内和海外实例)') - choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS'], description: 'BOTH 同时部署国内和海外实例;CN/OVERSEAS 用于单区故障重跑') booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)') choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境') booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署') - booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试;生产构建不建议勾选') - string(name: 'IMAGE_REPOSITORY', defaultValue: 'git.buggoglobal.com/java-project/baiyue-firstleg', description: '共享镜像仓库地址') - string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'git.buggoglobal.com/java-project/eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像') + booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试执行(仍编译测试源码)') + string(name: 'IMAGE_REPOSITORY', defaultValue: 'registry.example.com/team/baiyue-firstleg', description: '共享镜像仓库地址') + string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像') choice(name: 'DOWNSTREAM_JOB_PREFIX', choices: downstreamJobPrefixChoicesForJob(env.JOB_BASE_NAME), description: '由当前总控 Job 固定的单服务 Job 前缀') } @@ -230,7 +178,7 @@ pipeline { } if (params.ENVIRONMENT != resolvedEnvironment || params.DOWNSTREAM_JOB_PREFIX?.trim() != resolvedJobPrefix) { - echo "忽略外部传入的环境或 Job 前缀;${env.JOB_BASE_NAME} 固定使用 ${resolvedEnvironment} / ${resolvedJobPrefix}" + error('部署环境和下游前缀必须与当前 Job 固定映射一致') } env.RESOLVED_ENVIRONMENT = resolvedEnvironment echo "Job ${env.JOB_BASE_NAME} 使用部署环境: ${env.RESOLVED_ENVIRONMENT}" @@ -239,16 +187,7 @@ pipeline { env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim() env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim() - if (env.RESOLVED_ENVIRONMENT == 'production') { - sh ''' - set -eu - git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main - test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" || { - echo "生产构建只允许 origin/main 当前提交;请先通过 Pull Request 合并到 main 后再部署" >&2 - exit 1 - } - ''' - } + String requestedRepository = params.IMAGE_REPOSITORY?.trim() if (!requestedRepository || requestedRepository == 'registry.example.com/your-group/baiyue-firstleg') { @@ -262,9 +201,7 @@ pipeline { env.RESOLVED_JOB_PREFIX = resolvedJobPrefix env.DATABASE_CHANGE_DETECTED = 'false' - if (env.RESOLVED_ENVIRONMENT == 'production' && params.SKIP_TESTS) { - error('生产环境禁止跳过测试') - } + List selected = [] if (params.BUILD_MODE == 'ALL') { @@ -326,15 +263,7 @@ pipeline { } selected = selected.unique() - if (params.DEPLOY && selected.contains('event-bridge-service')) { - String bridgeRegion = params.EVENT_BRIDGE_REGION?.trim() - if (!['BOTH', 'SELECT', 'CN', 'OVERSEAS'].contains(bridgeRegion)) { - error('EVENT_BRIDGE_REGION 只能为 BOTH、CN 或 OVERSEAS') - } - if (bridgeRegion == 'SELECT') { - echo '兼容旧 Jenkins 参数 SELECT:本次按 BOTH 同时部署国内和海外事件桥' - } - } + env.SELECTED_SERVICES = selected.join(',') env.NO_SERVICE_CHANGE = selected ? 'false' : 'true' if (!selected) { @@ -402,11 +331,15 @@ pipeline { .findAll { it } .join(',') echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}" - sh """ - test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\" - find . -type f -path '*/target/surefire-reports/TEST-*.xml' -delete - mvn --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test - """ + lock(resource: 'firstleg-test-maven') { + timeout(time: 50, unit: 'MINUTES') { + sh """ + test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\" + find . -type f -path '*/target/surefire-reports/TEST-*.xml' -delete + sh deploy/scripts/run-ci-maven.sh --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test + """ + } + } echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试" } } @@ -426,14 +359,6 @@ pipeline { String service = selectedService branches[service] = { String jobName = "${env.RESOLVED_JOB_PREFIX}${service}" - boolean deployBothBridges = service == 'event-bridge-service' && - ['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION) - String downstreamImageTag = deployBothBridges - ? "${env.IMAGE_TAG}-both" - : env.IMAGE_TAG - String downstreamBridgeRegion = deployBothBridges - ? 'CN' - : (service == 'event-bridge-service' ? params.EVENT_BRIDGE_REGION : 'CN') echo "触发单服务 Job: ${jobName}" def downstreamBuild = build( job: jobName, @@ -441,16 +366,13 @@ pipeline { propagate: false, parameters: [ string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT), - string(name: 'IMAGE_TAG', value: downstreamImageTag), + string(name: 'IMAGE_TAG', value: env.IMAGE_TAG), string(name: 'ENVIRONMENT', value: env.RESOLVED_ENVIRONMENT), booleanParam(name: 'DEPLOY', value: params.DEPLOY), // 总控已完成统一测试,或用户已明确选择跳过;下游仅负责编译、打包和部署。 booleanParam(name: 'SKIP_TESTS', value: true), string(name: 'IMAGE_REPOSITORY', value: env.RESOLVED_IMAGE_REPOSITORY), - string(name: 'DOCKER_BASE_IMAGE', value: params.DOCKER_BASE_IMAGE), - // Choice 参数始终传递所有新旧 Job 都接受的单区值; - // 双区意图通过现有 IMAGE_TAG 参数携带,由下游还原真实镜像标签。 - string(name: 'EVENT_BRIDGE_REGION', value: downstreamBridgeRegion) + string(name: 'DOCKER_BASE_IMAGE', value: params.DOCKER_BASE_IMAGE) ] ) downstreamResults[service] = downstreamBuild.result ?: 'UNKNOWN' @@ -483,89 +405,4 @@ pipeline { } } - post { - success { - script { - if (env.JOB_BASE_NAME == 'backend-java-prod' && params.DEPLOY && env.NO_SERVICE_CHANGE != 'true') { - try { - List deployedServices = env.SELECTED_SERVICES?.split(',') - ?.collect { it.trim() } - ?.findAll { it } ?: [] - String serviceSummary = deployedServices.size() <= 3 - ? deployedServices.join('、') - : "${deployedServices.size()} 个服务" - sendProductionWeComNotification( - '✅ 生产部署成功', - '成功', - 'info', - "> 结果:${deployedServices.size()} 个服务全部成功\n" + - "> 部署服务:${serviceSummary ?: '—'}" - ) - } catch (Exception error) { - echo "企业微信通知失败,不影响原构建结果:${error.message}" - } - } - } - } - unstable { - script { - if (env.JOB_BASE_NAME == 'backend-java-prod' && params.DEPLOY) { - try { - List failedServices = downstreamResults.findAll { service, result -> result != 'SUCCESS' } - .collect { service, result -> service } - int successCount = downstreamResults.findAll { service, result -> result == 'SUCCESS' }.size() - String failedLinks = failedServices.collect { service -> - String url = downstreamBuildUrls[service] - String number = downstreamBuildNumbers[service] - String label = number ? "${service} #${number}" : service - url ? "[${label}](${url})" : label - }.join('、') - sendProductionWeComNotification( - '⚠️ 生产部署部分失败', - '部分失败', - 'warning', - "> 结果:成功 ${successCount} / 失败 ${failedServices.size()}\n" + - "> 失败服务:${failedLinks ?: '未解析'}\n" + - '> 说明:点击失败服务可直接查看下游构建日志' - ) - } catch (Exception error) { - echo "企业微信通知失败,不影响原构建结果:${error.message}" - } - } - } - } - failure { - script { - if (env.JOB_BASE_NAME == 'backend-java-prod' && params.DEPLOY) { - try { - sendProductionWeComNotification( - '❌ 生产发布失败', - '失败', - 'warning', - '> 说明:生产发布流程失败,请打开父构建日志查看失败阶段' - ) - } catch (Exception error) { - echo "企业微信通知失败,不影响原构建结果:${error.message}" - } - } - } - } - aborted { - script { - if (env.JOB_BASE_NAME == 'backend-java-prod' && params.DEPLOY) { - try { - sendProductionWeComNotification( - '⏹ 生产部署已取消', - '已取消', - 'comment', - '> 说明:生产部署流程被人工取消或中止' - ) - } catch (Exception error) { - echo "企业微信通知失败,不影响原构建结果:${error.message}" - } - } - } - } - } - } diff --git a/Jenkinsfile.service b/Jenkinsfile.service index 2f213a75f..310979127 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -1,11 +1,10 @@ def serviceModules = [ - 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' + 'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'audit-service' ] def serviceJars = [ 'gateway': 'gateway/target/gateway-1.0.0-SNAPSHOT.jar', - 'gateway-overseas': 'gateway-overseas/target/gateway-overseas-1.0.0-SNAPSHOT.jar', 'auth': 'auth/target/auth-service-1.0.0-SNAPSHOT.jar', 'iam': 'iam/target/iam-service-1.0.0-SNAPSHOT.jar', 'firstleg': 'firstleg/target/firstleg-service-1.0.0-SNAPSHOT.jar', @@ -17,18 +16,12 @@ def serviceJars = [ 'integration-service': 'integration-service/target/integration-service-1.0.0-SNAPSHOT.jar', 'lastleg-service': 'lastleg-service/target/lastleg-service-1.0.0-SNAPSHOT.jar', 'warehouse-overseas-service': 'warehouse-overseas-service/target/warehouse-overseas-service-1.0.0-SNAPSHOT.jar', - 'event-bridge-service': 'event-bridge-service/target/event-bridge-service-1.0.0-SNAPSHOT.jar', 'audit-service': 'audit-service/target/audit-service-1.0.0-SNAPSHOT.jar' ] def fixedEnvironmentForServiceJob(String jobBaseName) { - if (jobBaseName?.startsWith('baiyue-firstleg-dev-')) { - return 'development' - } - if (jobBaseName?.startsWith('baiyue-firstleg-prod-')) { - return 'production' - } - if (jobBaseName?.startsWith('baiyue-firstleg-')) { + if (jobBaseName?.startsWith('baiyue-firstleg-prod-')) return 'production' + if (jobBaseName?.startsWith('baiyue-firstleg-') && !jobBaseName.startsWith('baiyue-firstleg-dev-') && !jobBaseName.startsWith('baiyue-firstleg-prod-')) { return 'test' } return null @@ -53,17 +46,16 @@ pipeline { string(name: 'SOURCE_COMMIT', defaultValue: '', description: '必须构建的 Git commit;通常由总控 Job 传入') string(name: 'IMAGE_TAG', defaultValue: '', description: '不可变镜像标签;通常使用 Git commit 前 12 位') choice(name: 'ENVIRONMENT', choices: environmentChoicesForServiceJob(env.JOB_BASE_NAME), description: '由当前单服务 Job 固定的部署环境') + booleanParam(name: 'ALLOW_SAME_CONTRACT_ROLLBACK', defaultValue: false, description: '仅确认相同协议与Schema时允许回滚;架构切换保持关闭') booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务') booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试') - choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS', 'SELECT'], description: '仅 event-bridge-service 使用;BOTH 依次部署国内和海外实例;SELECT 为总控兼容值') - string(name: 'IMAGE_REPOSITORY', defaultValue: 'git.buggoglobal.com/java-project/baiyue-firstleg', description: '共享镜像仓库基础路径,不包含服务名和标签') - string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'git.buggoglobal.com/java-project/eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像') + string(name: 'IMAGE_REPOSITORY', defaultValue: 'registry.example.com/team/baiyue-firstleg', description: '共享镜像仓库基础路径,不包含服务名和标签') + string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像') } environment { REGISTRY_CREDENTIALS = 'gitea-registry-token' MAVEN_SETTINGS = 'deploy/maven/settings-ci.xml' - MAVEN_OPTS = '-Dfile.encoding=UTF-8 -Dmaven.wagon.http.retryHandler.count=3' } stages { @@ -98,12 +90,7 @@ pipeline { error("未配置单服务 Job ${env.JOB_BASE_NAME} 的固定部署环境,拒绝继续") } if (params.ENVIRONMENT != resolvedEnvironment) { - echo "忽略外部传入的环境 ${params.ENVIRONMENT};${env.JOB_BASE_NAME} 固定使用 ${resolvedEnvironment}" - } - boolean deployBothBridges = service == 'event-bridge-service' && - imageTag?.endsWith('-both') - if (deployBothBridges) { - imageTag = imageTag.substring(0, imageTag.length() - '-both'.length()) + error('部署环境必须与当前单服务 Job 固定映射一致') } if (!sourceCommit || !(sourceCommit ==~ /(?i)[0-9a-f]{7,40}/)) { error('SOURCE_COMMIT 必须是 7 到 40 位十六进制 Git commit') @@ -123,14 +110,8 @@ pipeline { env.RESOLVED_SOURCE_COMMIT = sourceCommit env.RESOLVED_IMAGE_TAG = imageTag env.RESOLVED_IMAGE_REPOSITORY = imageRepository - env.DEPLOY_BOTH_EVENT_BRIDGES = deployBothBridges ? 'true' : 'false' - if (service == 'event-bridge-service' && params.DEPLOY) { - String bridgeRegion = params.EVENT_BRIDGE_REGION?.trim() - if (!['BOTH', 'SELECT', 'CN', 'OVERSEAS'].contains(bridgeRegion)) { - error('EVENT_BRIDGE_REGION 只能为 BOTH、CN 或 OVERSEAS') - } - } + } sh ''' @@ -140,18 +121,16 @@ pipeline { git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}" git checkout --detach "${RESOLVED_SOURCE_COMMIT}" test "$(git rev-parse HEAD)" = "$(git rev-parse "${RESOLVED_SOURCE_COMMIT}^{commit}")" - ''' - - sh ''' - if [ "${RESOLVED_ENVIRONMENT}" = "production" ]; then + if [ "$RESOLVED_ENVIRONMENT" = "production" ]; then git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main - git merge-base --is-ancestor "${RESOLVED_SOURCE_COMMIT}" origin/main || { - echo "生产构建只允许已经合并到 main 的提交" >&2 + git merge-base --is-ancestor "$RESOLVED_SOURCE_COMMIT" origin/main || { + echo "灰度发布提交必须已经合并到 main" >&2 exit 1 } fi ''' + script { currentBuild.description = "${env.RESOLVED_SERVICE} | ${env.RESOLVED_IMAGE_TAG} | ${env.RESOLVED_ENVIRONMENT} | ${env.NODE_NAME}" echo "独立构建服务: ${env.RESOLVED_SERVICE},提交: ${env.RESOLVED_SOURCE_COMMIT},节点: ${env.NODE_NAME}" @@ -163,17 +142,17 @@ pipeline { steps { script { boolean coordinatorPinnedCommit = params.SOURCE_COMMIT?.trim() as boolean - if (env.RESOLVED_ENVIRONMENT == 'production' && params.SKIP_TESTS && !coordinatorPinnedCommit) { - error('生产环境手工构建禁止跳过测试;总控流水线可在统一测试通过后执行快速打包') - } - boolean skipTestExecution = env.RESOLVED_ENVIRONMENT == 'test' || params.SKIP_TESTS + + boolean skipTestExecution = params.SKIP_TESTS String testOption = skipTestExecution ? '-DskipTests' : '' if (params.SKIP_TESTS && coordinatorPinnedCommit) { echo "总控已处理提交 ${env.RESOLVED_SOURCE_COMMIT} 的测试门禁,本单服务仅编译测试源码并打包" - } else if (env.RESOLVED_ENVIRONMENT == 'test') { - echo '测试环境快速部署:编译主代码和测试代码,但不重复执行单元测试;完整测试由提交门禁和预发布执行' } - sh "mvn --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}" + lock(resource: 'firstleg-test-maven') { + timeout(time: 50, unit: 'MINUTES') { + sh "sh deploy/scripts/run-ci-maven.sh --settings ${env.MAVEN_SETTINGS} --batch-mode --errors -pl ${env.RESOLVED_SERVICE} -am clean package ${testOption}" + } + } } } post { @@ -216,51 +195,48 @@ pipeline { } } - stage('部署开发或测试环境') { - when { - beforeAgent true - expression { params.DEPLOY && ['development', 'test'].contains(env.RESOLVED_ENVIRONMENT) } - } + stage('部署 TEST') { + when { expression { params.DEPLOY && env.RESOLVED_ENVIRONMENT == 'test' } } agent none steps { script { - String deploySlotLabel = "firstleg-${env.RESOLVED_ENVIRONMENT}-deploy-slot" - String serviceDeployResource = "firstleg-${env.RESOLVED_ENVIRONMENT}-${env.RESOLVED_SERVICE}-deploy" - String credentialId = "firstleg-${env.RESOLVED_ENVIRONMENT}-env" - String targetLabel = env.RESOLVED_ENVIRONMENT == 'development' ? 'java-dev' : 'java-test' - // 不同服务可以并行,但同一环境的同一服务只能有一个任务更新容器。 - // resource 未预配置时 Lockable Resources 会创建仅在构建期间存在的临时资源。 - lock(resource: serviceDeployResource, inversePrecedence: true) { - lock(label: deploySlotLabel, quantity: 1, inversePrecedence: true) { - node(targetLabel) { - deleteDir() - try { - unstash 'deployment-files' - echo "在目标节点 ${env.NODE_NAME} 本机部署到 ${env.RESOLVED_ENVIRONMENT}" - List bridgeRegions = (env.RESOLVED_SERVICE == 'event-bridge-service' && - (env.DEPLOY_BOTH_EVENT_BRIDGES == 'true' || - ['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION))) - ? ['CN', 'OVERSEAS'] - : [params.EVENT_BRIDGE_REGION] - withCredentials([ - file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE'), - usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, - usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD') - ]) { - bridgeRegions.each { bridgeRegion -> - if (env.RESOLVED_SERVICE == 'event-bridge-service') { - echo "部署事件桥实例: ${bridgeRegion}" - } - withEnv([ - 'TARGET_IS_LOCAL=true', - "EVENT_BRIDGE_REGION_OVERRIDE=${bridgeRegion}" - ]) { - sh "sh deploy/scripts/deploy-built-service.sh '${env.RESOLVED_ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\"" - } - } - } - } finally { - cleanWs(deleteDirs: true, notFailBuild: true) + lock(resource: "firstleg-test-${env.RESOLVED_SERVICE}-deploy", inversePrecedence: true) { + node('java-test') { + deleteDir() + unstash 'deployment-files' + withCredentials([ + file(credentialsId: 'firstleg-test-env', variable: 'DEPLOY_ENV_FILE'), + usernamePassword(credentialsId: env.REGISTRY_CREDENTIALS, usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD') + ]) { + withEnv(['TARGET_IS_LOCAL=true', "ALLOW_SAME_CONTRACT_ROLLBACK=${params.ALLOW_SAME_CONTRACT_ROLLBACK}"]) { + sh 'sh deploy/scripts/deploy-built-service.sh test "$RESOLVED_IMAGE_REPOSITORY" "$RESOLVED_IMAGE_TAG" "$RESOLVED_SERVICE" "$DEPLOY_ENV_FILE"' + } + } + } + } + } + } + } + + stage('部署临时灰度') { + when { expression { params.DEPLOY && env.RESOLVED_ENVIRONMENT == 'production' } } + agent none + steps { + script { + lock(resource: "firstleg-production-${env.RESOLVED_SERVICE}-deploy", inversePrecedence: true) { + node('java-builder') { + deleteDir() + unstash 'deployment-files' + withCredentials([ + file(credentialsId: 'firstleg-production-env', variable: 'DEPLOY_ENV_FILE'), + usernamePassword(credentialsId: 'gitea-production-pull-token', usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD'), + sshUserPrivateKey(credentialsId: 'firstleg-production-ssh', keyFileVariable: 'TARGET_SSH_KEY', usernameVariable: 'TARGET_SSH_USER'), + string(credentialsId: 'firstleg-production-host', variable: 'TARGET_SSH_HOST'), + file(credentialsId: 'firstleg-production-known-hosts', variable: 'TARGET_SSH_KNOWN_HOSTS') + ]) { + withEnv(['TARGET_IS_LOCAL=false', 'TARGET_SSH_TARGET=' + env.TARGET_SSH_USER + '@' + env.TARGET_SSH_HOST, + "ALLOW_SAME_CONTRACT_ROLLBACK=${params.ALLOW_SAME_CONTRACT_ROLLBACK}"]) { + sh 'sh deploy/scripts/deploy-built-service.sh production "$RESOLVED_IMAGE_REPOSITORY" "$RESOLVED_IMAGE_TAG" "$RESOLVED_SERVICE" "$DEPLOY_ENV_FILE"' } } } @@ -269,53 +245,6 @@ pipeline { } } - stage('部署生产环境单个服务') { - when { - beforeAgent true - expression { params.DEPLOY && env.RESOLVED_ENVIRONMENT == 'production' } - } - agent { label 'java-builder' } - steps { - deleteDir() - unstash 'deployment-files' - script { - String credentialId = "firstleg-${env.RESOLVED_ENVIRONMENT}-env" - String serviceDeployResource = "firstleg-production-${env.RESOLVED_SERVICE}-deploy" - List bridgeRegions = (env.RESOLVED_SERVICE == 'event-bridge-service' && - (env.DEPLOY_BOTH_EVENT_BRIDGES == 'true' || - ['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION))) - ? ['CN', 'OVERSEAS'] - : [params.EVENT_BRIDGE_REGION] - lock(resource: serviceDeployResource, inversePrecedence: true) { - withCredentials([ - file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE'), - usernamePassword(credentialsId: 'gitea-production-pull-token', usernameVariable: 'REGISTRY_USER', passwordVariable: 'REGISTRY_PASSWORD'), - sshUserPrivateKey(credentialsId: 'firstleg-production-ssh', keyFileVariable: 'TARGET_SSH_KEY', usernameVariable: 'TARGET_SSH_USER'), - string(credentialsId: 'firstleg-production-host', variable: 'TARGET_SSH_HOST'), - file(credentialsId: 'firstleg-production-known-hosts', variable: 'TARGET_SSH_KNOWN_HOSTS') - ]) { - bridgeRegions.each { bridgeRegion -> - if (env.RESOLVED_SERVICE == 'event-bridge-service') { - echo "部署生产事件桥实例: ${bridgeRegion}" - } - withEnv([ - 'TARGET_DEPLOY_DIR=/data/firstleg/deploy/services', - 'TARGET_SSH_TARGET=' + env.TARGET_SSH_USER + '@' + env.TARGET_SSH_HOST, - "EVENT_BRIDGE_REGION_OVERRIDE=${bridgeRegion}" - ]) { - sh "sh deploy/scripts/deploy-built-service.sh '${env.RESOLVED_ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\"" - } - } - } - } - } - } - post { - always { - cleanWs(deleteDirs: true, notFailBuild: true) - } - } - } } post { diff --git a/README.md b/README.md index aaebd9eea..d1cf1d8a7 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,22 @@ # 柏悦头程平台 -本仓库承载国内头程与海外仓后端,包含统一认证、国内订单与履约、国内外仓储资源、海外尾程与入仓业务,以及跨区域事件桥和公共能力。国内、海外仍保持独立数据库、Nacos 命名空间与部署边界,通过版本化跨区域事件协作。 +本仓库承载头程与海外仓后端。当前只有一个测试环境,目标为所有服务同机部署,统一 Gateway、Auth 会话及 Nacos 命名空间,业务服务继续按职责拥有独立 Schema。部署与切换见[测试环境单机部署与配置契约](../docs/测试环境单机部署与配置契约.md),路由、认证和事件投递见[统一认证与服务通信契约](../docs/统一认证与服务通信契约.md)。 + +根 POM 与发布清单已收敛为 20 个模块、13 个服务;本地配置变化尚未经过真实 TEST 部署验收。`gateway-overseas/` 与 `event-bridge-service/` 为退役待清理源码,已不参与当前聚合构建和发布。 > 当前主体模型:业务单租户,产品只包含“平台—客户”两级。现有 `tenant_id > 0`、`tenantId` 和 `Tenant*` 是历史兼容命名,当前承载客户归属与隔离;`tenant_id = 0` 是平台账号、平台 RBAC 和平台公共主数据的技术作用域,不是可管理租户。平台公共仓库、往来单位、快递公司、托盘、库位等归属 `0`,客户订单、文件和费用归属客户 ID。未经过独立迁移设计,不得新增真实租户层或把现有客户隔离字段改义为上级租户。 ## 模块 +- `gateway`:四端统一 API 入口。 +- `finance`:财务账单、费用、充值、授信与资金核销。 - `auth`:统一登录、令牌签发与刷新。 - `iam`:客户主档、用户、企业成员、角色、菜单、按钮权限和数据范围;部分路径、表和类保留 `tenant` 历史命名。 - `order`:订单主数据及订单状态编排。 - `firstleg`:头程揽收、入仓、装载、发运和交接。 - `warehouse`:国内仓储能力。 - `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力;Print 调用方接入见 `print/README.md`。 -- `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service`:海外公网入口、海外尾程/入仓与 IAM 安全投影、海外仓储资源。 -- `event-bridge-service`:按国内、海外双实例部署的跨区域事件桥。 +- `lastleg-service`、`warehouse-overseas-service`:海外尾程/入仓/出库/派送和海外仓储资源,统一会话鉴权。 - `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。 - `platform-bom`:统一管理仓库内公共依赖版本。 @@ -29,21 +32,17 @@ GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务 2026-07-30 订单与客户前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。 -国内本地联调启动顺序为:IAM、Auth、Firstleg、Order、Warehouse、File、Print,最后启动 Gateway。往来单位不是独立服务,由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。海外服务通过独立 Compose 与 Nacos 配置启动,不与国内服务共享数据库或配置命名空间。 - -国内 Nacos 配置位于 `config/nacos/cn`。数据库、Redis、Nacos 和 RocketMQ 的生产密码必须通过环境变量或配置中心密文注入,禁止提交明文密码。 +唯一发布配置为 `deploy/docker-compose.yml`、`deploy/env/.env.test.example` 和 `config/nacos/test/`;所有保留服务共用实际 Namespace ID、Auth/Redis 会话和单机内部网络,各服务 Schema 保留。往来单位由 `warehouse-service` 承载并保存到 `baiyue_warehouse.wh_partner`。发布、健康检查和失败恢复见 `deploy/TEST单机部署执行清单.md`。真实凭据不得提交;本地编译不代表另有开发环境。 ## 容器日志 -所有服务只向标准输出和标准错误写日志,不在容器内生成应用日志文件,也不把应用日志目录挂载到宿主机。国内、海外及基础设施 Compose 默认使用宝塔可见的 Docker `json-file` 日志驱动,并设置强制轮转;仍可通过 `docker logs` 排查。文件服务的 `/data/files` 是业务文件目录,不属于日志目录,必须继续持久化。 +所有服务只向标准输出和标准错误写日志,不在容器内生成应用日志文件,也不把应用日志目录挂载到宿主机。统一业务及基础设施 Compose 默认使用宝塔可见的 Docker `json-file` 日志驱动,并设置强制轮转;仍可通过 `docker logs` 排查。文件服务的 `/data/files` 是业务文件目录,不属于日志目录,必须继续持久化。 -单容器默认保留 `50m × 3`,可在各环境部署变量中覆盖: +单容器默认保留 `50m × 3`,可在 TEST 部署变量中覆盖: | 环境 | `DOCKER_LOG_DRIVER` | `DOCKER_LOG_MAX_SIZE` | `DOCKER_LOG_MAX_FILE` | 长期留存 | |---|---|---:|---:|---| -| 开发 | `json-file` | `20m` | `2` | 不要求 | | 测试 | `json-file` | `50m` | `3` | 不要求 | -| 生产前期 | `json-file` | `100m` | `5` | 宝塔与 `docker logs` 提供短期排障能力 | 当前优先保证宝塔可观测性,因此不启用 `json-file` 压缩或非阻塞投递,避免面板无法读取轮转文件或在日志压力下静默丢弃。接入集中日志系统后,可通过 `DOCKER_LOG_DRIVER` 切换驱动,并由集中系统承担长期检索与留存。 @@ -51,7 +50,7 @@ GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务 ## 网关跨域配置 -本地默认允许商户端和后台端从 `localhost/127.0.0.1` 的 `5173/5174` 端口访问网关。部署到测试或生产环境时,必须通过 `CORS_ALLOWED_ORIGINS` 配置两个前端的明确地址,多个地址用英文逗号分隔,例如: +本地默认允许商户端和后台端从 `localhost/127.0.0.1` 的 `5173/5174` 端口访问网关。部署到 TEST 时,必须通过 `CORS_ALLOWED_ORIGINS` 配置两个前端的明确地址,多个地址用英文逗号分隔,例如: ```text CORS_ALLOWED_ORIGINS=https://merchant.example.com,https://admin.example.com @@ -61,4 +60,6 @@ CORS_ALLOWED_ORIGINS=https://merchant.example.com,https://admin.example.com ## 仓库边界 -仓库边界、服务调用和拆分规则见 `docs/头程项目目录结构与服务说明.md` 的“项目定位”“主要调用关系”和“开发和拆分约束”。国内与海外服务不允许跨区域共享数据库、Redis 或 Nacos,也不允许通过公网直接调用对方内部服务。 +仓库结构入口见 `docs/头程项目目录结构与服务说明.md`。服务保持数据归属、禁止跨库直接查询其他服务业务表;同机可共用 MySQL 实例、Redis 和 Nacos。内部调用不通过公网,部署及通信规则以新的两份主契约为准。 + +前端 phase4 定向离线检查入口为 `apps/admin-web/scripts/phase4-test-frontend-acceptance.mjs`(工作区相对路径),由 Admin 的 `pnpm test:phase4:test` 使用 `vitest.phase4-test.config.ts` 执行;不替代真实HTTP、页面或PDA验收。 diff --git a/audit-service/AGENTS.md b/audit-service/AGENTS.md index 3f9ba8e89..781ddd2ab 100644 --- a/audit-service/AGENTS.md +++ b/audit-service/AGENTS.md @@ -17,7 +17,7 @@ | 入库编排 | `service/AuditIngestService.java` | | 持久化 | `entity/AuditLogEntity.java`、`mapper/AuditLogMapper.java`、`repository/AuditEventRepository.java` | | 表结构 | `src/main/resources/db/audit.sql` | -| 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/audit-service.yml` | +| 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/test/audit-service.yml` | ## 修改与验证 diff --git a/audit-service/src/main/resources/application-dev.yml b/audit-service/src/main/resources/application-dev.yml deleted file mode 100644 index cd4d4a031..000000000 --- a/audit-service/src/main/resources/application-dev.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${AUDIT_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_audit?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${AUDIT_DB_USERNAME:root} - password: ${AUDIT_DB_PASSWORD:123456} diff --git a/audit-service/src/main/resources/application-test.yml b/audit-service/src/main/resources/application-test.yml deleted file mode 100644 index 5cde9b056..000000000 --- a/audit-service/src/main/resources/application-test.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${AUDIT_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_audit?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true} - username: ${AUDIT_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}} - password: ${AUDIT_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}} diff --git a/audit-service/src/main/resources/application.yml b/audit-service/src/main/resources/application.yml index 7246521f9..6c658ac7e 100644 --- a/audit-service/src/main/resources/application.yml +++ b/audit-service/src/main/resources/application.yml @@ -3,27 +3,27 @@ server: spring: profiles: - active: ${SPRING_PROFILES_ACTIVE:dev} + active: ${SPRING_PROFILES_ACTIVE:test} application: name: audit-service config: import: - - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + - nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true + - nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} + server-addr: ${NACOS_SERVER_ADDR} + username: ${NACOS_USERNAME} + password: ${NACOS_PASSWORD} discovery: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} + server-addr: ${NACOS_SERVER_ADDR} enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} config: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - namespace: ${NACOS_NAMESPACE:public} + server-addr: ${NACOS_SERVER_ADDR} + namespace: ${NACOS_NAMESPACE} baiyue.audit.enabled: false baiyue: diff --git a/auth/AGENTS.md b/auth/AGENTS.md index 0cb3466c8..5decc247f 100644 --- a/auth/AGENTS.md +++ b/auth/AGENTS.md @@ -5,7 +5,7 @@ - Mapper 只由 `BaiyueAuthApplication.java` 对 `com.baiyue.auth` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。 - 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。 - 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。 -- 修改国内/海外 Token、Audience、JWKS 或跨区域身份边界前,先读 `../../docs/跨区域认证与服务通信契约.md`。 +- 修改统一 Access/Refresh Token、Audience、JWKS、Redis 会话或权限撤销前,先读 `../../docs/统一认证与服务通信契约.md` 的认证章节;平台、客户及两类 PDA 保持各自访问域和仓绑定边界。 - `tenant_id=0` 为平台作用域,`tenant_id>0` 为客户 ID;不得引入租户管理或切换。 ## 模块入口 @@ -16,9 +16,9 @@ | 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` | | 登录标识与企业联系人解耦 | `service/AccountManagementService.java`、`dto/AccountManagementDtos.java`、`service/MerchantRegistrationService.java`;注册手机号只在创建时同时初始化首位管理员登录手机号和公司联系电话,创建后二者独立维护;账号手机号变更需撤销该账号全部会话 | | 统一 JWT 签发与 JWKS | `config/AuthSecurityConfig.java`、`config/AuthSecurityProperties.java`、`service/AuthService.java`;Auth 是国内与海外仓共用的唯一用户 Token 签发方,JWKS 公钥发布按认证接口契约实现 | -| 国内小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,国内服务结合 Redis 会话校验 | -| 海外访问 Token 签发 | `service/OverseasTokenService.java`;登录/刷新编排使用同一用户身份同时签发短时 RS256 海外 Token,限定海外 Audience,只携带稳定身份、客户范围和版本声明,不携带角色或权限集;前端统一认证层负责按区域 API 自动选择 | -| 海外 PDA 实时会话校验 | `controller/InternalAccountController.java`、`service/OverseasSessionValidationService.java`;只接受海外 Gateway 已验签后提取的 userId/tokenVersion/overseasWarehouseId,以内部服务令牌保护并实时联查 IAM 当前仓绑定 | +| 统一小 JWT 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java`;浏览器登录 Token 只承载稳定身份、应用域和版本声明,各业务服务结合 Redis 会话校验 | +| 统一 RS256 签发 | `config/UnifiedJwtConfiguration.java`、`config/UnifiedJwtProperties.java`;登录、刷新、代登录使用唯一 Access Token,`JWT_RSA_JWK_SET` 和 `JWT_CURRENT_KID` 仅配置在 Auth;不再提供海外 Token 兑换 | +| 统一会话权威校验 | `controller/InternalAccountController.java`、`service/SessionStateService.java`;内部 `/internal/auth/accounts/sessions/validate` 接收 jwtId/userId,核对 Redis Access 与状态指纹,再查询当前 Auth/IAM;内部服务令牌保护,不接收或转发用户 JWT | | 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` | @@ -28,7 +28,9 @@ ## 修改约束 -- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。国内登录 Token 的实时权限来自 Redis 会话快照;短时海外 Token 不携带角色或权限集,海外授权由 IAM 事件投影到 Lastleg,再由海外 Gateway 验证并以可信内网上下文传递,海外服务不访问国内 Redis。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。 +- 密码、Token、敏感 Claim 不得写日志;统一 JWT 使用 RS256,私钥只留在 Auth,JWKS 只发布公钥。统一 Token 不携带角色或权限集,权限来自共享 Redis 会话;用户、客户、权限和仓绑定变更必须撤销受影响会话。本地源码已采用统一权威状态校验并移除安全投影,不能将本地改造视为测试环境已部署。修改登录响应、Claim、Audience、JWKS、权限快照或过期策略时联查 Admin/Client 统一认证层、`common-security/`、`gateway/`、`iam/` 和海外服务消费者。 +- 登录、刷新和账号安全修改通过账号行锁串行化;IAM 身份、权限、客户版本、仓绑定由单条 SQL 快照组成指纹,Access/Refresh 均绑定指纹。Gateway 与业务服务逐请求在线核对,无正结果缓存;提交后开始的请求不得接受旧指纹,提交前已经完成认证的在途业务请求不追溯中断。撤销失败使变更失败或回滚,丢失撤销响应也不得恢复旧会话。 +- 校验链为 Gateway/业务服务 → Auth → IAM,不转发 Bearer、无递归。统一 `baiyue.security.session-validation-uri` 默认 `http://auth:9100/internal/auth/accounts/sessions/validate`;外层请求总超时 4 秒,Auth→IAM 连接 1 秒/读取 2 秒且不自动重试。Auth/IAM/Redis 任一不可用均失败关闭;真实吞吐与耗时仍需测试环境验证。 - 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。 - 登录账号的 username、phone、email 唯一性只约束 Auth 账号;IAM 公司联系电话/联系邮箱是企业资料,不得反向覆盖账号登录标识,也不得因其他企业使用相同联系电话而拒绝。成员登录手机号变更只能由 IAM 账号协作入口同步并撤销该账号全部会话。 - 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。 diff --git a/auth/pom.xml b/auth/pom.xml index 8d1a1d8cb..58bb2d170 100644 --- a/auth/pom.xml +++ b/auth/pom.xml @@ -22,6 +22,7 @@ com.mysqlmysql-connector-jruntime org.springframework.bootspring-boot-starter-testtest org.springframework.securityspring-security-testtest + com.h2databaseh2test org.springframework.bootspring-boot-maven-plugin diff --git a/auth/src/main/java/com/baiyue/auth/client/AuthorizationFeignConfiguration.java b/auth/src/main/java/com/baiyue/auth/client/AuthorizationFeignConfiguration.java new file mode 100644 index 000000000..238c63f68 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/client/AuthorizationFeignConfiguration.java @@ -0,0 +1,14 @@ +package com.baiyue.auth.client; + +import feign.Request; +import feign.Retryer; +import org.springframework.context.annotation.Bean; +import java.util.concurrent.TimeUnit; + +/** Only the IAM authorization client uses this bounded, single-attempt chain. */ +public class AuthorizationFeignConfiguration { + @Bean public Request.Options authorizationRequestOptions() { + return new Request.Options(1, TimeUnit.SECONDS, 2, TimeUnit.SECONDS, false); + } + @Bean public Retryer authorizationRetryer() { return Retryer.NEVER_RETRY; } +} diff --git a/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java b/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java index a7cae146c..8b2632ae6 100644 --- a/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java +++ b/auth/src/main/java/com/baiyue/auth/client/AuthorizationSnapshot.java @@ -23,6 +23,14 @@ public class AuthorizationSnapshot { private Long permissionVersion; private Long overseasWarehouseId; private Long domesticWarehouseId; + private Long tenantVersion; + + public AuthorizationSnapshot(Long userId, Long tenantId, Long membershipId, String appCode, + String clientType, Set roles, Set permissions, Long permissionVersion, + Long overseasWarehouseId, Long domesticWarehouseId) { + this(userId, tenantId, membershipId, appCode, clientType, roles, permissions, + permissionVersion, overseasWarehouseId, domesticWarehouseId, 0L); + } public AuthorizationSnapshot(Long userId, Long tenantId, Long membershipId, String appCode, String clientType, Set roles, Set permissions, diff --git a/auth/src/main/java/com/baiyue/auth/client/IamAuthorizationClient.java b/auth/src/main/java/com/baiyue/auth/client/IamAuthorizationClient.java index 5dca79307..3a1d6a019 100644 --- a/auth/src/main/java/com/baiyue/auth/client/IamAuthorizationClient.java +++ b/auth/src/main/java/com/baiyue/auth/client/IamAuthorizationClient.java @@ -9,7 +9,7 @@ import org.springframework.web.bind.annotation.RequestHeader; /** * 认证服务访问 IAM 内部授权接口的声明式客户端,用于加载用户在指定应用和租户下的权限快照。 */ -@FeignClient(name = "iam-service", contextId = "iamAuthorizationClient", +@FeignClient(name = "iam-service", contextId = "iamAuthorizationClient", configuration = AuthorizationFeignConfiguration.class, url = "${baiyue.clients.iam.url:}", path = "/internal/iam") public interface IamAuthorizationClient { @PostMapping("/authorization-snapshot") diff --git a/auth/src/main/java/com/baiyue/auth/config/AuthSecurityProperties.java b/auth/src/main/java/com/baiyue/auth/config/AuthSecurityProperties.java index 5b9f00a1c..32ad3cbb0 100644 --- a/auth/src/main/java/com/baiyue/auth/config/AuthSecurityProperties.java +++ b/auth/src/main/java/com/baiyue/auth/config/AuthSecurityProperties.java @@ -8,7 +8,7 @@ import java.time.Duration; * 认证安全配置属性,承载 JWT 密钥、签发方及访问令牌、刷新令牌有效期。 */ @ConfigurationProperties(prefix = "baiyue.security") -public record AuthSecurityProperties(String jwtSecret, String issuer, String audience, +public record AuthSecurityProperties(String issuer, String audience, Duration accessTokenTtl, Duration refreshTokenTtl, int maxLoginFailures, Duration lockDuration) { } diff --git a/auth/src/main/java/com/baiyue/auth/config/DomesticJwtConfiguration.java b/auth/src/main/java/com/baiyue/auth/config/DomesticJwtConfiguration.java deleted file mode 100644 index acae75153..000000000 --- a/auth/src/main/java/com/baiyue/auth/config/DomesticJwtConfiguration.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.baiyue.auth.config; - -import com.nimbusds.jose.jwk.source.ImmutableSecret; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Primary; -import org.springframework.security.oauth2.jwt.JwtEncoder; -import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; - -import java.nio.charset.StandardCharsets; - -/** 国内现有 HS256 编码器,始终为认证服务主编码器。 */ -@Configuration(proxyBeanMethods = false) -public class DomesticJwtConfiguration { - @Bean("jwtEncoder") - @Primary - JwtEncoder jwtEncoder(AuthSecurityProperties properties) { - byte[] secret = properties.jwtSecret().getBytes(StandardCharsets.UTF_8); - if (secret.length < 32) throw new IllegalStateException("JWT_SECRET 至少需要 32 字节"); - return new NimbusJwtEncoder(new ImmutableSecret<>(secret)); - } -} diff --git a/auth/src/main/java/com/baiyue/auth/config/OverseasJwtProperties.java b/auth/src/main/java/com/baiyue/auth/config/OverseasJwtProperties.java deleted file mode 100644 index 62e173c7f..000000000 --- a/auth/src/main/java/com/baiyue/auth/config/OverseasJwtProperties.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.baiyue.auth.config; - -import org.springframework.boot.context.properties.ConfigurationProperties; - -import java.time.Duration; - -/** 海外尾程 RS256 短时令牌配置,默认关闭。 */ -@ConfigurationProperties(prefix = "baiyue.security.overseas-jwt") -public record OverseasJwtProperties(boolean enabled, String issuer, String audience, - String rsaJwkSet, String currentKid, Duration accessTokenTtl) { } diff --git a/auth/src/main/java/com/baiyue/auth/config/OverseasJwtConfiguration.java b/auth/src/main/java/com/baiyue/auth/config/UnifiedJwtConfiguration.java similarity index 67% rename from auth/src/main/java/com/baiyue/auth/config/OverseasJwtConfiguration.java rename to auth/src/main/java/com/baiyue/auth/config/UnifiedJwtConfiguration.java index ed7d60faa..37c346b4d 100644 --- a/auth/src/main/java/com/baiyue/auth/config/OverseasJwtConfiguration.java +++ b/auth/src/main/java/com/baiyue/auth/config/UnifiedJwtConfiguration.java @@ -3,7 +3,6 @@ package com.baiyue.auth.config; import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.RSAKey; import com.nimbusds.jose.jwk.source.ImmutableJWKSet; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -11,27 +10,26 @@ import org.springframework.security.oauth2.jwt.JwtEncoder; import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; @Configuration(proxyBeanMethods = false) -@EnableConfigurationProperties(OverseasJwtProperties.class) -@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true") -public class OverseasJwtConfiguration { - @Bean("overseasJwtEncoder") - JwtEncoder overseasJwtEncoder(OverseasJwtProperties properties) { +@EnableConfigurationProperties(UnifiedJwtProperties.class) +public class UnifiedJwtConfiguration { + @Bean("jwtEncoder") + JwtEncoder jwtEncoder(UnifiedJwtProperties properties) { try { JWKSet set = JWKSet.parse(properties.rsaJwkSet()); RSAKey current = set.getKeyByKeyId(properties.currentKid()) instanceof RSAKey rsa ? rsa : null; if (current == null || !current.isPrivate()) { - throw new IllegalStateException("海外 current-kid 必须指向包含私钥的 RSA 密钥"); + throw new IllegalStateException("统一 current-kid 必须指向包含私钥的 RSA 密钥"); } if (set.getKeys().stream().anyMatch(jwk -> !(jwk instanceof RSAKey))) { - throw new IllegalStateException("海外 JWK Set 只能包含 RSA 密钥"); + throw new IllegalStateException("统一 JWK Set 只能包含 RSA 密钥"); } if (set.getKeys().stream().anyMatch(jwk -> !properties.currentKid().equals(jwk.getKeyID()) && jwk instanceof RSAKey rsa && rsa.isPrivate())) { - throw new IllegalStateException("海外非当前密钥只能配置公钥"); + throw new IllegalStateException("统一非当前密钥只能配置公钥"); } return new NimbusJwtEncoder(new ImmutableJWKSet<>(set)); } catch (java.text.ParseException exception) { - throw new IllegalStateException("海外 RSA JWK Set 配置无效", exception); + throw new IllegalStateException("统一 RSA JWK Set 配置无效", exception); } } } diff --git a/auth/src/main/java/com/baiyue/auth/config/UnifiedJwtProperties.java b/auth/src/main/java/com/baiyue/auth/config/UnifiedJwtProperties.java new file mode 100644 index 000000000..265f9d4a2 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/config/UnifiedJwtProperties.java @@ -0,0 +1,7 @@ +package com.baiyue.auth.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** Unified RS256 signing key configuration. Private keys remain in Auth. */ +@ConfigurationProperties(prefix = "baiyue.security.jwt") +public record UnifiedJwtProperties(String rsaJwkSet, String currentKid) { } diff --git a/auth/src/main/java/com/baiyue/auth/controller/AuthController.java b/auth/src/main/java/com/baiyue/auth/controller/AuthController.java index 6130bf029..a4fd65b75 100644 --- a/auth/src/main/java/com/baiyue/auth/controller/AuthController.java +++ b/auth/src/main/java/com/baiyue/auth/controller/AuthController.java @@ -23,8 +23,6 @@ import com.baiyue.auth.dto.AccountManagementDtos.ChangePasswordRequest; import com.baiyue.auth.dto.AccountManagementDtos.ForcedPasswordChangeRequest; import com.baiyue.auth.dto.AccountManagementDtos; import com.baiyue.auth.service.AccountManagementService; -import com.baiyue.auth.service.OverseasTokenService; -import org.springframework.beans.factory.ObjectProvider; import org.springframework.security.access.prepost.PreAuthorize; /** @@ -40,19 +38,6 @@ public class AuthController { private MerchantRegistrationService registrationService; @Resource private AccountManagementService accountManagementService; - @Resource - private ObjectProvider overseasTokens; - - @PostMapping("/overseas/token") - @PreAuthorize("isAuthenticated()") - @Operation(summary = "兑换海外尾程访问令牌", description = "使用已通过国内会话校验的身份兑换 15 分钟 RS256 海外令牌。") - public ApiResponse exchangeOverseasToken( - @AuthenticationPrincipal AuthenticatedUser user) { - OverseasTokenService service = overseasTokens.getIfAvailable(); - if (service == null) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "海外认证未启用"); - return ApiResponse.success(service.exchange(user.userId())); - } - @PutMapping("/password") @PreAuthorize("isAuthenticated()") @Operation(summary = "修改当前用户密码") diff --git a/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java b/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java index 966201676..922292fb6 100644 --- a/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java +++ b/auth/src/main/java/com/baiyue/auth/controller/InternalAccountController.java @@ -3,15 +3,12 @@ package com.baiyue.auth.controller; import com.baiyue.auth.dto.AccountManagementDtos.*; import com.baiyue.auth.service.AccountManagementService; import com.baiyue.auth.service.AuthService; -import com.baiyue.auth.service.OverseasSessionValidationService; -import com.baiyue.auth.dto.OverseasSessionValidationDtos; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import jakarta.annotation.PostConstruct; import jakarta.annotation.Resource; import jakarta.validation.Valid; -import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.*; @@ -22,14 +19,13 @@ import java.security.MessageDigest; /** 仅供可信内部服务调用的账号管理接口。 */ @RestController @RequestMapping("/internal/auth/accounts") -@Tag(name = "Internal Auth / 内部认证", description = "Service-token protected internal account and overseas PDA session APIs") +@Tag(name = "Internal Auth / 内部认证", description = "Service-token protected internal account and unified session APIs") public class InternalAccountController { @Resource private AccountManagementService service; @Resource private AuthService authService; - @Resource - private OverseasSessionValidationService overseasSessionValidationService; + @Resource private com.baiyue.auth.service.SessionStateService sessionStateService; @Value("${baiyue.security.internal-service-token}") private String configuredToken; @@ -71,15 +67,17 @@ public class InternalAccountController { @Valid @RequestBody RevokeAccountSessionsRequest request) { verify(token); service.revokeSessions(request.accountIds()); return ApiResponse.success(null); } - @PostMapping("/overseas-session/validate") - @Operation(summary = "Validate verified overseas PDA claims / 校验已验签的海外PDA声明", - description = "Accepts only claims extracted after Gateway JWT verification; raw tokens and user credentials are forbidden.") - public ApiResponse validateOverseasSession( + public record SessionValidationRequest(@jakarta.validation.constraints.NotBlank String jwtId, + @jakarta.validation.constraints.NotNull Long userId) { } + + @PostMapping("/sessions/validate") + public ApiResponse validateSession( @RequestHeader(value="X-Internal-Service-Token", required=false) String token, - @Valid @RequestBody OverseasSessionValidationDtos.Request request) { + @Valid @RequestBody SessionValidationRequest request) { verify(token); - return ApiResponse.success(overseasSessionValidationService.validate(request)); + return ApiResponse.success(sessionStateService.validate(request.jwtId(), request.userId())); } + @PutMapping("/{id}/profile") public ApiResponse profile(@RequestHeader(value="X-Internal-Service-Token", required=false) String token, @PathVariable Long id, @Valid @RequestBody UpdateAccountProfileRequest request) { diff --git a/auth/src/main/java/com/baiyue/auth/controller/JwksController.java b/auth/src/main/java/com/baiyue/auth/controller/JwksController.java index f8fa9487e..136712e26 100644 --- a/auth/src/main/java/com/baiyue/auth/controller/JwksController.java +++ b/auth/src/main/java/com/baiyue/auth/controller/JwksController.java @@ -1,10 +1,9 @@ package com.baiyue.auth.controller; -import com.baiyue.auth.config.OverseasJwtProperties; +import com.baiyue.auth.config.UnifiedJwtProperties; import com.nimbusds.jose.jwk.JWKSet; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirements; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.CacheControl; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -13,17 +12,16 @@ import org.springframework.web.bind.annotation.RestController; import java.util.Map; import java.util.concurrent.TimeUnit; -/** 仅在海外 JWT 显式启用后发布 RSA 公钥。 */ +/** 发布统一 JWT 的 RSA 公钥。 */ @RestController -@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true") public class JwksController { - private final OverseasJwtProperties properties; + private final UnifiedJwtProperties properties; - public JwksController(OverseasJwtProperties properties) { this.properties = properties; } + public JwksController(UnifiedJwtProperties properties) { this.properties = properties; } @GetMapping("/api/auth/.well-known/jwks.json") @SecurityRequirements - @Operation(summary = "获取海外 JWT 公钥集", description = "仅发布当前和轮换期上一把 RSA 公钥,不包含私钥。") + @Operation(summary = "获取统一 JWT 公钥集", description = "仅发布当前和轮换期上一把 RSA 公钥,不包含私钥。") public ResponseEntity> jwks() throws Exception { return ResponseEntity.ok().cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePublic()) .body(JWKSet.parse(properties.rsaJwkSet()).toPublicJWKSet().toJSONObject()); diff --git a/auth/src/main/java/com/baiyue/auth/dto/OverseasSessionValidationDtos.java b/auth/src/main/java/com/baiyue/auth/dto/OverseasSessionValidationDtos.java deleted file mode 100644 index 2ab9a5f3f..000000000 --- a/auth/src/main/java/com/baiyue/auth/dto/OverseasSessionValidationDtos.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.baiyue.auth.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; - -/** 海外 PDA 已验签身份的实时会话校验契约;绝不接收 raw JWT 或用户凭据。 */ -public final class OverseasSessionValidationDtos { - private OverseasSessionValidationDtos() { } - - @Schema(name = "OverseasSessionValidationRequest", - description = "Claims extracted only after overseas Gateway verifies the JWT signature") - public record Request( - @NotNull Long userId, - @NotNull Integer tokenVersion, - Long overseasWarehouseId) { } - - @Schema(name = "OverseasSessionValidationResponse") - public record Response(boolean valid, FailureReason failureReason) { - public static Response accepted() { return new Response(true, null); } - public static Response rejected(FailureReason reason) { return new Response(false, reason); } - } - - public enum FailureReason { - ACCOUNT_INVALID, - TOKEN_VERSION_MISMATCH, - WAREHOUSE_UNBOUND, - WAREHOUSE_MISMATCH - } -} diff --git a/auth/src/main/java/com/baiyue/auth/dto/TokenResponse.java b/auth/src/main/java/com/baiyue/auth/dto/TokenResponse.java index aa86d983c..9d5af3cc9 100644 --- a/auth/src/main/java/com/baiyue/auth/dto/TokenResponse.java +++ b/auth/src/main/java/com/baiyue/auth/dto/TokenResponse.java @@ -14,10 +14,6 @@ public record TokenResponse( @Schema(description = "Access token lifetime in seconds / 访问令牌有效秒数", example = "1800") long expiresIn, @Schema(description = "Refresh token / 刷新令牌", format = "password") String refreshToken, @Schema(description = "Refresh token lifetime in seconds / 刷新令牌有效秒数", example = "604800") long refreshExpiresIn, - @Schema(description = "Short-lived overseas API access token; null only when overseas JWT is disabled / 海外 API 短时访问令牌;仅海外 JWT 未启用时为空", - format = "password", nullable = true) String overseasAccessToken, - @Schema(description = "Overseas access token lifetime in seconds; zero only when overseas JWT is disabled / 海外访问令牌有效秒数;仅海外 JWT 未启用时为 0", - example = "900", minimum = "0") long overseasExpiresIn, @Schema(description = "Current role snapshot returned outside JWT / JWT 外返回的当前角色快照") Set roles, @Schema(description = "Current authority snapshot returned outside JWT / JWT 外返回的当前权限快照") Set authorities ) { diff --git a/auth/src/main/java/com/baiyue/auth/repository/AuthAccountRepository.java b/auth/src/main/java/com/baiyue/auth/repository/AuthAccountRepository.java index 636a1efe0..6e61ad475 100644 --- a/auth/src/main/java/com/baiyue/auth/repository/AuthAccountRepository.java +++ b/auth/src/main/java/com/baiyue/auth/repository/AuthAccountRepository.java @@ -19,13 +19,13 @@ public class AuthAccountRepository { public AuthAccount findByUsername(String username) { return mapper.selectOne(new LambdaQueryWrapper() .eq(AuthAccount::getUsername, username) - .last("LIMIT 1")); + .last("LIMIT 1 FOR UPDATE")); } public AuthAccount findByPhone(String phone) { return mapper.selectOne(new LambdaQueryWrapper() .eq(AuthAccount::getPhone, phone) - .last("LIMIT 1")); + .last("LIMIT 1 FOR UPDATE")); } public boolean existsByUsername(String username) { @@ -53,13 +53,15 @@ public class AuthAccountRepository { public void insert(AuthAccount account) { mapper.insert(account); } public AuthAccount findById(Long id) { - return mapper.selectById(id); + return mapper.selectOne(new LambdaQueryWrapper().eq(AuthAccount::getId, id).last("FOR UPDATE")); } + public AuthAccount findCurrentById(Long id) { return mapper.selectById(id); } + public List findMerchantByTenant(Long tenantId) { return mapper.selectList(new LambdaQueryWrapper() .eq(AuthAccount::getDefaultTenantId, tenantId) - .eq(AuthAccount::getDefaultAppCode, "MERCHANT")); + .eq(AuthAccount::getDefaultAppCode, "MERCHANT").orderByAsc(AuthAccount::getId).last("FOR UPDATE")); } public void save(AuthAccount account) { diff --git a/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java b/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java index 6c64184e1..5f4fdc138 100644 --- a/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java +++ b/auth/src/main/java/com/baiyue/auth/service/AccountManagementService.java @@ -9,6 +9,7 @@ import jakarta.annotation.Resource; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.dao.DuplicateKeyException; import com.baiyue.common.audit.BusinessAudit; import java.util.Locale; @@ -63,7 +64,9 @@ public class AccountManagementService { String appCode = request.appCode() == null ? "MERCHANT" : request.appCode().trim().toUpperCase(Locale.ROOT); validateScope(appCode, request.tenantId()); - AuthAccount existing = repository.findById(request.accountId()); + // Locking a missing key takes an InnoDB gap lock and can deadlock + // unrelated account inserts. The unique indexes arbitrate creation. + AuthAccount existing = repository.findCurrentById(request.accountId()); if (existing != null) { if (sameIdentity(existing, request, appCode)) return summary(existing); throw new BizException(ResultCode.CONFLICT, "认证账号标识已被其他成员使用"); @@ -88,7 +91,13 @@ public class AccountManagementService { account.setDefaultMembershipId(request.membershipId()); account.setDefaultAppCode(appCode); account.setDeleted(0); - repository.insert(account); + try { + repository.insert(account); + } catch (DuplicateKeyException duplicate) { + AuthAccount winner = repository.findById(request.accountId()); + if (winner != null && sameIdentity(winner, request, appCode)) return summary(winner); + throw new BizException(ResultCode.CONFLICT, "认证账号标识或登录账号已被使用"); + } return summary(account); } @@ -108,7 +117,7 @@ public class AccountManagementService { resourceId = "'RBAC_CHANGE'", description = "RBAC 变更批量撤销账号会话", captureBefore = false, captureAfter = false) public void revokeSessions(Set accountIds) { - accountIds.forEach(accountId -> bumpVersion(required(accountId))); + accountIds.stream().sorted().forEach(accountId -> bumpVersion(required(accountId))); } @Transactional diff --git a/auth/src/main/java/com/baiyue/auth/service/AuthService.java b/auth/src/main/java/com/baiyue/auth/service/AuthService.java index 15144bf9f..36c987ec2 100644 --- a/auth/src/main/java/com/baiyue/auth/service/AuthService.java +++ b/auth/src/main/java/com/baiyue/auth/service/AuthService.java @@ -16,14 +16,14 @@ import com.baiyue.common.core.exception.BizException; import jakarta.annotation.Resource; import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.oauth2.jose.jws.MacAlgorithm; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; import org.springframework.security.oauth2.jwt.JwtClaimsSet; import org.springframework.security.oauth2.jwt.JwtEncoder; import org.springframework.security.oauth2.jwt.JwtEncoderParameters; import org.springframework.security.oauth2.jwt.JwsHeader; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.beans.factory.ObjectProvider; +import com.baiyue.auth.config.UnifiedJwtProperties; import java.security.SecureRandom; import java.time.Instant; @@ -51,7 +51,7 @@ public class AuthService { @Resource private TokenSessionStore tokenSessionStore; @Resource - private ObjectProvider overseasTokens; + private UnifiedJwtProperties signingProperties; private final SecureRandom secureRandom = new SecureRandom(); @@ -92,7 +92,8 @@ public class AuthService { AuthAccount account = accountRepository.findById(session.userId()); if (account == null || !"ACTIVE".equals(account.getAccountStatus())) throw new BizException(ResultCode.UNAUTHORIZED, "账号不可用"); - return issue(account); + if (session.fingerprint() == null) throw new BizException(ResultCode.UNAUTHORIZED, "刷新会话缺少授权状态"); + return issue(account, session.fingerprint()); } @Transactional @@ -109,8 +110,13 @@ public class AuthService { accountRepository.save(account); } - private TokenResponse issue(AuthAccount account) { + private TokenResponse issue(AuthAccount account) { return issue(account, null); } + + private TokenResponse issue(AuthAccount account, String expectedState) { AuthorizationSnapshot authorization = authorizationLoader.load(account); + if (expectedState != null && !expectedState.equals(SessionStateService.fingerprint(account, authorization))) { + throw new BizException(ResultCode.UNAUTHORIZED, "授权状态已变更,请重新登录"); + } Instant issuedAt = Instant.now(); String jwtId = UUID.randomUUID().toString(); Instant expiresAt = issuedAt.plus(properties.accessTokenTtl()); @@ -145,15 +151,11 @@ public class AuthService { byte[] random = new byte[48]; secureRandom.nextBytes(random); String rawRefresh = Base64.getUrlEncoder().withoutPadding().encodeToString(random); - tokenSessionStore.store(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(), rawRefresh, + tokenSessionStore.store(jwtId, account.getId(), authorization.getRoles(), authorization.getPermissions(), + SessionStateService.fingerprint(account, authorization), rawRefresh, properties.accessTokenTtl(), properties.refreshTokenTtl()); - OverseasTokenService service = overseasTokens.getIfAvailable(); - OverseasTokenService.OverseasTokenResponse overseas = service == null - ? null : service.issue(account, authorization); return new TokenResponse("Bearer", accessToken, properties.accessTokenTtl().toSeconds(), rawRefresh, properties.refreshTokenTtl().toSeconds(), - overseas == null ? null : overseas.accessToken(), - overseas == null ? 0L : overseas.expiresIn(), authorization.getRoles(), authorization.getPermissions()); } @@ -207,18 +209,12 @@ public class AuthService { .claim("password_change_required", Boolean.TRUE.equals(account.getPasswordChangeRequired())).build(); String accessToken = jwtEncoder.encode(JwtEncoderParameters.from(signingHeader(), claims)).getTokenValue(); tokenSessionStore.storeAccessOnly(jwtId, account.getId(), authorization.getRoles(), - authorization.getPermissions(), Duration.ofMinutes(15)); - OverseasTokenService service = overseasTokens.getIfAvailable(); - OverseasTokenService.OverseasTokenResponse overseas = service == null - ? null : service.issue(account, authorization); + authorization.getPermissions(), SessionStateService.fingerprint(account, authorization), Duration.ofMinutes(15)); return new ImpersonationTokenResponse("Bearer", accessToken, 900, - overseas == null ? null : overseas.accessToken(), - overseas == null ? 0L : overseas.expiresIn(), authorization.getRoles(), authorization.getPermissions()); } public record ImpersonationTokenResponse(String tokenType, String accessToken, long expiresIn, - String overseasAccessToken, long overseasExpiresIn, java.util.Set roles, java.util.Set authorities) { public ImpersonationTokenResponse { @@ -228,7 +224,7 @@ public class AuthService { } private JwsHeader signingHeader() { - return JwsHeader.with(MacAlgorithm.HS256).build(); + return JwsHeader.with(SignatureAlgorithm.RS256).keyId(signingProperties.currentKid()).build(); } private static BizException invalidCredentials() { diff --git a/auth/src/main/java/com/baiyue/auth/service/OverseasSessionValidationService.java b/auth/src/main/java/com/baiyue/auth/service/OverseasSessionValidationService.java deleted file mode 100644 index 0f566a290..000000000 --- a/auth/src/main/java/com/baiyue/auth/service/OverseasSessionValidationService.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.baiyue.auth.service; - -import com.baiyue.auth.client.AuthorizationLoader; -import com.baiyue.auth.dto.OverseasSessionValidationDtos.FailureReason; -import com.baiyue.auth.dto.OverseasSessionValidationDtos.Request; -import com.baiyue.auth.dto.OverseasSessionValidationDtos.Response; -import com.baiyue.auth.entity.AuthAccount; -import com.baiyue.auth.repository.AuthAccountRepository; -import jakarta.annotation.Resource; -import org.springframework.stereotype.Service; - -import java.util.Objects; - -/** 实时核对海外 PDA token 的账号版本与当前 IAM 仓绑定。 */ -@Service -public class OverseasSessionValidationService { - @Resource private AuthAccountRepository accounts; - @Resource private AuthorizationLoader authorizationLoader; - - public Response validate(Request request) { - AuthAccount account = accounts.findById(request.userId()); - if (account == null || !"ACTIVE".equals(account.getAccountStatus())) { - return Response.rejected(FailureReason.ACCOUNT_INVALID); - } - int currentVersion = account.getTokenVersion() == null ? 0 : account.getTokenVersion(); - if (currentVersion != request.tokenVersion()) { - return Response.rejected(FailureReason.TOKEN_VERSION_MISMATCH); - } - Long currentWarehouseId = authorizationLoader.load(account).getOverseasWarehouseId(); - if (currentWarehouseId == null || request.overseasWarehouseId() == null) { - return Response.rejected(FailureReason.WAREHOUSE_UNBOUND); - } - if (!Objects.equals(currentWarehouseId, request.overseasWarehouseId())) { - return Response.rejected(FailureReason.WAREHOUSE_MISMATCH); - } - return Response.accepted(); - } -} diff --git a/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java b/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java deleted file mode 100644 index 5c90d612e..000000000 --- a/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.baiyue.auth.service; - -import com.baiyue.auth.client.AuthorizationLoader; -import com.baiyue.auth.client.AuthorizationSnapshot; -import com.baiyue.auth.config.OverseasJwtProperties; -import com.baiyue.auth.entity.AuthAccount; -import com.baiyue.auth.repository.AuthAccountRepository; -import com.baiyue.common.core.api.ResultCode; -import com.baiyue.common.core.exception.BizException; -import jakarta.annotation.Resource; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; -import org.springframework.security.oauth2.jwt.JwtClaimsSet; -import org.springframework.security.oauth2.jwt.JwtEncoder; -import org.springframework.security.oauth2.jwt.JwtEncoderParameters; -import org.springframework.security.oauth2.jwt.JwsHeader; -import org.springframework.stereotype.Service; - -import java.time.Instant; -import java.util.List; -import java.util.UUID; - -/** 将已经通过国内 HMAC 与 Redis 校验的会话兑换为海外 RS256 短时令牌。 */ -@Service -@ConditionalOnProperty(name = "baiyue.security.overseas-jwt.enabled", havingValue = "true") -public class OverseasTokenService { - @Resource private AuthAccountRepository accounts; - @Resource private AuthorizationLoader authorizationLoader; - @Resource private OverseasJwtProperties properties; - @Resource @Qualifier("overseasJwtEncoder") private JwtEncoder encoder; - - public OverseasTokenResponse exchange(long authenticatedUserId) { - AuthAccount account = accounts.findById(authenticatedUserId); - if (account == null || !"ACTIVE".equals(account.getAccountStatus())) { - throw new BizException(ResultCode.UNAUTHORIZED, "账号不可用"); - } - AuthorizationSnapshot authorization = authorizationLoader.load(account); - return issue(account, authorization); - } - - public OverseasTokenResponse issue(AuthAccount account, AuthorizationSnapshot authorization) { - Instant issuedAt = Instant.now(); - Instant expiresAt = issuedAt.plus(properties.accessTokenTtl()); - JwtClaimsSet.Builder claimsBuilder = JwtClaimsSet.builder() - .issuer(properties.issuer()).audience(List.of(properties.audience())) - .subject(account.getId().toString()).id(UUID.randomUUID().toString()) - .issuedAt(issuedAt).expiresAt(expiresAt) - .claim("user_id", account.getId()).claim("tenant_id", authorization.getTenantId()) - .claim("membership_id", authorization.getMembershipId()) - .claim("app_code", authorization.getAppCode()).claim("client_type", authorization.getClientType()) - .claim("token_version", account.getTokenVersion() == null ? 0 : account.getTokenVersion()) - .claim("permission_version", authorization.getPermissionVersion() == null ? 0L : authorization.getPermissionVersion()); - if (authorization.getOverseasWarehouseId() != null) { - claimsBuilder.claim("overseas_warehouse_id", authorization.getOverseasWarehouseId()); - } - JwtClaimsSet claims = claimsBuilder.build(); - String token = encoder.encode(JwtEncoderParameters.from( - JwsHeader.with(SignatureAlgorithm.RS256).keyId(properties.currentKid()).build(), claims)).getTokenValue(); - return new OverseasTokenResponse("Bearer", token, properties.accessTokenTtl().toSeconds(), - authorization.getRoles(), authorization.getPermissions()); - } - - public record OverseasTokenResponse(String tokenType, String accessToken, long expiresIn, - java.util.Set roles, java.util.Set authorities) { - public OverseasTokenResponse { - roles = roles == null ? java.util.Set.of() : java.util.Set.copyOf(roles); - authorities = authorities == null ? java.util.Set.of() : java.util.Set.copyOf(authorities); - } - } -} diff --git a/auth/src/main/java/com/baiyue/auth/service/SessionStateService.java b/auth/src/main/java/com/baiyue/auth/service/SessionStateService.java new file mode 100644 index 000000000..69b7480f5 --- /dev/null +++ b/auth/src/main/java/com/baiyue/auth/service/SessionStateService.java @@ -0,0 +1,63 @@ +package com.baiyue.auth.service; + +import com.baiyue.auth.client.AuthorizationLoader; +import com.baiyue.auth.client.AuthorizationSnapshot; +import com.baiyue.auth.entity.AuthAccount; +import com.baiyue.auth.repository.AuthAccountRepository; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.TreeSet; + +/** No cache: a validation started after a security commit must observe that commit. */ +@Service +public class SessionStateService { + private static final ObjectMapper FINGERPRINT_JSON = new ObjectMapper(); + @Resource private AuthAccountRepository accounts; + @Resource private AuthorizationLoader authorizationLoader; + @Resource private TokenSessionStore sessions; + + public static String fingerprint(AuthAccount account, AuthorizationSnapshot snapshot) { + try { + if (account.getTokenVersion() == null || snapshot.getPermissionVersion() == null + || snapshot.getTenantVersion() == null) throw new IllegalArgumentException("Missing persisted security version"); + // Ordered JSON array avoids set ordering and delimiter ambiguities. + Object[] state = {account.getId(), account.getTokenVersion(), account.getDefaultTenantId(), + account.getDefaultMembershipId(), account.getDefaultAppCode(), account.getOperatorTenantId(), + snapshot.getPermissionVersion(), snapshot.getTenantVersion(), snapshot.getClientType(), + snapshot.getOverseasWarehouseId(), snapshot.getDomesticWarehouseId(), + new TreeSet<>(snapshot.getRoles()), new TreeSet<>(snapshot.getPermissions())}; + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(FINGERPRINT_JSON.writeValueAsBytes(state))); + } catch (Exception failure) { + throw new IllegalStateException("Cannot fingerprint authorization state", failure); + } + } + + private void requireCurrent(Long userId, String expected) { + AuthAccount account = accounts.findCurrentById(userId); + if (account == null || !"ACTIVE".equals(account.getAccountStatus()) + || Integer.valueOf(1).equals(account.getDeleted()) || expected == null + || !expected.equals(fingerprint(account, authorizationLoader.load(account)))) { + throw new BizException(ResultCode.UNAUTHORIZED, "登录授权已变更,请重新登录"); + } + } + + @Transactional(readOnly = true, propagation = Propagation.REQUIRES_NEW, + isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) + public boolean validate(String jwtId, Long userId) { + TokenSessionStore.SessionState state = sessions.loadState(jwtId); + if (state == null || !state.userId().equals(userId)) { + throw new BizException(ResultCode.UNAUTHORIZED, "登录会话已失效"); + } + requireCurrent(userId, state.fingerprint()); + return true; + } +} diff --git a/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java b/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java index fbfefb41b..5fb10f1b1 100644 --- a/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java +++ b/auth/src/main/java/com/baiyue/auth/service/TokenSessionStore.java @@ -12,8 +12,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.dao.DataAccessException; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.SetOperations; -import org.springframework.data.redis.core.Cursor; -import org.springframework.data.redis.core.ScanOptions; import org.springframework.stereotype.Component; import java.nio.charset.StandardCharsets; @@ -21,8 +19,6 @@ import java.security.MessageDigest; import java.time.Duration; import java.util.HexFormat; import java.util.Set; -import java.util.ArrayList; -import java.util.List; /** * 认证会话存储服务,使用 Redis 保存和一次性消费刷新会话,支持刷新令牌轮换及主动撤销。 @@ -42,7 +38,7 @@ public class TokenSessionStore { } public void store(String jwtId, Long userId, Set roles, Set permissions, - String rawRefreshToken, Duration accessTtl, Duration refreshTtl) { + String fingerprint, String rawRefreshToken, Duration accessTtl, Duration refreshTtl) { String accessKey = RedisTokenKeys.access(keyPrefix, jwtId); String refreshKey = RedisTokenKeys.refresh(keyPrefix, sha256(rawRefreshToken)); String userKey = userSessionsKey(userId); @@ -50,25 +46,27 @@ public class TokenSessionStore { String accessValue = objectMapper.writeValueAsString( new AccessSession(1, userId, normalized(roles), normalized(permissions))); redisTemplate.opsForValue().set(accessKey, accessValue, accessTtl); - String refreshValue = objectMapper.writeValueAsString(new RefreshSession(userId, jwtId)); + storeState(jwtId, userId, fingerprint, accessTtl); + String refreshValue = objectMapper.writeValueAsString(new RefreshSession(userId, jwtId, fingerprint)); redisTemplate.opsForValue().set(refreshKey, refreshValue, refreshTtl); SetOperations sets = redisTemplate.opsForSet(); - if (sets != null) sets.add(userKey, accessKey, refreshKey); + if (sets != null) sets.add(userKey, accessKey, refreshKey, stateKey(jwtId)); redisTemplate.expire(userKey, refreshTtl); } catch (Exception exception) { - try { redisTemplate.delete(Set.of(accessKey, refreshKey, userKey)); } catch (DataAccessException ignored) { } + try { redisTemplate.delete(Set.of(accessKey, refreshKey, stateKey(jwtId))); } catch (DataAccessException ignored) { } throw unavailable(exception); } } public void storeAccessOnly(String jwtId, Long userId, Set roles, - Set permissions, Duration ttl) { + Set permissions, String fingerprint, Duration ttl) { try { String accessKey = RedisTokenKeys.access(keyPrefix, jwtId); String accessValue = objectMapper.writeValueAsString( new AccessSession(1, userId, normalized(roles), normalized(permissions))); redisTemplate.opsForValue().set(accessKey, accessValue, ttl); - redisTemplate.opsForSet().add(userSessionsKey(userId), accessKey); + storeState(jwtId, userId, fingerprint, ttl); + redisTemplate.opsForSet().add(userSessionsKey(userId), accessKey, stateKey(jwtId)); redisTemplate.expire(userSessionsKey(userId), ttl); } catch (Exception exception) { throw unavailable(exception); @@ -103,6 +101,7 @@ public class TokenSessionStore { if (value == null || value.isBlank()) return null; RefreshSession session = objectMapper.readValue(value, RefreshSession.class); redisTemplate.delete(RedisTokenKeys.access(keyPrefix, session.accessJwtId())); + redisTemplate.delete(stateKey(session.accessJwtId())); return session; } catch (DataAccessException exception) { throw unavailable(exception); @@ -121,43 +120,26 @@ public class TokenSessionStore { try { Set keys = redisTemplate.opsForSet().members(userKey); if (keys != null && !keys.isEmpty()) redisTemplate.delete(keys); - // 兼容功能上线前尚未写入用户会话索引的存量令牌,SCAN 不阻塞 Redis 主线程。 - List legacyKeys = new ArrayList<>(); - collectLegacyKeys(keyPrefix + ":access:*", userId, legacyKeys, false); - collectLegacyKeys(keyPrefix + ":refresh:*", userId, legacyKeys, true); - if (!legacyKeys.isEmpty()) redisTemplate.delete(legacyKeys); redisTemplate.delete(userKey); } catch (DataAccessException exception) { throw unavailable(exception); } } - private void collectLegacyKeys(String pattern, Long userId, List result, boolean refresh) { - Cursor cursor = redisTemplate.scan(ScanOptions.scanOptions().match(pattern).count(500).build()); - if (cursor == null) return; - try (cursor) { - while (cursor.hasNext()) { - String key = cursor.next(); - String value = redisTemplate.opsForValue().get(key); - if ((!refresh && accessBelongsTo(value, userId)) || (refresh && belongsTo(value, userId))) { - result.add(key); - } - } - } catch (Exception exception) { - throw unavailable(exception); - } + private String stateKey(String jwtId) { return keyPrefix + ":state:" + jwtId; } + + private void storeState(String jwtId, Long userId, String fingerprint, Duration ttl) throws Exception { + if (fingerprint == null || fingerprint.isBlank()) throw new IllegalArgumentException("Missing security state"); + redisTemplate.opsForValue().set(stateKey(jwtId), + objectMapper.writeValueAsString(new SessionState(userId, fingerprint)), ttl); } - private boolean belongsTo(String value, Long userId) { - if (value == null || value.isBlank()) return false; - try { return userId.equals(objectMapper.readValue(value, RefreshSession.class).userId()); } - catch (Exception ignored) { return false; } - } - - private boolean accessBelongsTo(String value, Long userId) { - if (value == null || value.isBlank()) return false; - try { return userId.equals(objectMapper.readValue(value, AccessSession.class).userId()); } - catch (Exception ignored) { return false; } + public SessionState loadState(String jwtId) { + try { + if (!Boolean.TRUE.equals(redisTemplate.hasKey(RedisTokenKeys.access(keyPrefix, jwtId)))) return null; + String value = redisTemplate.opsForValue().get(stateKey(jwtId)); + return value == null ? null : objectMapper.readValue(value, SessionState.class); + } catch (Exception failure) { throw unavailable(failure); } } private static Set normalized(Set values) { @@ -182,7 +164,8 @@ public class TokenSessionStore { } } - public record RefreshSession(Long userId, String accessJwtId) { } + public record SessionState(Long userId, String fingerprint) { } + public record RefreshSession(Long userId, String accessJwtId, String fingerprint) { } public record AccessSession(int version, Long userId, Set roles, Set permissions) { public AccessSession { roles = normalized(roles); diff --git a/auth/src/main/resources/application-dev.yml b/auth/src/main/resources/application-dev.yml deleted file mode 100644 index 1c0753a99..000000000 --- a/auth/src/main/resources/application-dev.yml +++ /dev/null @@ -1,10 +0,0 @@ -spring: - cloud: - nacos: - discovery: - ip: ${NACOS_DISCOVERY_IP:127.0.0.1} - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${AUTH_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${AUTH_DB_USERNAME:root} - password: ${AUTH_DB_PASSWORD:123456} diff --git a/auth/src/main/resources/application-test.yml b/auth/src/main/resources/application-test.yml deleted file mode 100644 index 07c7f96b5..000000000 --- a/auth/src/main/resources/application-test.yml +++ /dev/null @@ -1,15 +0,0 @@ -spring: - cloud: - nacos: - config: - namespace: ${NACOS_NAMESPACE:test} - group: ${NACOS_GROUP:BAIYUE} - encode: UTF-8 - discovery: - namespace: ${NACOS_NAMESPACE:test} - group: ${NACOS_GROUP:BAIYUE} - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${AUTH_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true} - username: ${AUTH_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}} - password: ${AUTH_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}} diff --git a/auth/src/main/resources/application.yml b/auth/src/main/resources/application.yml index fd346398e..6acb857eb 100644 --- a/auth/src/main/resources/application.yml +++ b/auth/src/main/resources/application.yml @@ -2,36 +2,35 @@ server: port: ${AUTH_PORT:9100} spring: profiles: - active: ${SPRING_PROFILES_ACTIVE:dev} + active: ${SPRING_PROFILES_ACTIVE:test} application: name: auth-service config: import: - - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + - nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true + - nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true data: redis: - host: ${REDIS_HOST:127.0.0.1} + host: ${REDIS_HOST} port: ${REDIS_PORT:6379} password: ${REDIS_PASSWORD:} database: ${REDIS_DATABASE:0} timeout: ${REDIS_TIMEOUT:3s} cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} + server-addr: ${NACOS_SERVER_ADDR} + username: ${NACOS_USERNAME} + password: ${NACOS_PASSWORD} discovery: enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} config: - namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} encode: UTF-8 baiyue.security: - jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} - internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} + internal-service-token: ${INTERNAL_SERVICE_TOKEN} token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth} issuer: ${JWT_ISSUER:auth-service} audience: ${JWT_AUDIENCE:lastleg-api} @@ -39,16 +38,17 @@ baiyue.security: refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d} max-login-failures: ${MAX_LOGIN_FAILURES:5} lock-duration: ${LOGIN_LOCK_DURATION:15m} - overseas-jwt: - enabled: ${OVERSEAS_JWT_ENABLED:false} - issuer: ${OVERSEAS_JWT_ISSUER:auth-service} - audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api} - rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:} - current-kid: ${OVERSEAS_JWT_CURRENT_KID:} - access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m} + jwt-mode: jwks + session-mode: redis + jwk-set-uri: ${JWT_JWKS_URI:http://auth:9100/api/auth/.well-known/jwks.json} + jwt: + rsa-jwk-set: ${JWT_RSA_JWK_SET:} + current-kid: ${JWT_CURRENT_KID:} baiyue.clients: iam: url: ${IAM_SERVICE_URL:} management.endpoints.web.exposure.include: health,info,prometheus + +baiyue.security.session-validation-uri: ${AUTH_SESSION_VALIDATION_URL:http://auth:9100/internal/auth/accounts/sessions/validate} diff --git a/auth/src/main/resources/openapi/auth-internal.yaml b/auth/src/main/resources/openapi/auth-internal.yaml new file mode 100644 index 000000000..4972b5463 --- /dev/null +++ b/auth/src/main/resources/openapi/auth-internal.yaml @@ -0,0 +1,39 @@ +openapi: 3.0.3 +info: + title: Auth internal session validation + version: 1.0.0 +paths: + /internal/auth/accounts/sessions/validate: + post: + summary: Validate persisted account and IAM authorization state + description: Internal network only. No Gateway public route. Verifies the Redis Access session and state fingerprint against current Auth account and a consistent IAM snapshot. No positive cache; missing state, stale state and dependency failure deny access. Does not accept or forward a user Bearer token. + security: + - internalServiceToken: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [jwtId, userId] + properties: + jwtId: {type: string, minLength: 1} + userId: {type: integer, format: int64} + responses: + '200': + description: Only string code 200 and boolean data true constitute success. Business errors deny access. + content: + application/json: + schema: + type: object + required: [code] + properties: + code: {type: string} + message: {type: string} + data: {type: boolean, nullable: true} +components: + securitySchemes: + internalServiceToken: + type: apiKey + in: header + name: X-Internal-Service-Token diff --git a/auth/src/test/java/com/baiyue/auth/client/AuthorizationFeignConfigurationContractTest.java b/auth/src/test/java/com/baiyue/auth/client/AuthorizationFeignConfigurationContractTest.java new file mode 100644 index 000000000..fe67e14bb --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/client/AuthorizationFeignConfigurationContractTest.java @@ -0,0 +1,96 @@ +package com.baiyue.auth.client; + +import feign.Client; +import feign.FeignException; +import feign.Request; +import feign.Response; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.cloud.openfeign.FeignAutoConfiguration; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.cloud.openfeign.FeignClientProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.FileSystemResource; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.*; + +class AuthorizationFeignConfigurationContractTest { + @Test + void actualNamedNacosConfigMatchesContextIdAndOverridesGlobalFeignBudget() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(FeignAutoConfiguration.class, + JacksonAutoConfiguration.class, HttpMessageConvertersAutoConfiguration.class)) + .withUserConfiguration(OfflineFeignConfiguration.class) + .withPropertyValues("baiyue.clients.iam.url=http://127.0.0.1:1") + .withInitializer(context -> { + for (String file : new String[]{"baiyue-common.yml", "auth-service.yml"}) { + Path path = Path.of("config", "nacos", "test", file); + if (!Files.isRegularFile(path)) path = Path.of("..").resolve(path); + try { + // Load real configuration as properties only, without config-data imports. + for (var source : new YamlPropertySourceLoader().load(file, new FileSystemResource(path))) { + context.getEnvironment().getPropertySources().addLast(source); + } + } catch (java.io.IOException failure) { + throw new IllegalStateException("Cannot read actual Auth Feign configuration", failure); + } + } + }) + .run(context -> { + assertThat(context).hasNotFailed(); + String contextId = IamAuthorizationClient.class.getAnnotation(FeignClient.class).contextId(); + var properties = context.getBean(FeignClientProperties.class); + var named = properties.getConfig().get(contextId); + assertThat(named).as("Nacos key must match actual @FeignClient contextId").isNotNull(); + assertThat(named.getConnectTimeout()).isEqualTo(1000); + assertThat(named.getReadTimeout()).isEqualTo(2000); + assertThat(named.isFollowRedirects()).isFalse(); + assertThat(properties.getConfig().get("default").getConnectTimeout()).isEqualTo(3000); + assertThat(properties.getConfig().get("default").getReadTimeout()).isEqualTo(5000); + + var transport = context.getBean(CapturingClient.class); + var client = context.getBean(IamAuthorizationClient.class); + var request = new AuthorizationSnapshotRequest(1L, 7L, 9L, "MERCHANT"); + client.load("offline-service-token", request); + assertThat(transport.options.get().connectTimeoutMillis()).isEqualTo(1000); + assertThat(transport.options.get().readTimeoutMillis()).isEqualTo(2000); + assertThat(transport.options.get().isFollowRedirects()).isFalse(); + + transport.status.set(503); + assertThatThrownBy(() -> client.load("offline-service-token", request)).isInstanceOf(FeignException.class); + assertThat(transport.calls.get()).as("One success plus one failure, without automatic retry").isEqualTo(2); + }); + } + + @Configuration(proxyBeanMethods = false) + @EnableFeignClients(clients = IamAuthorizationClient.class) + static class OfflineFeignConfiguration { + @Bean CapturingClient capturingClient() { return new CapturingClient(); } + } + + /** The real Feign factory builds the client; only network I/O is replaced. */ + static class CapturingClient implements Client { + final AtomicReference options = new AtomicReference<>(); + final AtomicInteger calls = new AtomicInteger(); + final AtomicInteger status = new AtomicInteger(204); + + @Override public Response execute(Request request, Request.Options effectiveOptions) { + options.set(effectiveOptions); + calls.incrementAndGet(); + return Response.builder().request(request).status(status.get()).reason("offline") + .headers(Map.of()).build(); + } + } +} diff --git a/auth/src/test/java/com/baiyue/auth/config/AuthTokenTtlConfigurationContractTest.java b/auth/src/test/java/com/baiyue/auth/config/AuthTokenTtlConfigurationContractTest.java index 0b7440f31..d597d7097 100644 --- a/auth/src/test/java/com/baiyue/auth/config/AuthTokenTtlConfigurationContractTest.java +++ b/auth/src/test/java/com/baiyue/auth/config/AuthTokenTtlConfigurationContractTest.java @@ -10,14 +10,14 @@ import static org.assertj.core.api.Assertions.assertThat; class AuthTokenTtlConfigurationContractTest { @Test - void cnNacosConfigurationKeepsShortAccessAndSevenDayRefreshTokens() throws Exception { + void testNacosConfigurationKeepsUnifiedShortAccessAndSevenDayRefreshTokens() throws Exception { String configuration = Files.readString( - Path.of("..", "config", "nacos", "cn", "auth-service.yml"), StandardCharsets.UTF_8); + Path.of("..", "config", "nacos", "test", "auth-service.yml"), StandardCharsets.UTF_8); assertThat(configuration) .contains("access-token-ttl: ${ACCESS_TOKEN_TTL:24m}") .contains("refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d}") - .contains("access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m}") + .doesNotContain("OVERSEAS_ACCESS_TOKEN_TTL") .doesNotContain("access-token-ttl: ${ACCESS_TOKEN_TTL:7d}"); } } diff --git a/auth/src/test/java/com/baiyue/auth/config/DualTrackJwtConfigurationTest.java b/auth/src/test/java/com/baiyue/auth/config/DualTrackJwtConfigurationTest.java deleted file mode 100644 index 65c871721..000000000 --- a/auth/src/test/java/com/baiyue/auth/config/DualTrackJwtConfigurationTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.baiyue.auth.config; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.autoconfigure.AutoConfigurations; -import org.springframework.boot.test.context.runner.ApplicationContextRunner; -import org.springframework.security.oauth2.jwt.JwtEncoder; - -import static org.assertj.core.api.Assertions.assertThat; - -class DualTrackJwtConfigurationTest { - private final ApplicationContextRunner runner = new ApplicationContextRunner() - .withConfiguration(AutoConfigurations.of(DomesticJwtConfiguration.class, OverseasJwtConfiguration.class)) - .withBean(AuthSecurityProperties.class, () -> new AuthSecurityProperties( - "domestic-secret-at-least-32-bytes-long", "auth-service", "lastleg-api", - java.time.Duration.ofMinutes(24), java.time.Duration.ofDays(7), 5, - java.time.Duration.ofMinutes(15))) - .withPropertyValues( - "baiyue.security.overseas-jwt.enabled=false"); - - @Test - void startsDomesticEncoderWithoutRsaConfiguration() { - runner.run(context -> { - assertThat(context).hasNotFailed(); - assertThat(context).hasBean("jwtEncoder"); - assertThat(context).doesNotHaveBean("overseasJwtEncoder"); - assertThat(context).hasSingleBean(JwtEncoder.class); - }); - } -} diff --git a/auth/src/test/java/com/baiyue/auth/config/UnifiedJwtConfigurationTest.java b/auth/src/test/java/com/baiyue/auth/config/UnifiedJwtConfigurationTest.java new file mode 100644 index 000000000..8c4f1c416 --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/config/UnifiedJwtConfigurationTest.java @@ -0,0 +1,43 @@ +package com.baiyue.auth.config; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.*; +import java.time.Instant; +import java.util.List; +import static org.assertj.core.api.Assertions.*; + +class UnifiedJwtConfigurationTest { + @Test + void signsWithCurrentKeyAndRetainsPreviousPublicKeyForRotation() throws Exception { + var current = new RSAKeyGenerator(2048).keyID("current").algorithm(JWSAlgorithm.RS256).generate(); + var previous = new RSAKeyGenerator(2048).keyID("previous").algorithm(JWSAlgorithm.RS256).generate(); + var properties = new UnifiedJwtProperties(new JWKSet(List.of(current, previous.toPublicJWK())).toString(false), "current"); + var encoder = new UnifiedJwtConfiguration().jwtEncoder(properties); + var now = Instant.now(); + var token = encoder.encode(JwtEncoderParameters.from( + JwsHeader.with(SignatureAlgorithm.RS256).keyId("current").build(), + JwtClaimsSet.builder().subject("42").issuedAt(now).expiresAt(now.plusSeconds(60)).build())); + var decoded = NimbusJwtDecoder.withPublicKey(current.toRSAPublicKey()).build().decode(token.getTokenValue()); + assertThat(decoded.getSubject()).isEqualTo("42"); + assertThat(decoded.getHeaders()).containsEntry("kid", "current").containsEntry("alg", "RS256"); + assertThatThrownBy(() -> NimbusJwtDecoder.withPublicKey(previous.toRSAPublicKey()).build().decode(token.getTokenValue())) + .isInstanceOf(JwtException.class); + } + + @Test + void rejectsMissingPrivateKeyAndPreviousPrivateKey() throws Exception { + var current = new RSAKeyGenerator(2048).keyID("current").generate(); + var previous = new RSAKeyGenerator(2048).keyID("previous").generate(); + var config = new UnifiedJwtConfiguration(); + assertThatThrownBy(() -> config.jwtEncoder(new UnifiedJwtProperties(new JWKSet(current.toPublicJWK()).toString(), "current"))) + .isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> config.jwtEncoder(new UnifiedJwtProperties(new JWKSet(List.of(current, previous)).toString(false), "current"))) + .isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> config.jwtEncoder(new UnifiedJwtProperties("", "current"))) + .isInstanceOf(IllegalStateException.class); + } +} diff --git a/auth/src/test/java/com/baiyue/auth/controller/JwksControllerTest.java b/auth/src/test/java/com/baiyue/auth/controller/JwksControllerTest.java index 53a673c72..9feacbcc7 100644 --- a/auth/src/test/java/com/baiyue/auth/controller/JwksControllerTest.java +++ b/auth/src/test/java/com/baiyue/auth/controller/JwksControllerTest.java @@ -1,6 +1,6 @@ package com.baiyue.auth.controller; -import com.baiyue.auth.config.OverseasJwtProperties; +import com.baiyue.auth.config.UnifiedJwtProperties; import com.nimbusds.jose.JWSAlgorithm; import com.nimbusds.jose.jwk.JWKSet; import com.nimbusds.jose.jwk.KeyUse; @@ -20,8 +20,7 @@ class JwksControllerTest { RSAKey current = key("current", true); RSAKey previous = key("previous", false); String json = new JWKSet(List.of(current, previous)).toString(false); - var properties = new OverseasJwtProperties(true, "auth-service", "lastleg-api", json, - "current", Duration.ofMinutes(15)); + var properties = new UnifiedJwtProperties(json, "current"); var body = new JwksController(properties).jwks().getBody(); String published = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(body); diff --git a/auth/src/test/java/com/baiyue/auth/controller/OverseasSessionValidationControllerContractTest.java b/auth/src/test/java/com/baiyue/auth/controller/OverseasSessionValidationControllerContractTest.java deleted file mode 100644 index 7c698a724..000000000 --- a/auth/src/test/java/com/baiyue/auth/controller/OverseasSessionValidationControllerContractTest.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.baiyue.auth.controller; - -import com.baiyue.auth.dto.OverseasSessionValidationDtos; -import io.swagger.v3.oas.annotations.Operation; -import org.junit.jupiter.api.Test; -import org.springframework.web.bind.annotation.PostMapping; - -import java.util.Arrays; - -import static org.assertj.core.api.Assertions.assertThat; - -class OverseasSessionValidationControllerContractTest { - @Test - void internalEndpointAcceptsOnlyVerifiedClaimValuesAndIsDocumented() { - var method = Arrays.stream(InternalAccountController.class.getDeclaredMethods()) - .filter(candidate -> candidate.getName().equals("validateOverseasSession")) - .findFirst().orElseThrow(); - - assertThat(method.getAnnotation(PostMapping.class).value()) - .containsExactly("/overseas-session/validate"); - assertThat(method.getAnnotation(Operation.class).description()) - .contains("Gateway JWT verification", "raw tokens and user credentials are forbidden"); - assertThat(OverseasSessionValidationDtos.Request.class.getRecordComponents()) - .extracting(java.lang.reflect.RecordComponent::getName) - .containsExactly("userId", "tokenVersion", "overseasWarehouseId") - .doesNotContain("token", "accessToken", "authorization", "password"); - } -} diff --git a/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java index ee951962c..f3fa7cd0a 100644 --- a/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/AccountManagementServiceTest.java @@ -20,6 +20,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.dao.DuplicateKeyException; import jakarta.annotation.Resource; @@ -85,7 +86,7 @@ class AccountManagementServiceTest { } @Test void createPlatformAccountPersistsPlatformAppCodeAndPasswordHashOnly() { - when(repository.findById(20L)).thenReturn(null); + when(repository.findCurrentById(20L)).thenReturn(null); when(repository.existsByUsername("platform.user")).thenReturn(false); var result = service.create(new CreateAccountRequest(20L, "platform.user", "Password1", @@ -97,6 +98,7 @@ class AccountManagementServiceTest { assertEquals("PLATFORM", captor.getValue().getDefaultAppCode()); assertTrue(encoder.matches("Password1", captor.getValue().getPasswordHash())); assertNotEquals("Password1", captor.getValue().getPasswordHash()); + verify(repository, never()).findById(20L); } @Test void usernameAvailabilityUsesTheAuthAccountRepositoryAsTheFactSource() { @@ -107,7 +109,7 @@ class AccountManagementServiceTest { } @Test void createDisabledAccountAndSummaryExposeLastLoginAndScope() { - when(repository.findById(20L)).thenReturn(null); + when(repository.findCurrentById(20L)).thenReturn(null); when(repository.existsByUsername("merchant.user")).thenReturn(false); var created = service.create(new CreateAccountRequest(20L, "merchant.user", "Password1", @@ -141,6 +143,7 @@ class AccountManagementServiceTest { account.setUsername("platform.user"); account.setDisplayName("平台用户"); account.setDefaultTenantId(0L); account.setOperatorTenantId(0L); account.setDefaultMembershipId(30L); account.setDefaultAppCode("PLATFORM"); account.setDeleted(0); + when(repository.findCurrentById(10L)).thenReturn(account); var result = service.create(new CreateAccountRequest(10L, "platform.user", "Password1", "平台用户", 0L, 30L, "PLATFORM")); @@ -149,6 +152,44 @@ class AccountManagementServiceTest { verify(repository, never()).insert(any()); } + @Test void concurrentInsertOfSameIdentityReturnsDurableWinner() { + account.setUsername("platform.user"); account.setDefaultTenantId(0L); + account.setOperatorTenantId(0L); account.setDefaultMembershipId(30L); + account.setDefaultAppCode("PLATFORM"); account.setDeleted(0); + when(repository.findCurrentById(10L)).thenReturn(null); + doThrow(new DuplicateKeyException("concurrent insert")).when(repository).insert(any()); + + var result = service.create(new CreateAccountRequest(10L, "platform.user", "Password1", + "平台用户", 0L, 30L, "PLATFORM")); + + assertEquals(10L, result.accountId()); + var order = inOrder(repository); + order.verify(repository).findCurrentById(10L); + order.verify(repository).existsByUsername("platform.user"); + order.verify(repository).insert(any()); + order.verify(repository).findById(10L); + verify(repository, never()).save(any()); + } + + @Test void concurrentInsertOfDifferentIdentityIsRejected() { + when(repository.findCurrentById(10L)).thenReturn(null); + doThrow(new DuplicateKeyException("concurrent insert")).when(repository).insert(any()); + + assertThrows(BizException.class, () -> service.create(new CreateAccountRequest(10L, + "platform.user", "Password1", "平台用户", 0L, 30L, "PLATFORM"))); + verify(repository, never()).save(any()); + } + + @Test void concurrentUsernameConflictDoesNotCreateOrReturnAnotherAccount() { + when(repository.findCurrentById(20L)).thenReturn(null); + when(repository.findById(20L)).thenReturn(null); + doThrow(new DuplicateKeyException("username conflict")).when(repository).insert(any()); + + assertThrows(BizException.class, () -> service.create(new CreateAccountRequest(20L, + "platform.user", "Password1", "平台用户", 0L, 30L, "PLATFORM"))); + verify(repository, never()).save(any()); + } + @Test void synchronizeDisplayNameRevokesExistingSessions() { account.setDisplayName("旧名称"); diff --git a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java index 1ea5b0459..d8f367e62 100644 --- a/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/AuthServiceSmallJwtTest.java @@ -12,7 +12,7 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.oauth2.jose.jws.MacAlgorithm; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.jwt.JwtEncoder; import org.springframework.security.oauth2.jwt.JwtEncoderParameters; @@ -37,21 +37,24 @@ class AuthServiceSmallJwtTest { @MockBean private JwtEncoder jwtEncoder; @MockBean private AuthorizationLoader authorizationLoader; @MockBean private TokenSessionStore tokenSessionStore; - @MockBean private OverseasTokenService overseasTokenService; @Resource private AuthService service; @Configuration(proxyBeanMethods = false) static class TestConfig { + @Bean + com.baiyue.auth.config.UnifiedJwtProperties signingProperties() { + return new com.baiyue.auth.config.UnifiedJwtProperties("unused", "current"); + } @Bean AuthSecurityProperties properties() { - return new AuthSecurityProperties("unused", "auth-service", "lastleg-api", + return new AuthSecurityProperties("auth-service", "lastleg-api", Duration.ofMinutes(30), Duration.ofDays(7), 5, Duration.ofMinutes(10)); } } @Test - void signsCrossRegionClaimsAndStoresSession() { + void signsUnifiedClaimsAndStoresSession() { AuthAccount account = new AuthAccount(); account.setId(42L); account.setUsername("operator"); @@ -71,9 +74,6 @@ class AuthServiceSmallJwtTest { when(authorizationLoader.load(account)).thenReturn(snapshot); when(jwtEncoder.encode(any())).thenReturn(encoded); when(encoded.getTokenValue()).thenReturn("small-token"); - when(overseasTokenService.issue(account, snapshot)).thenReturn( - new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-token", 900, - snapshot.getRoles(), snapshot.getPermissions())); var response = service.login(new LoginRequest("operator", "secret")); @@ -89,16 +89,13 @@ class AuthServiceSmallJwtTest { .containsEntry("overseas_warehouse_id", 900L) .doesNotContainKeys("roles", "authorities", "permissions", "scope"); assertThat(jwt.getValue().getClaims().getAudience()).containsExactly("lastleg-api"); - assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(MacAlgorithm.HS256); - assertThat(jwt.getValue().getJwsHeader().getKeyId()).isNull(); + assertThat(jwt.getValue().getJwsHeader().getAlgorithm()).isEqualTo(SignatureAlgorithm.RS256); + assertThat(jwt.getValue().getJwsHeader().getKeyId()).isEqualTo("current"); verify(tokenSessionStore).store(any(), org.mockito.ArgumentMatchers.eq(42L), org.mockito.ArgumentMatchers.eq(Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR")), - org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), any(), + org.mockito.ArgumentMatchers.eq(Set.of("platform:order:list")), anyString(), any(), org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)), org.mockito.ArgumentMatchers.eq(Duration.ofDays(7))); - verify(overseasTokenService).issue(account, snapshot); - assertThat(response.overseasAccessToken()).isEqualTo("overseas-token"); - assertThat(response.overseasExpiresIn()).isEqualTo(900); assertThat(response.roles()).containsExactly("OVERSEAS_WAREHOUSE_PDA_OPERATOR"); assertThat(response.authorities()).containsExactly("platform:order:list"); } @@ -144,7 +141,7 @@ class AuthServiceSmallJwtTest { } @Test - void refreshRotatesDomesticAndOverseasTokensFromOneAuthorizationSnapshot() { + void refreshRotatesUnifiedTokenFromCurrentAuthorizationSnapshot() { AuthAccount account = new AuthAccount(); account.setId(52L); account.setUsername("merchant"); @@ -159,14 +156,11 @@ class AuthServiceSmallJwtTest { Jwt encoded = org.mockito.Mockito.mock(Jwt.class); when(tokenSessionStore.consume("refresh-old")).thenReturn( - new TokenSessionStore.RefreshSession(52L, "refresh-session")); + new TokenSessionStore.RefreshSession(52L, "refresh-session", SessionStateService.fingerprint(account, snapshot))); when(repository.findById(52L)).thenReturn(account); when(authorizationLoader.load(account)).thenReturn(snapshot); when(jwtEncoder.encode(any())).thenReturn(encoded); when(encoded.getTokenValue()).thenReturn("domestic-refreshed"); - when(overseasTokenService.issue(account, snapshot)).thenReturn( - new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-refreshed", 900, - snapshot.getRoles(), snapshot.getPermissions())); var response = service.refresh("refresh-old"); @@ -175,19 +169,16 @@ class AuthServiceSmallJwtTest { assertThat(response.expiresIn()).isEqualTo(1800); assertThat(response.refreshToken()).isNotBlank().isNotEqualTo("refresh-old"); assertThat(response.refreshExpiresIn()).isEqualTo(604800); - assertThat(response.overseasAccessToken()).isEqualTo("overseas-refreshed"); - assertThat(response.overseasExpiresIn()).isEqualTo(900); assertThat(response.roles()).containsExactly("MERCHANT_ADMIN"); assertThat(response.authorities()).containsExactly("merchant:order:list"); verify(tokenSessionStore).consume("refresh-old"); verify(tokenSessionStore).store(anyString(), org.mockito.ArgumentMatchers.eq(52L), org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_ADMIN")), org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")), - org.mockito.ArgumentMatchers.eq(response.refreshToken()), + anyString(), org.mockito.ArgumentMatchers.eq(response.refreshToken()), org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(30)), org.mockito.ArgumentMatchers.eq(Duration.ofDays(7))); verify(authorizationLoader).load(account); - verify(overseasTokenService).issue(account, snapshot); } @Test @@ -200,7 +191,6 @@ class AuthServiceSmallJwtTest { verify(repository, never()).findById(any()); verify(jwtEncoder, never()).encode(any()); - verify(overseasTokenService, never()).issue(any(), any()); } @Test @@ -225,14 +215,9 @@ class AuthServiceSmallJwtTest { when(authorizationLoader.load(account)).thenReturn(snapshot); when(jwtEncoder.encode(any())).thenReturn(encoded); when(encoded.getTokenValue()).thenReturn("small-impersonation-token"); - when(overseasTokenService.issue(account, snapshot)).thenReturn( - new OverseasTokenService.OverseasTokenResponse("Bearer", "overseas-impersonation-token", 900, - Set.of("MERCHANT_OWNER"), Set.of("merchant:order:list"))); AuthService.ImpersonationTokenResponse response = service.exchangeImpersonationTicket("one-time-code"); - assertThat(response.overseasAccessToken()).isEqualTo("overseas-impersonation-token"); - assertThat(response.overseasExpiresIn()).isEqualTo(900); assertThat(response.roles()).containsExactly("MERCHANT_OWNER"); assertThat(response.authorities()).containsExactly("merchant:order:list"); @@ -246,7 +231,6 @@ class AuthServiceSmallJwtTest { verify(tokenSessionStore).storeAccessOnly(any(), org.mockito.ArgumentMatchers.eq(43L), org.mockito.ArgumentMatchers.eq(Set.of("MERCHANT_OWNER")), org.mockito.ArgumentMatchers.eq(Set.of("merchant:order:list")), - org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(15))); - verify(overseasTokenService).issue(account, snapshot); + anyString(), org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(15))); } } diff --git a/auth/src/test/java/com/baiyue/auth/service/OverseasSessionValidationServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/OverseasSessionValidationServiceTest.java deleted file mode 100644 index c076584bf..000000000 --- a/auth/src/test/java/com/baiyue/auth/service/OverseasSessionValidationServiceTest.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.baiyue.auth.service; - -import com.baiyue.auth.client.AuthorizationLoader; -import com.baiyue.auth.client.AuthorizationSnapshot; -import com.baiyue.auth.dto.OverseasSessionValidationDtos.FailureReason; -import com.baiyue.auth.dto.OverseasSessionValidationDtos.Request; -import com.baiyue.auth.entity.AuthAccount; -import com.baiyue.auth.repository.AuthAccountRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.util.Set; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -class OverseasSessionValidationServiceTest { - private final AuthAccountRepository accounts = mock(AuthAccountRepository.class); - private final AuthorizationLoader authorizationLoader = mock(AuthorizationLoader.class); - private final OverseasSessionValidationService service = new OverseasSessionValidationService(); - private AuthAccount account; - - @BeforeEach - void setUp() throws Exception { - inject("accounts", accounts); - inject("authorizationLoader", authorizationLoader); - account = new AuthAccount(); - account.setId(42L); - account.setAccountStatus("ACTIVE"); - account.setTokenVersion(8); - when(accounts.findById(42L)).thenReturn(account); - } - - @Test - void oldTokenIsRejectedAfterWarehouseChangeBumpsAccountVersion() { - assertThat(service.validate(new Request(42L, 7, 900L))) - .extracting("valid", "failureReason") - .containsExactly(false, FailureReason.TOKEN_VERSION_MISMATCH); - } - - @Test - void newlyIssuedTokenWithCurrentWarehousePasses() { - when(authorizationLoader.load(account)).thenReturn(snapshot(8L, 901L)); - - assertThat(service.validate(new Request(42L, 8, 901L)).valid()).isTrue(); - } - - @Test - void missingOrChangedWarehouseBindingFailsClosed() { - when(authorizationLoader.load(account)).thenReturn(snapshot(8L, null)); - assertThat(service.validate(new Request(42L, 8, null)).failureReason()) - .isEqualTo(FailureReason.WAREHOUSE_UNBOUND); - - when(authorizationLoader.load(account)).thenReturn(snapshot(8L, 901L)); - assertThat(service.validate(new Request(42L, 8, 900L)).failureReason()) - .isEqualTo(FailureReason.WAREHOUSE_MISMATCH); - } - - @Test - void disabledAccountFailsClosedBeforeIamLookup() { - account.setAccountStatus("DISABLED"); - assertThat(service.validate(new Request(42L, 8, 901L)).failureReason()) - .isEqualTo(FailureReason.ACCOUNT_INVALID); - } - - private static AuthorizationSnapshot snapshot(Long version, Long warehouseId) { - return new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "PDA", - Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), version, warehouseId); - } - - private void inject(String fieldName, Object value) throws Exception { - var field = OverseasSessionValidationService.class.getDeclaredField(fieldName); - field.setAccessible(true); - field.set(service, value); - } -} diff --git a/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java deleted file mode 100644 index cda05aa12..000000000 --- a/auth/src/test/java/com/baiyue/auth/service/OverseasTokenServiceTest.java +++ /dev/null @@ -1,139 +0,0 @@ -package com.baiyue.auth.service; - -import com.baiyue.auth.client.AuthorizationLoader; -import com.baiyue.auth.client.AuthorizationSnapshot; -import com.baiyue.auth.config.OverseasJwtProperties; -import com.baiyue.auth.entity.AuthAccount; -import com.baiyue.auth.repository.AuthAccountRepository; -import jakarta.annotation.Resource; -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.security.oauth2.jwt.JwtEncoder; -import org.springframework.security.oauth2.jwt.JwtEncoderParameters; -import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; -import org.springframework.test.context.TestPropertySource; - -import java.time.Duration; -import java.util.Set; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.when; - -@SpringJUnitConfig(classes = {OverseasTokenService.class, OverseasTokenServiceTest.Config.class}) -@TestPropertySource(properties = "baiyue.security.overseas-jwt.enabled=true") -class OverseasTokenServiceTest { - @MockBean private AuthAccountRepository accounts; - @MockBean private AuthorizationLoader authorizationLoader; - @MockBean(name = "overseasJwtEncoder") private JwtEncoder encoder; - @Resource private OverseasTokenService service; - - @Configuration(proxyBeanMethods = false) - static class Config { - @Bean OverseasJwtProperties properties() { - return new OverseasJwtProperties(true, "auth-service", "lastleg-api", "{}", "current-kid", - Duration.ofMinutes(15)); - } - } - - @Test - void signsShortRs256TokenWithCrossRegionClaims() { - AuthAccount account = new AuthAccount(); - account.setId(42L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(999L); - account.setDefaultMembershipId(84L); account.setDefaultAppCode("MERCHANT"); - String authority = "platform:overseas-master-data:quote:list"; - String merchantAuthority = "merchant:warehouse:delivery:list"; - String merchantCrossBorderAuthority = "merchant:cross-border:procurement:list"; - String lastlegAuthority = "lastleg:pda:operate"; - String financeAuthority = "platform:finance:fee-source:edit"; - String domesticAuthority = "platform:firstleg:order:list"; - AuthorizationSnapshot snapshot = new AuthorizationSnapshot(42L, 0L, 84L, "PLATFORM", "WEB", - Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), - Set.of(authority, merchantAuthority, merchantCrossBorderAuthority, lastlegAuthority, - financeAuthority, domesticAuthority), 7L, 900L); - Jwt encoded = org.mockito.Mockito.mock(Jwt.class); - when(accounts.findById(42L)).thenReturn(account); - when(authorizationLoader.load(account)).thenReturn(snapshot); - when(encoder.encode(any())).thenReturn(encoded); - when(encoded.getTokenValue()).thenReturn("overseas-token"); - - var response = service.exchange(42L); - - ArgumentCaptor captor = ArgumentCaptor.forClass(JwtEncoderParameters.class); - verify(encoder).encode(captor.capture()); - var parameters = captor.getValue(); - assertThat(parameters.getJwsHeader().getAlgorithm()).isEqualTo(SignatureAlgorithm.RS256); - assertThat(parameters.getJwsHeader().getKeyId()).isEqualTo("current-kid"); - assertThat(parameters.getClaims().getAudience()).containsExactly("lastleg-api"); - assertThat(parameters.getClaims().getClaims()) - .containsEntry("user_id", 42L).containsEntry("tenant_id", 0L) - .containsEntry("membership_id", 84L) - .containsEntry("app_code", "PLATFORM").containsEntry("client_type", "WEB") - .containsEntry("permission_version", 7L) - .containsEntry("token_version", 0) - .containsEntry("overseas_warehouse_id", 900L) - .doesNotContainKeys("roles", "authorities", "permissions", "userId", "tenantId", "appCode", - "permissionVersion"); - assertThat(response.expiresIn()).isEqualTo(900); - assertThat(response.roles()).containsExactly("OVERSEAS_WAREHOUSE_PDA_OPERATOR"); - assertThat(response.authorities()).containsExactlyInAnyOrder(authority, merchantAuthority, - merchantCrossBorderAuthority, lastlegAuthority, financeAuthority, domesticAuthority); - } - - @Test - void refreshedAuthorizationSnapshotReplacesWarehouseBindingAndTokenIdentity() { - AuthAccount account = new AuthAccount(); - account.setId(43L); account.setAccountStatus("ACTIVE"); account.setDefaultTenantId(0L); - account.setDefaultMembershipId(85L); account.setDefaultAppCode("PLATFORM"); - account.setTokenVersion(8); - AuthorizationSnapshot oldBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA", - Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 8L, 900L); - AuthorizationSnapshot newBinding = new AuthorizationSnapshot(43L, 0L, 85L, "PLATFORM", "PDA", - Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 9L, 901L); - Jwt first = org.mockito.Mockito.mock(Jwt.class); - Jwt second = org.mockito.Mockito.mock(Jwt.class); - when(encoder.encode(any())).thenReturn(first, second); - when(first.getTokenValue()).thenReturn("overseas-old-binding"); - when(second.getTokenValue()).thenReturn("overseas-new-binding"); - - var oldToken = service.issue(account, oldBinding); - var newToken = service.issue(account, newBinding); - - ArgumentCaptor captor = ArgumentCaptor.forClass(JwtEncoderParameters.class); - verify(encoder, times(2)).encode(captor.capture()); - var oldClaims = captor.getAllValues().get(0).getClaims(); - var newClaims = captor.getAllValues().get(1).getClaims(); - assertThat(oldClaims.getId()).isNotEqualTo(newClaims.getId()); - assertThat(oldClaims.getClaims()).containsEntry("overseas_warehouse_id", 900L) - .containsEntry("permission_version", 8L); - assertThat(newClaims.getClaims()).containsEntry("overseas_warehouse_id", 901L) - .containsEntry("permission_version", 9L); - assertThat(newClaims.getClaims()).containsEntry("token_version", 8); - assertThat(oldToken.accessToken()).isNotEqualTo(newToken.accessToken()); - } - - @Test - void unboundWarehouseIsOmittedSoPdaIdentityMustFailClosed() { - AuthAccount account = new AuthAccount(); - account.setId(44L); account.setAccountStatus("ACTIVE"); - AuthorizationSnapshot unbound = new AuthorizationSnapshot(44L, 0L, 86L, "PLATFORM", "PDA", - Set.of("OVERSEAS_WAREHOUSE_PDA_OPERATOR"), Set.of("lastleg:pda:operate"), 10L, null); - Jwt encoded = org.mockito.Mockito.mock(Jwt.class); - when(encoder.encode(any())).thenReturn(encoded); - when(encoded.getTokenValue()).thenReturn("overseas-unbound"); - - service.issue(account, unbound); - - ArgumentCaptor captor = ArgumentCaptor.forClass(JwtEncoderParameters.class); - verify(encoder).encode(captor.capture()); - assertThat(captor.getValue().getClaims().getClaims()) - .doesNotContainKey("overseas_warehouse_id"); - } -} diff --git a/auth/src/test/java/com/baiyue/auth/service/SessionStateConcurrencyTest.java b/auth/src/test/java/com/baiyue/auth/service/SessionStateConcurrencyTest.java new file mode 100644 index 000000000..6f1af9625 --- /dev/null +++ b/auth/src/test/java/com/baiyue/auth/service/SessionStateConcurrencyTest.java @@ -0,0 +1,281 @@ +package com.baiyue.auth.service; + +import com.baiyue.auth.client.AuthorizationLoader; +import com.baiyue.auth.client.AuthorizationSnapshot; +import com.baiyue.auth.config.AuthSecurityProperties; +import com.baiyue.auth.config.UnifiedJwtProperties; +import com.baiyue.auth.dto.AccountManagementDtos.ResetPasswordRequest; +import com.baiyue.auth.dto.LoginRequest; +import com.baiyue.auth.entity.AuthAccount; +import com.baiyue.auth.repository.AuthAccountRepository; +import com.baiyue.common.core.exception.BizException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.data.redis.core.SetOperations; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.Duration; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** Real MyBatis repository, JDBC transactions/row locks and production login/refresh/revocation services. + * Redis is an atomic in-memory transport double; this is not a Redis/MySQL environment test. */ +class SessionStateConcurrencyTest { + JdbcTemplate authDb, iamDb; + DataSourceTransactionManager authTx, iamTx; + AuthService auth; + AccountManagementService accounts; + SessionStateService states; + TokenSessionStore sessions; + JwtEncoder encoder; + AtomicBoolean redisFailure; + final ConcurrentMap values = new ConcurrentHashMap<>(); + final ConcurrentMap> sets = new ConcurrentHashMap<>(); + + @BeforeEach void setup() throws Exception { + authDb = database(); iamDb = database(); + authTx = new DataSourceTransactionManager(authDb.getDataSource()); + iamTx = new DataSourceTransactionManager(iamDb.getDataSource()); + authDb.execute("CREATE TABLE auth_account(id BIGINT PRIMARY KEY, username VARCHAR(50),phone VARCHAR(50)," + + "password_hash VARCHAR(100),display_name VARCHAR(50),account_status VARCHAR(20),failed_login_count INT," + + "locked_until TIMESTAMP,token_version INT,password_change_required BOOLEAN,default_tenant_id BIGINT," + + "operator_tenant_id BIGINT,default_membership_id BIGINT,default_app_code VARCHAR(20),deleted INT," + + "last_login_at TIMESTAMP,created_at TIMESTAMP,updated_at TIMESTAMP)"); + authDb.update("INSERT INTO auth_account(id,username,password_hash,account_status,token_version,default_tenant_id," + + "operator_tenant_id,default_membership_id,default_app_code,deleted) VALUES(1,'operator','old-password','ACTIVE',1,7,7,9,'MERCHANT',0)"); + iamDb.execute("CREATE TABLE authorization_state(id BIGINT PRIMARY KEY, version BIGINT, tenant_version BIGINT, permission VARCHAR(50), warehouse BIGINT, active BOOLEAN)"); + iamDb.update("INSERT INTO authorization_state VALUES (1,1,1,'write',10,true)"); + + var factory = new com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean(); + factory.setDataSource(authDb.getDataSource()); + var config = new com.baomidou.mybatisplus.core.MybatisConfiguration(); + config.setMapUnderscoreToCamelCase(true); + config.addMapper(com.baiyue.auth.mapper.AuthAccountMapper.class); + factory.setConfiguration(config); + var mapper = new org.mybatis.spring.SqlSessionTemplate(factory.getObject()) + .getMapper(com.baiyue.auth.mapper.AuthAccountMapper.class); + var repository = new AuthAccountRepository(); + ReflectionTestUtils.setField(repository, "mapper", mapper); + AuthorizationLoader loader = mock(AuthorizationLoader.class); + when(loader.load(any())).thenAnswer(call -> { + var tx = new TransactionTemplate(iamTx); + tx.setIsolationLevel(org.springframework.transaction.TransactionDefinition.ISOLATION_REPEATABLE_READ); + return tx.execute(status -> iamDb.queryForObject("SELECT * FROM authorization_state WHERE id=1", (rs, row) -> { + if (!rs.getBoolean("active")) throw new IllegalArgumentException("IAM identity disabled"); + return new AuthorizationSnapshot(1L, 7L, 9L, "MERCHANT", "MERCHANT", Set.of("OPERATOR"), + Set.of(rs.getString("permission")), rs.getLong("version"), rs.getLong("warehouse"), null, + rs.getLong("tenant_version")); + })); + }); + sessions = new TokenSessionStore(); + ReflectionTestUtils.setField(sessions, "objectMapper", new ObjectMapper()); + ReflectionTestUtils.setField(sessions, "keyPrefix", "test"); + ReflectionTestUtils.setField(sessions, "redisTemplate", redis()); + PasswordEncoder passwords = mock(PasswordEncoder.class); + when(passwords.matches(anyString(), anyString())).thenAnswer(c -> c.getArgument(0).equals(c.getArgument(1))); + when(passwords.encode(anyString())).thenAnswer(c -> c.getArgument(0)); + encoder = mock(JwtEncoder.class); + when(encoder.encode(any())).thenAnswer(c -> { + var claims = ((org.springframework.security.oauth2.jwt.JwtEncoderParameters)c.getArgument(0)).getClaims(); + return Jwt.withTokenValue(claims.getId()).header("alg", "RS256").claims(m -> m.putAll(claims.getClaims())).build(); + }); + var service = new AuthService(); + ReflectionTestUtils.setField(service, "accountRepository", repository); + ReflectionTestUtils.setField(service, "authorizationLoader", loader); + ReflectionTestUtils.setField(service, "tokenSessionStore", sessions); + ReflectionTestUtils.setField(service, "passwordEncoder", passwords); + ReflectionTestUtils.setField(service, "jwtEncoder", encoder); + ReflectionTestUtils.setField(service, "properties", new AuthSecurityProperties("issuer", "aud", Duration.ofMinutes(5), Duration.ofHours(1), 5, Duration.ofMinutes(1))); + ReflectionTestUtils.setField(service, "signingProperties", new UnifiedJwtProperties("unused", "kid")); + auth = transactional(service); + var management = new AccountManagementService(); + ReflectionTestUtils.setField(management, "repository", repository); + ReflectionTestUtils.setField(management, "tokenSessionStore", sessions); + ReflectionTestUtils.setField(management, "passwordEncoder", passwords); + accounts = transactional(management); + var state = new SessionStateService(); + ReflectionTestUtils.setField(state, "accounts", repository); + ReflectionTestUtils.setField(state, "authorizationLoader", loader); + ReflectionTestUtils.setField(state, "sessions", sessions); + states = transactional(state); + } + + @Test void lateLoginAfterRevokeBeforeIamCommitCannotUseOldPermissionsOrRefresh() throws Exception { + var revoked = new CountDownLatch(1); + var snapshotLoaded = new CountDownLatch(1); + var committed = new CountDownLatch(1); + doAnswer(c -> { + snapshotLoaded.countDown(); await(committed); + var claims = ((org.springframework.security.oauth2.jwt.JwtEncoderParameters)c.getArgument(0)).getClaims(); + return Jwt.withTokenValue(claims.getId()).header("alg", "RS256").claims(m -> m.putAll(claims.getClaims())).build(); + }).when(encoder).encode(any()); + ExecutorService pool = Executors.newFixedThreadPool(2); + try { + Future change = pool.submit(() -> { + new TransactionTemplate(iamTx).executeWithoutResult(tx -> { + iamDb.update("UPDATE authorization_state SET version=version+1, permission='read' WHERE id=1"); + accounts.revokeSessions(Set.of(1L)); + revoked.countDown(); await(snapshotLoaded); + }); + committed.countDown(); + }); + await(revoked); + var login = pool.submit(() -> auth.login(new LoginRequest("operator", "old-password"))); + var stale = login.get(5, TimeUnit.SECONDS); change.get(5, TimeUnit.SECONDS); + // Access and state were written AFTER revoke, yet the first post-commit request is denied. + assertThat(values).containsKey("test:access:" + stale.accessToken()); + assertThatThrownBy(() -> states.validate(stale.accessToken(), 1L)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> auth.refresh(stale.refreshToken())).isInstanceOf(BizException.class); + var fresh = auth.login(new LoginRequest("operator", "old-password")); + assertThat(states.validate(fresh.accessToken(), 1L)).isTrue(); + assertThat(fresh.authorities()).containsExactly("read"); + assertThatThrownBy(() -> auth.refresh(stale.refreshToken())).isInstanceOf(BizException.class); + } finally { committed.countDown(); pool.shutdownNow(); } + } + + @Test void passwordResetWaitsForConcurrentLoginThenInvalidatesAccessAndRefresh() throws Exception { + var loaded = new CountDownLatch(1); var release = new CountDownLatch(1); + doAnswer(c -> { + loaded.countDown(); await(release); + var claims = ((org.springframework.security.oauth2.jwt.JwtEncoderParameters)c.getArgument(0)).getClaims(); + return Jwt.withTokenValue(claims.getId()).header("alg", "RS256").claims(m -> m.putAll(claims.getClaims())).build(); + }).when(encoder).encode(any()); + ExecutorService pool = Executors.newFixedThreadPool(2); + try { + var login = pool.submit(() -> auth.login(new LoginRequest("operator", "old-password"))); + await(loaded); + var resetting = new CountDownLatch(1); + var reset = pool.submit(() -> { resetting.countDown(); accounts.resetPassword(1L, new ResetPasswordRequest("new-password", false), false); }); + await(resetting); + assertThatThrownBy(() -> reset.get(100, TimeUnit.MILLISECONDS)).isInstanceOf(TimeoutException.class); + release.countDown(); + var old = login.get(5, TimeUnit.SECONDS); reset.get(5, TimeUnit.SECONDS); + assertThat(authDb.queryForObject("SELECT token_version FROM auth_account WHERE id=1", Integer.class)).isEqualTo(2); + assertThatThrownBy(() -> states.validate(old.accessToken(), 1L)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> auth.refresh(old.refreshToken())).isInstanceOf(BizException.class); + } finally { release.countDown(); pool.shutdownNow(); } + } + + @Test void failedRevocationRollsBackAuthAndIamAndDoesNotReportSuccess() { + var old = auth.login(new LoginRequest("operator", "old-password")); + redisFailure.set(true); + assertThatThrownBy(() -> new TransactionTemplate(iamTx).executeWithoutResult(tx -> { + iamDb.update("UPDATE authorization_state SET version=version+1, active=false WHERE id=1"); + accounts.revokeSessions(Set.of(1L)); + })).isInstanceOf(BizException.class); + redisFailure.set(false); + assertThat(authDb.queryForObject("SELECT token_version FROM auth_account WHERE id=1", Integer.class)).isEqualTo(1); + assertThat(iamDb.queryForObject("SELECT version FROM authorization_state WHERE id=1", Long.class)).isEqualTo(1); + assertThat(states.validate(old.accessToken(), 1L)).isTrue(); + } + + @Test void committedIamVersionRejectsResidualSessionWhenNoRedisDeletionHappens() { + var old = auth.login(new LoginRequest("operator", "old-password")); + new TransactionTemplate(iamTx).executeWithoutResult(tx -> + iamDb.update("UPDATE authorization_state SET tenant_version=tenant_version+1 WHERE id=1")); + assertThatThrownBy(() -> states.validate(old.accessToken(), 1L)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> auth.refresh(old.refreshToken())).isInstanceOf(BizException.class); + var current = auth.login(new LoginRequest("operator", "old-password")); + assertThat(states.validate(current.accessToken(), 1L)).isTrue(); + iamDb.update("UPDATE authorization_state SET warehouse=20 WHERE id=1"); + assertThatThrownBy(() -> states.validate(current.accessToken(), 1L)).isInstanceOf(BizException.class); + } + + @Test void iamRollbackPreservesFingerprintAndOtherUserCannotUseSession() { + var old = auth.login(new LoginRequest("operator", "old-password")); + new TransactionTemplate(iamTx).executeWithoutResult(tx -> { + iamDb.update("UPDATE authorization_state SET version=version+1, permission='read' WHERE id=1"); + tx.setRollbackOnly(); + }); + assertThat(states.validate(old.accessToken(), 1L)).isTrue(); + assertThatThrownBy(() -> states.validate(old.accessToken(), 2L)).isInstanceOf(BizException.class); + var refreshed = auth.refresh(old.refreshToken()); + assertThat(states.validate(refreshed.accessToken(), 1L)).isTrue(); + } + + @Test void lostRevokeAcknowledgementRollsBackIamButCannotRestoreOldAuthSession() { + var old = auth.login(new LoginRequest("operator", "old-password")); + assertThatThrownBy(() -> new TransactionTemplate(iamTx).executeWithoutResult(tx -> { + iamDb.update("UPDATE authorization_state SET version=version+1, permission='read' WHERE id=1"); + accounts.revokeSessions(Set.of(1L)); + throw new IllegalStateException("Auth response timed out after its commit"); + })).isInstanceOf(IllegalStateException.class); + assertThat(iamDb.queryForObject("SELECT permission FROM authorization_state WHERE id=1", String.class)).isEqualTo("write"); + assertThat(authDb.queryForObject("SELECT token_version FROM auth_account WHERE id=1", Integer.class)).isEqualTo(2); + assertThatThrownBy(() -> states.validate(old.accessToken(), 1L)).isInstanceOf(BizException.class); + var fresh = auth.login(new LoginRequest("operator", "old-password")); + assertThat(states.validate(fresh.accessToken(), 1L)).isTrue(); + assertThat(fresh.authorities()).containsExactly("write"); + } + + @Test void disabledCustomerOrMemberAndUnavailableIamRejectAccessAndRefresh() { + var old = auth.login(new LoginRequest("operator", "old-password")); + // No Redis deletion: the authoritative identity gate alone closes the session. + iamDb.update("UPDATE authorization_state SET active=false, tenant_version=tenant_version+1 WHERE id=1"); + assertThatThrownBy(() -> states.validate(old.accessToken(), 1L)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> auth.refresh(old.refreshToken())).isInstanceOf(IllegalArgumentException.class); + iamDb.update("UPDATE authorization_state SET active=true WHERE id=1"); + var fresh = auth.login(new LoginRequest("operator", "old-password")); + iamDb.execute("DROP TABLE authorization_state"); + assertThatThrownBy(() -> states.validate(fresh.accessToken(), 1L)).isInstanceOf(org.springframework.dao.DataAccessException.class); + assertThatThrownBy(() -> auth.refresh(fresh.refreshToken())).isInstanceOf(org.springframework.dao.DataAccessException.class); + } + + @SuppressWarnings("unchecked") private StringRedisTemplate redis() { + redisFailure = new AtomicBoolean(); + var redis = mock(StringRedisTemplate.class); + ValueOperations ops = mock(ValueOperations.class); + SetOperations index = mock(SetOperations.class); + when(redis.opsForValue()).thenReturn(ops); when(redis.opsForSet()).thenReturn(index); + doAnswer(c -> { values.put(c.getArgument(0), c.getArgument(1)); return null; }).when(ops).set(anyString(), anyString(), any(Duration.class)); + when(ops.get(anyString())).thenAnswer(c -> values.get(c.getArgument(0))); + when(ops.getAndDelete(anyString())).thenAnswer(c -> values.remove(c.getArgument(0))); + when(redis.hasKey(anyString())).thenAnswer(c -> values.containsKey(c.getArgument(0))); + when(redis.delete(anyString())).thenAnswer(c -> values.remove(c.getArgument(0)) != null); + when(redis.delete(anyCollection())).thenAnswer(c -> { + if (redisFailure.get()) throw new org.springframework.data.redis.RedisConnectionFailureException("offline"); + long count = 0; for (Object key : (java.util.Collection) c.getArgument(0)) if (values.remove(key) != null) count++; + return count; + }); + when(index.members(anyString())).thenAnswer(c -> sets.getOrDefault(c.getArgument(0), Set.of())); + doAnswer(c -> { + Set members = sets.computeIfAbsent(c.getArgument(0), key -> ConcurrentHashMap.newKeySet()); + for (int i=1; i T transactional(T target) { + var factory = new ProxyFactory(target); factory.setProxyTargetClass(true); + factory.addAdvice(new TransactionInterceptor(authTx, new AnnotationTransactionAttributeSource())); + return (T) factory.getProxy(); + } + static void await(CountDownLatch latch) { + try { if (!latch.await(5, TimeUnit.SECONDS)) throw new AssertionError("Barrier timed out"); } + catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new AssertionError(e); } + } +} diff --git a/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java b/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java index a18fc513d..760896079 100644 --- a/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/TokenSessionStoreTest.java @@ -47,7 +47,7 @@ class TokenSessionStoreTest { when(redis.opsForValue()).thenReturn(values); store.store("jti-1", 42L, Set.of("ADMIN"), Set.of("order:read"), - "raw-refresh", Duration.ofMinutes(30), Duration.ofDays(7)); + "state", "raw-refresh", Duration.ofMinutes(30), Duration.ofDays(7)); verify(values).set(eq("baiyue:auth:access:jti-1"), org.mockito.ArgumentMatchers.argThat(value -> value.contains("\"version\":1") @@ -64,7 +64,7 @@ class TokenSessionStoreTest { ValueOperations values = mock(ValueOperations.class); when(redis.opsForValue()).thenReturn(values); String json = new ObjectMapper().writeValueAsString( - new TokenSessionStore.RefreshSession(42L, "jti-1")); + new TokenSessionStore.RefreshSession(42L, "jti-1", "state")); when(values.getAndDelete(anyString())).thenReturn(json); TokenSessionStore.RefreshSession session = store.consume("raw-refresh"); diff --git a/common-core/AGENTS.md b/common-core/AGENTS.md index 0bd37d101..e725c4e21 100644 --- a/common-core/AGENTS.md +++ b/common-core/AGENTS.md @@ -14,6 +14,9 @@ | 业务异常 | `src/main/java/com/baiyue/common/core/exception/BizException.java` | | 通用审计字段 | `src/main/java/com/baiyue/common/core/model/BaseEntity.java` | | MyBatis-Plus 与审计人 | `src/main/java/com/baiyue/common/core/mybatis/` | +| 有限重试与退避 | `src/main/java/com/baiyue/common/core/retry/BoundedRetryPolicy.java`;供多个服务的 Outbox 复用,不拥有持久化或投递逻辑 | +| 人工重试事务与凭据 | `src/main/java/com/baiyue/common/core/outbox/OutboxOperations.java`、`EventOperationsTokenGuard.java`;三个生产服务显式选择本库表及事件白名单,锁定 DEAD 原记录,状态恢复与审计同事务;不访问其他服务数据库或直接投递事件 | +| 逐次投递审计事务 | `src/main/java/com/baiyue/common/core/outbox/EventDeliveryJournal.java`;领取与STARTED、持有者CAS完成与审计各自同库REQUIRES_NEW事务;HTTP在两个事务之间执行,迟到结果不得写入;JDBC为optional依赖,Print等无数据库服务不被引入JDBC | | 响应契约测试 | `src/test/java/com/baiyue/common/core/ApiResponseTest.java` | ## 修改与验证 diff --git a/common-core/pom.xml b/common-core/pom.xml index faff84fd8..2ef4508d3 100644 --- a/common-core/pom.xml +++ b/common-core/pom.xml @@ -14,6 +14,7 @@ baiyue-common-core + com.fasterxml.jackson.corejackson-annotations org.springframeworkspring-web com.baomidou @@ -24,5 +25,7 @@ spring-boot-starter-test test + org.springframeworkspring-jdbctrue + com.h2databaseh2test diff --git a/common-core/src/main/java/com/baiyue/common/core/api/ApiResponse.java b/common-core/src/main/java/com/baiyue/common/core/api/ApiResponse.java index d88fd5b64..488845219 100644 --- a/common-core/src/main/java/com/baiyue/common/core/api/ApiResponse.java +++ b/common-core/src/main/java/com/baiyue/common/core/api/ApiResponse.java @@ -1,5 +1,7 @@ package com.baiyue.common.core.api; +import com.fasterxml.jackson.annotation.JsonInclude; + import java.time.Instant; /** @@ -8,7 +10,7 @@ import java.time.Instant; public record ApiResponse( String code, String message, - T data, + @JsonInclude(JsonInclude.Include.ALWAYS) T data, String requestId, String traceId, Instant timestamp) { diff --git a/common-core/src/main/java/com/baiyue/common/core/outbox/EventDeliveryJournal.java b/common-core/src/main/java/com/baiyue/common/core/outbox/EventDeliveryJournal.java new file mode 100644 index 000000000..4783d1b52 --- /dev/null +++ b/common-core/src/main/java/com/baiyue/common/core/outbox/EventDeliveryJournal.java @@ -0,0 +1,59 @@ +package com.baiyue.common.core.outbox; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Objects; +import java.util.Set; +import java.util.function.BooleanSupplier; +import java.util.function.IntSupplier; + +/** Same-database audit. HTTP must only run after begin returns; never hold a DB transaction over HTTP. */ +public final class EventDeliveryJournal { + public enum Outcome { APPLIED, DUPLICATE, STALE, RETRY, DEAD, LEASE_EXPIRED } + private final JdbcTemplate jdbc; + private final TransactionTemplate transaction; + + public EventDeliveryJournal(JdbcTemplate jdbc, PlatformTransactionManager manager) { + this.jdbc = Objects.requireNonNull(jdbc); + if (!(manager instanceof DataSourceTransactionManager local) + || local.getDataSource() != jdbc.getDataSource()) { + throw new IllegalArgumentException("Delivery audit must share the local Outbox transaction manager"); + } + transaction = new TransactionTemplate(manager); + transaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + } + + public boolean begin(String attempt, String service, String eventType, String entryId, + String eventId, String target, LocalDateTime now, BooleanSupplier claim) { + return Boolean.TRUE.equals(transaction.execute(status -> { + if (!claim.getAsBoolean()) return false; + jdbc.update("INSERT INTO event_delivery_attempt(attempt_id,service_name,event_type,entry_id,event_id,target_name,started_at,outcome) VALUES(?,?,?,?,?,?,?,'STARTED')", + attempt, service, eventType, entryId, eventId, target, now); + return true; + })); + } + + public boolean complete(String attempt, Outcome outcome, LocalDateTime now, + String errorCode, LocalDateTime nextRetry, IntSupplier updateOwnedOutbox) { + if (errorCode != null && !Set.of("RETRY_EXHAUSTED", "LEASE_EXPIRED", "INVALID_EVENT", "INVALID_RESPONSE", "PERMANENT_FAILURE", "DELIVERY_FAILURE").contains(errorCode) + && !errorCode.matches("HTTP_[1-5][0-9]{2}")) throw new IllegalArgumentException("Unbounded audit error code"); + return Boolean.TRUE.equals(transaction.execute(status -> { + int changed = updateOwnedOutbox.getAsInt(); + if (changed == 0) return false; // A stale worker has no right to change either record. + if (changed != 1) throw new IllegalStateException("Unexpected Outbox update count"); + LocalDateTime started = jdbc.queryForObject( + "SELECT started_at FROM event_delivery_attempt WHERE attempt_id=? AND outcome='STARTED' FOR UPDATE", + LocalDateTime.class, attempt); + long duration = Math.max(0, Duration.between(Objects.requireNonNull(started), now).toMillis()); + int audited = jdbc.update("UPDATE event_delivery_attempt SET finished_at=?,duration_ms=?,outcome=?,error_code=?,next_retry_at=? WHERE attempt_id=? AND outcome='STARTED'", + now, duration, outcome.name(), errorCode, nextRetry, attempt); + if (audited != 1) throw new IllegalStateException("Delivery completion audit is missing"); + return true; + })); + } +} diff --git a/common-core/src/main/java/com/baiyue/common/core/outbox/EventDeliveryProducer.java b/common-core/src/main/java/com/baiyue/common/core/outbox/EventDeliveryProducer.java new file mode 100644 index 000000000..32dc2a7be --- /dev/null +++ b/common-core/src/main/java/com/baiyue/common/core/outbox/EventDeliveryProducer.java @@ -0,0 +1,4 @@ +package com.baiyue.common.core.outbox; + +/** Marks services that require durable attempt audit and cached delivery metrics. */ +public interface EventDeliveryProducer { } diff --git a/common-core/src/main/java/com/baiyue/common/core/outbox/EventOperationsTokenGuard.java b/common-core/src/main/java/com/baiyue/common/core/outbox/EventOperationsTokenGuard.java new file mode 100644 index 000000000..23d1b8934 --- /dev/null +++ b/common-core/src/main/java/com/baiyue/common/core/outbox/EventOperationsTokenGuard.java @@ -0,0 +1,20 @@ +package com.baiyue.common.core.outbox; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +/** Dedicated operations credential; a missing or weak configuration disables this capability. */ +public final class EventOperationsTokenGuard { + private final byte[] expected; + public EventOperationsTokenGuard(String token) { + expected = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8); + } + public void require(String supplied) { + if (expected.length < 32 || supplied == null || supplied.length() > 4096 + || !MessageDigest.isEqual(expected, supplied.getBytes(StandardCharsets.UTF_8))) { + throw new BizException(ResultCode.FORBIDDEN, "事件运维凭据无效或入口未启用"); + } + } +} diff --git a/common-core/src/main/java/com/baiyue/common/core/outbox/OutboxOperations.java b/common-core/src/main/java/com/baiyue/common/core/outbox/OutboxOperations.java new file mode 100644 index 000000000..f6f0664a5 --- /dev/null +++ b/common-core/src/main/java/com/baiyue/common/core/outbox/OutboxOperations.java @@ -0,0 +1,131 @@ +package com.baiyue.common.core.outbox; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; + +/** Transactional operator retry of an immutable, service-owned Outbox entry. No cross-schema access. */ +public final class OutboxOperations { + public record Layout(String table, String idColumn, String statusColumn, Set eventTypes) { + public Layout { + for (String name : List.of(table, idColumn, statusColumn)) { + if (!name.matches("[a-z][a-z0-9_]*")) throw new IllegalArgumentException("Invalid Outbox identifier"); + } + eventTypes = Set.copyOf(eventTypes); + if (eventTypes.isEmpty()) throw new IllegalArgumentException("Event allowlist is required"); + } + } + public record Identity(String eventId, String eventType, String payloadHash) { } + public record RetryRequest(String requestId, String operator, String reason, String expectedPayloadHash) { } + public record RetryResult(String entryId, String eventId, String payloadHash, String requestId, boolean duplicate) { } + public record Entry(String entryId, String eventId, String eventType, String payloadHash, + String status, int failures, String lastError, Object nextRetryAt) { } + private final JdbcTemplate jdbc; + private final Layout layout; + private final Function identityReader; + private final TransactionTemplate transaction; + + public OutboxOperations(JdbcTemplate jdbc, PlatformTransactionManager transactions, Layout layout, + Function identityReader) { + this.jdbc = jdbc; + this.layout = layout; + this.identityReader = identityReader; + this.transaction = new TransactionTemplate(transactions); + } + + public Entry inspect(String entryId) { + validateId(entryId); + Map row = find(entryId, false); + Identity identity = identity(row); + return new Entry(String.valueOf(row.get("entry_id")), identity.eventId(), identity.eventType(), identity.payloadHash(), + String.valueOf(row.get("entry_status")), ((Number) row.get("retry_count")).intValue(), + (String) row.get("last_error"), row.get("next_retry_at")); + } + + public RetryResult retry(String entryId, RetryRequest request) { + validateId(entryId); + validateRequest(request); + try { + return transaction.execute(status -> { + Map row = find(entryId, true); + String canonicalId = String.valueOf(row.get("entry_id")); + Identity identity = identity(row); + if (!identity.payloadHash().equalsIgnoreCase(request.expectedPayloadHash())) { + throw conflict("事件摘要已变化,拒绝重试"); + } + List> prior = jdbc.queryForList( + "SELECT producer_table,entry_id,event_id,payload_hash,operator_name,reason FROM event_delivery_retry_audit WHERE request_id=?", + request.requestId()); + if (!prior.isEmpty()) { + Map audit = prior.get(0); + if (!layout.table().equals(audit.get("producer_table")) || !canonicalId.equals(audit.get("entry_id")) + || !identity.eventId().equals(audit.get("event_id")) + || !identity.payloadHash().equals(audit.get("payload_hash")) + || !request.operator().equals(audit.get("operator_name")) || !request.reason().equals(audit.get("reason"))) { + throw conflict("操作请求编号已用于不同重试参数"); + } + return result(canonicalId, identity, request, true); + } + if (!"DEAD".equals(row.get("entry_status"))) throw conflict("仅允许重试 DEAD 事件"); + // Relay due-time comparisons use UTC, independently of the JVM default zone. + LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); + int updated = jdbc.update("UPDATE " + layout.table() + " SET " + layout.statusColumn() + + "='PENDING',retry_count=0,next_retry_at=?,locked_by=NULL,locked_at=NULL,last_error=NULL WHERE " + + layout.idColumn() + "=? AND " + layout.statusColumn() + "='DEAD'", now, canonicalId); + if (updated != 1) throw conflict("事件状态已变化"); + jdbc.update("INSERT INTO event_delivery_retry_audit(request_id,producer_table,entry_id,event_id,event_type,payload_hash,operator_name,reason,previous_failures,previous_error,requested_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)", + request.requestId(), layout.table(), canonicalId, identity.eventId(), identity.eventType(), identity.payloadHash(), + request.operator(), request.reason(), row.get("retry_count"), row.get("last_error"), now); + return result(canonicalId, identity, request, false); + }); + } catch (DuplicateKeyException duplicate) { + throw conflict("操作请求编号冲突,原事务已回滚"); + } + } + + private Map find(String id, boolean lock) { + List> rows = jdbc.queryForList("SELECT " + layout.idColumn() + " AS entry_id,payload_json," + layout.statusColumn() + + " AS entry_status,retry_count,last_error,next_retry_at FROM " + layout.table() + " WHERE " + + layout.idColumn() + "=?" + (lock ? " FOR UPDATE" : ""), id); + if (rows.isEmpty()) throw new BizException(ResultCode.NOT_FOUND, "事件不存在"); + return rows.get(0); + } + private Identity identity(Map row) { + Identity identity = identityReader.apply(String.valueOf(row.get("payload_json"))); + if (!layout.eventTypes().contains(identity.eventType())) throw new BizException(ResultCode.FORBIDDEN, "事件不属于本运维入口"); + return identity; + } + private static RetryResult result(String entryId, Identity identity, RetryRequest request, boolean duplicate) { + return new RetryResult(entryId, identity.eventId(), identity.payloadHash(), request.requestId(), duplicate); + } + private static void validateId(String entryId) { + if (entryId == null || !entryId.matches("[A-Za-z0-9-]{1,64}")) throw new BizException(ResultCode.BAD_REQUEST, "事件记录标识无效"); + } + private static void validateRequest(RetryRequest request) { + if (request == null) throw new BizException(ResultCode.BAD_REQUEST, "重试参数不能为空"); + try { + if (!UUID.fromString(request.requestId()).toString().equalsIgnoreCase(request.requestId())) throw new IllegalArgumentException(); + } catch (RuntimeException failure) { throw new BizException(ResultCode.BAD_REQUEST, "requestId 必须为标准 UUID"); } + requireText(request.operator(), 128, "操作人"); + requireText(request.reason(), 500, "原因"); + if (request.expectedPayloadHash() == null || !request.expectedPayloadHash().matches("sha256:[0-9a-fA-F]{64}")) { + throw new BizException(ResultCode.BAD_REQUEST, "必须提供预期事件摘要"); + } + } + private static void requireText(String value, int length, String label) { + if (value == null || value.isBlank() || value.length() > length || value.chars().anyMatch(Character::isISOControl)) { + throw new BizException(ResultCode.BAD_REQUEST, label + "无效"); + } + } + private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); } +} diff --git a/common-core/src/main/java/com/baiyue/common/core/retry/BoundedRetryPolicy.java b/common-core/src/main/java/com/baiyue/common/core/retry/BoundedRetryPolicy.java new file mode 100644 index 000000000..2ac3c9895 --- /dev/null +++ b/common-core/src/main/java/com/baiyue/common/core/retry/BoundedRetryPolicy.java @@ -0,0 +1,31 @@ +package com.baiyue.common.core.retry; + +import java.time.Duration; +import java.util.concurrent.ThreadLocalRandom; + +/** Bounded exponential retry schedule; callers persist failed or expired attempts. */ +public record BoundedRetryPolicy(int maxAttempts, long baseDelaySeconds, long maxDelaySeconds, double jitter) { + public BoundedRetryPolicy { + if (maxAttempts < 1 || baseDelaySeconds < 1 || maxDelaySeconds < baseDelaySeconds + || maxDelaySeconds > 86400 || !Double.isFinite(jitter) || jitter < 0 || jitter > 0.5) { + throw new IllegalArgumentException("Invalid bounded retry policy"); + } + } + public boolean exhausted(int failedAttempts) { + return failedAttempts >= maxAttempts; + } + public Duration delayAfterFailure(int failedAttempts) { + return delayAfterFailure(failedAttempts, ThreadLocalRandom.current().nextDouble()); + } + public Duration delayAfterFailure(int failedAttempts, double random) { + if (failedAttempts < 1 || !Double.isFinite(random) || random < 0 || random > 1) { + throw new IllegalArgumentException("Invalid retry attempt or jitter sample"); + } + long seconds = baseDelaySeconds; + for (int i = 1; i < failedAttempts && seconds < maxDelaySeconds; i++) { + seconds = Math.min(maxDelaySeconds, seconds * 2); + } + long millis = Math.round(seconds * 1000 * (1 - jitter + 2 * jitter * random)); + return Duration.ofMillis(Math.min(maxDelaySeconds * 1000, Math.max(1, millis))); + } +} diff --git a/common-core/src/test/java/com/baiyue/common/core/BoundedRetryPolicyTest.java b/common-core/src/test/java/com/baiyue/common/core/BoundedRetryPolicyTest.java new file mode 100644 index 000000000..95f055a0a --- /dev/null +++ b/common-core/src/test/java/com/baiyue/common/core/BoundedRetryPolicyTest.java @@ -0,0 +1,31 @@ +package com.baiyue.common.core; + +import com.baiyue.common.core.retry.BoundedRetryPolicy; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class BoundedRetryPolicyTest { + @Test void startsAtFiveSecondsDoublesAndCapsAtThirtyMinutes() { + var policy = new BoundedRetryPolicy(12, 5, 1800, 0); + assertThat(policy.delayAfterFailure(1).toSeconds()).isEqualTo(5); + assertThat(policy.delayAfterFailure(2).toSeconds()).isEqualTo(10); + assertThat(policy.delayAfterFailure(9).toSeconds()).isEqualTo(1280); + assertThat(policy.delayAfterFailure(10).toSeconds()).isEqualTo(1800); + assertThat(policy.delayAfterFailure(Integer.MAX_VALUE).toSeconds()).isEqualTo(1800); + assertThat(policy.exhausted(11)).isFalse(); + assertThat(policy.exhausted(12)).isTrue(); + } + @Test void jitterStaysBoundedEvenAtMaximumDelay() { + var policy = new BoundedRetryPolicy(12, 5, 1800, 0.1); + assertThat(policy.delayAfterFailure(1, 0).toMillis()).isEqualTo(4500); + assertThat(policy.delayAfterFailure(1, 1).toMillis()).isEqualTo(5500); + assertThat(policy.delayAfterFailure(20, 1).toSeconds()).isEqualTo(1800); + assertThat(policy.delayAfterFailure(20, 0).toSeconds()).isEqualTo(1620); + } + @Test void invalidConfigurationFailsClosed() { + assertThatThrownBy(() -> new BoundedRetryPolicy(0, 5, 1800, 0.1)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new BoundedRetryPolicy(12, 0, 1800, 0.1)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new BoundedRetryPolicy(12, 5, 3, 0.1)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new BoundedRetryPolicy(12, 5, 1800, Double.NaN)).isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/common-core/src/test/java/com/baiyue/common/core/EventDeliveryJournalTest.java b/common-core/src/test/java/com/baiyue/common/core/EventDeliveryJournalTest.java new file mode 100644 index 000000000..48b4f0083 --- /dev/null +++ b/common-core/src/test/java/com/baiyue/common/core/EventDeliveryJournalTest.java @@ -0,0 +1,83 @@ +package com.baiyue.common.core; + +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import com.baiyue.common.core.outbox.EventDeliveryJournal.Outcome; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import java.time.LocalDateTime; +import java.util.UUID; +import static org.assertj.core.api.Assertions.*; + +class EventDeliveryJournalTest { + JdbcTemplate jdbc; + EventDeliveryJournal journal; + DataSourceTransactionManager transactions; + @BeforeEach void setup() { + var source = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + jdbc = new JdbcTemplate(source); + transactions = new DataSourceTransactionManager(source); + journal = new EventDeliveryJournal(jdbc, transactions); + jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))"); + jdbc.execute("CREATE TABLE work(id INT PRIMARY KEY,state VARCHAR(20),owner VARCHAR(36))"); + jdbc.update("INSERT INTO work VALUES(1,'PENDING',NULL)"); + } + boolean begin(String owner) { + return journal.begin(owner, "firstleg", "FirstLegLoadingPlanCompleted", "1", UUID.randomUUID().toString(), "lastleg-events", LocalDateTime.now(), + () -> jdbc.update("UPDATE work SET state='PROCESSING',owner=? WHERE id=1 AND state='PENDING'", owner) == 1); + } + int finish(String owner) { return jdbc.update("UPDATE work SET state='PUBLISHED',owner=NULL WHERE id=1 AND owner=?", owner); } + String state() { return jdbc.queryForObject("SELECT state FROM work WHERE id=1", String.class); } + String outcome(String attempt) { return jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt WHERE attempt_id=?", String.class, attempt); } + + @Test void startedIsCommittedBeforeCallerContinuesEvenWithinAnOuterTransaction() { + new TransactionTemplate(transactions).executeWithoutResult(status -> { + assertThat(begin("a")).isTrue(); + // Another connection sees STARTED before any HTTP would be invoked. + var other = new JdbcTemplate(jdbc.getDataSource()); + assertThat(other.queryForObject("SELECT outcome FROM event_delivery_attempt", String.class)).isEqualTo("STARTED"); + status.setRollbackOnly(); + }); + assertThat(state()).isEqualTo("PROCESSING"); + assertThat(outcome("a")).isEqualTo("STARTED"); // process interruption leaves diagnostic evidence + assertThat(begin("b")).isFalse(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_attempt", Integer.class)).isEqualTo(1); + } + @Test void startAuditFailureRollsBackTheClaim() { + assertThat(begin("a")).isTrue(); + jdbc.update("UPDATE work SET state='PENDING',owner=NULL"); + assertThatThrownBy(() -> begin("a")).isInstanceOf(RuntimeException.class); + assertThat(state()).isEqualTo("PENDING"); + } + @Test void completionAuditFailureRollsBackOutboxAndPreservesStartedRecord() { + begin("a"); + jdbc.execute("ALTER TABLE event_delivery_attempt ADD CONSTRAINT reject_applied CHECK(outcome <> 'APPLIED')"); + assertThatThrownBy(() -> journal.complete("a", Outcome.APPLIED, LocalDateTime.now(), null, null, () -> finish("a"))).isInstanceOf(RuntimeException.class); + assertThat(state()).isEqualTo("PROCESSING"); + assertThat(outcome("a")).isEqualTo("STARTED"); + jdbc.execute("ALTER TABLE event_delivery_attempt DROP CONSTRAINT reject_applied"); + assertThat(journal.complete("a", Outcome.APPLIED, LocalDateTime.now(), null, null, () -> finish("a"))).isTrue(); + assertThat(state()).isEqualTo("PUBLISHED"); + assertThat(outcome("a")).isEqualTo("APPLIED"); + assertThat(jdbc.queryForObject("SELECT duration_ms FROM event_delivery_attempt", Long.class)).isNotNegative(); + } + @Test void recoveryAndLateResultCannotOverwriteTheNewOwnerOrItsAudit() { + begin("a"); + var retryAt = LocalDateTime.now().plusSeconds(5); + assertThat(journal.complete("a", Outcome.LEASE_EXPIRED, LocalDateTime.now(), "LEASE_EXPIRED", retryAt, + () -> jdbc.update("UPDATE work SET state='PENDING',owner=NULL WHERE owner='a'"))).isTrue(); + begin("b"); + assertThat(journal.complete("a", Outcome.APPLIED, LocalDateTime.now(), null, null, () -> finish("a"))).isFalse(); + assertThat(outcome("a")).isEqualTo("LEASE_EXPIRED"); + assertThat(outcome("b")).isEqualTo("STARTED"); + assertThat(state()).isEqualTo("PROCESSING"); + } + @Test void unexpectedCompletionWithoutStartedRecordRollsBackInsteadOfUsingAFallback() { + jdbc.update("UPDATE work SET state='PROCESSING',owner='missing'"); + assertThatThrownBy(() -> journal.complete("missing", Outcome.APPLIED, LocalDateTime.now(), null, null, () -> finish("missing"))).isInstanceOf(RuntimeException.class); + assertThat(state()).isEqualTo("PROCESSING"); + } +} diff --git a/common-core/src/test/java/com/baiyue/common/core/EventOperationsTokenGuardTest.java b/common-core/src/test/java/com/baiyue/common/core/EventOperationsTokenGuardTest.java new file mode 100644 index 000000000..688006950 --- /dev/null +++ b/common-core/src/test/java/com/baiyue/common/core/EventOperationsTokenGuardTest.java @@ -0,0 +1,23 @@ +package com.baiyue.common.core; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.outbox.EventOperationsTokenGuard; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class EventOperationsTokenGuardTest { + @Test void missingOrShortConfigurationDisablesOperations() { + for (String configured : new String[] {null, "", "short"}) { + var guard = new EventOperationsTokenGuard(configured); + assertThatThrownBy(() -> guard.require(configured)).isInstanceOf(BizException.class); + } + } + @Test void onlyExactCredentialIsAccepted() { + String token = "operation-secret-".repeat(3); + var guard = new EventOperationsTokenGuard(token); + assertThatCode(() -> guard.require(token)).doesNotThrowAnyException(); + for (String supplied : new String[] {null, "", token + " ", token.toUpperCase(), "a".repeat(4097)}) { + assertThatThrownBy(() -> guard.require(supplied)).isInstanceOf(BizException.class); + } + } +} diff --git a/common-core/src/test/java/com/baiyue/common/core/OutboxOperationsTest.java b/common-core/src/test/java/com/baiyue/common/core/OutboxOperationsTest.java new file mode 100644 index 000000000..2570a4853 --- /dev/null +++ b/common-core/src/test/java/com/baiyue/common/core/OutboxOperationsTest.java @@ -0,0 +1,122 @@ +package com.baiyue.common.core; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.outbox.OutboxOperations; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Isolated; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Set; +import java.util.TimeZone; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import static org.assertj.core.api.Assertions.*; + +@Isolated("UTC retry regression temporarily changes the JVM default time zone") +class OutboxOperationsTest { + private static final String HASH = "sha256:" + "a".repeat(64); + private JdbcTemplate jdbc; + private OutboxOperations operations; + + @BeforeEach void setup() { + var source = new JdbcDataSource(); + source.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000"); + jdbc = new JdbcTemplate(source); + jdbc.execute("CREATE TABLE events(id VARCHAR(64) PRIMARY KEY,payload_json VARCHAR(200),status VARCHAR(20),retry_count INT,last_error VARCHAR(1000),next_retry_at TIMESTAMP,locked_by VARCHAR(100),locked_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE event_delivery_retry_audit(request_id VARCHAR(36) PRIMARY KEY,producer_table VARCHAR(100),entry_id VARCHAR(64),event_id VARCHAR(64),event_type VARCHAR(100),payload_hash VARCHAR(71),operator_name VARCHAR(128),reason VARCHAR(500),previous_failures INT,previous_error VARCHAR(1000),requested_at TIMESTAMP)"); + operations = new OutboxOperations(jdbc, new DataSourceTransactionManager(source), + new OutboxOperations.Layout("events", "id", "status", Set.of("business.completed")), + payload -> new OutboxOperations.Identity(payload, "business.completed", HASH)); + jdbc.update("INSERT INTO events VALUES('1','event-1','DEAD',12,'timeout',CURRENT_TIMESTAMP,'old-worker',CURRENT_TIMESTAMP)"); + } + private OutboxOperations.RetryRequest request() { + return new OutboxOperations.RetryRequest(UUID.randomUUID().toString(), "operator-7", "dependency restored", HASH); + } + + @Test void retryPreservesPayloadAndAuditsPreviousFailureThenDeduplicates() { + var request = request(); + assertThat(operations.retry("1", request).duplicate()).isFalse(); + assertThat(operations.retry("1", request).duplicate()).isTrue(); + assertThat(jdbc.queryForMap("SELECT * FROM events WHERE id='1'")) + .containsEntry("PAYLOAD_JSON", "event-1").containsEntry("STATUS", "PENDING") + .containsEntry("RETRY_COUNT", 0).containsEntry("LOCKED_BY", null).containsEntry("LAST_ERROR", null); + assertThat(jdbc.queryForMap("SELECT * FROM event_delivery_retry_audit")) + .containsEntry("PREVIOUS_FAILURES", 12).containsEntry("PREVIOUS_ERROR", "timeout") + .containsEntry("OPERATOR_NAME", "operator-7").containsEntry("PAYLOAD_HASH", HASH); + } + + @Test void auditInsertFailureRollsBackRequeue() { + jdbc.execute("ALTER TABLE event_delivery_retry_audit ADD CONSTRAINT reject_audit CHECK(previous_failures < 0)"); + assertThatThrownBy(() -> operations.retry("1", request())).isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class); + assertThat(operations.inspect("1").status()).isEqualTo("DEAD"); + assertThat(operations.inspect("1").failures()).isEqualTo(12); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit", Integer.class)).isZero(); + } + + @Test void retryIsImmediatelyDueInUtcUnderShanghaiDefaultZoneAndReplayKeepsTimes() { + TimeZone original = TimeZone.getDefault(); + try { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai")); + var request = request(); + LocalDateTime before = LocalDateTime.now(ZoneOffset.UTC).minusSeconds(1); + assertThat(operations.retry("1", request).duplicate()).isFalse(); + LocalDateTime nextRetry = jdbc.queryForObject("SELECT next_retry_at FROM events WHERE id='1'", + (row, index) -> row.getObject(1, LocalDateTime.class)); + LocalDateTime requested = jdbc.queryForObject("SELECT requested_at FROM event_delivery_retry_audit", + (row, index) -> row.getObject(1, LocalDateTime.class)); + LocalDateTime scan = LocalDateTime.now(ZoneOffset.UTC); + // Allow database timestamp precision rounding, but never a default-zone offset. + assertThat(nextRetry).isBetween(before, scan.plusSeconds(1)); + assertThat(requested).isEqualTo(nextRetry); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM events WHERE id='1' AND status='PENDING' AND next_retry_at<=?", + Integer.class, scan)).isEqualTo(1); + assertThat(operations.retry("1", request).duplicate()).isTrue(); + assertThat(jdbc.queryForObject("SELECT next_retry_at FROM events WHERE id='1'", + (row, index) -> row.getObject(1, LocalDateTime.class))).isEqualTo(nextRetry); + assertThat(jdbc.queryForObject("SELECT requested_at FROM event_delivery_retry_audit", + (row, index) -> row.getObject(1, LocalDateTime.class))).isEqualTo(requested); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit", Integer.class)).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT payload_json FROM events WHERE id='1'", String.class)).isEqualTo("event-1"); + } finally { + TimeZone.setDefault(original); + } + } + + @Test void staleHashAndNonDeadAndReusedRequestAreRejected() { + var original = request(); + var stale = new OutboxOperations.RetryRequest(original.requestId(), original.operator(), original.reason(), "sha256:" + "b".repeat(64)); + assertThatThrownBy(() -> operations.retry("1", stale)).isInstanceOf(BizException.class); + operations.retry("1", original); + assertThatThrownBy(() -> operations.retry("1", request())).isInstanceOf(BizException.class); + var changed = new OutboxOperations.RetryRequest(original.requestId(), "someone-else", original.reason(), HASH); + assertThatThrownBy(() -> operations.retry("1", changed)).isInstanceOf(BizException.class); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit", Integer.class)).isEqualTo(1); + } + + @Test void concurrentSameRequestHasOneAuditAndOneDuplicate() throws Exception { + var request = request(); + var start = new CountDownLatch(1); + var pool = Executors.newFixedThreadPool(2); + try { + var first = pool.submit(() -> { start.await(); return operations.retry("1", request); }); + var second = pool.submit(() -> { start.await(); return operations.retry("1", request); }); + start.countDown(); + assertThat(first.get(10, TimeUnit.SECONDS).duplicate()).isNotEqualTo(second.get(10, TimeUnit.SECONDS).duplicate()); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM event_delivery_retry_audit", Integer.class)).isEqualTo(1); + } finally { pool.shutdownNow(); } + } + + @Test void crossEntryRequestCollisionRollsBackSecondEntry() { + jdbc.update("INSERT INTO events VALUES('2','event-2','DEAD',12,'timeout',CURRENT_TIMESTAMP,NULL,NULL)"); + var request = request(); + operations.retry("1", request); + assertThatThrownBy(() -> operations.retry("2", request)).isInstanceOf(BizException.class); + assertThat(operations.inspect("2").status()).isEqualTo("DEAD"); + } +} diff --git a/common-event-contract/AGENTS.md b/common-event-contract/AGENTS.md index c1ce19dbe..ab5e442cc 100644 --- a/common-event-contract/AGENTS.md +++ b/common-event-contract/AGENTS.md @@ -2,8 +2,8 @@ ## 职责边界 -- 维护国内头程与国外尾程之间稳定、可版本化的事件契约。 -- 只放事件信封、类型、区域、标识、规范化、哈希和校验;不放任一侧领域 Entity、数据库模型或发送实现。 +- 维护 Firstleg、Lastleg、Finance 之间稳定、可版本化的业务事件契约。 +- 只放事件信封、类型、标识、规范化、哈希和校验;不放部署区域、领域 Entity、数据库模型或发送实现。 ## 快速索引 @@ -13,24 +13,23 @@ | 契约校验 | `EventEnvelopeValidator.java` | | 事件 ID 与业务键 | `EventIdentifiers.java` | | Payload 规范化与哈希 | `EventPayloadCanonicalizer.java`、`EventPayloadHash.java` | -| 事件类型与区域 | `EventTypes.java`、`RegionCode.java` | +| 事件类型与直接目标 | `EventTypes.java`、`EventDeliveryRoute.java`;明确白名单,不按区域或默认目标分派 | | 头程装柜完成 V3(当前生产) | `FirstLegLoadingPlanCompletedV3.java` 在 V2 订单快照上增加物理柜空仓、半仓、满仓、锁仓、带锁码五类不可变照片引用;订单级仍冻结 `destinationType=FBA|OVERSEAS`,FBA 可按箱数接收,OVERSEAS 逐明细冻结与实装箱数一致的 SKU/箱号;国内生产者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletedOutboxService.java`,海外消费者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java`;V1/V2 仅用于历史事件读取 | | 头程装柜完成 V1 解析模型 | `FirstLegLoadingPlanCompletedV1.java`;用于消费端冻结字段映射,新增生产不得回退为 V1 | | 海外卸柜完成 V1 | `OverseasContainerUnloadedV1.java`;按来源柜预订 ID 做柜级聚合/幂等,同一柜只发布一次,信封固定使用 `tenantId=0` 平台技术范围;`unloadingCompletedAt` 必须是全柜 complete 时刻,不得用签收拍照时刻代替;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`,国内消费者见 `../firstleg/src/main/java/com/baiyue/firstleg/service/OverseasContainerEventConsumer.java` | | 海外入仓单暂扣变更 V1 | `OverseasInboundOrderDetainedChangedV1.java`;聚合键固定为来源头程入仓单 ID,暂扣与解除暂扣均由尾程 Outbox 回传,国内按入仓单绑定、客户归属及聚合版本单调消费 | | 海外派送完成 V1 | `OverseasDeliveryCompletedV1.java`、`EventTypes.OVERSEAS_DELIVERY_COMPLETED`;每个按 FBA 仓拆分的派送单完成后,按关联来源入仓单分别发布,携带来源订单、入仓码、FBA 仓和签收时间,国内幂等推进对应 Firstleg 入仓单与 Order 完成 | | 国内船节点记录 V1 | `FirstLegContainerShippingNodeRecordedV1.java`;按 `containerBookingId` 柜级聚合,不携带客户,信封固定使用 `tenantId=0` 平台技术范围;只接受十节点中带实际时间和完整供应商审计字段的事实,国内生产者 `../firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/CarrierScheduleNodeOutboxService.java`,海外消费者 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | -| 海外业务费用来源变更 V1 | `BusinessFeeSourceChangedV1.java`、`EventTypes.BUSINESS_FEE_SOURCE_CHANGED`;携带可独立校验入池的完整不可变客户、业务、方向、供应商、付款、来源版本与哈希快照,长整型标识使用字符串;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/financefee/`,Event Bridge 定向投递国内 `finance/`,Finance 不跨区同步回查 | +| 海外业务费用来源变更 V1 | `BusinessFeeSourceChangedV1.java`、`EventTypes.BUSINESS_FEE_SOURCE_CHANGED`;携带可独立校验入池的完整不可变客户、业务、方向、供应商、付款、来源版本与哈希快照,长整型标识使用字符串;海外生产者见 `../lastleg-service/src/main/java/com/baiyue/lastleg/financefee/`,Lastleg Outbox 直接投递 `finance/`,Finance 不跨区同步回查 | | 海外业务资金指令与结果 V1 | `WalletCommandRequestedV1.java`、`WalletCommandCompletedV1.java`;海外业务通过 Outbox 请求国内 Finance 冻结、结算或释放,Finance 执行后再经 Outbox 回传结果,双方以事件与业务幂等键防重,不跨区域同步调用钱包 | -| IAM 安全事件 V1 | `IamSecurityEventsV1.java`;账号、授权快照等安全事件的冻结模型 | | 契约说明 | `package-info.java` | | 契约测试 | `src/test/java/com/baiyue/common/event/EventEnvelopeTest.java`、`FirstLegLoadingPlanCompletedV1Test.java`、`FirstLegLoadingPlanCompletedV2Test.java`、`FirstLegLoadingPlanCompletedV3Test.java`、`OverseasContainerUnloadedV1Test.java`、`FirstLegContainerShippingNodeRecordedV1Test.java` | | 真实生产/消费验收 | `../firstleg/src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`、`../lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java` | ## 修改与验证 -- 所有字段与事件类型都视为跨仓公共契约;当前正式模型不保留旧事件字段、旧版本或兼容解析,契约变化时直接修改当前模型,并同步更新 `event-bridge-service/`、国内生产者、尾程消费者和全部契约测试。若存在不可变外部事件契约,先单独定义版本升级规则。 +- 所有字段与事件类型都视为跨仓公共契约;当前正式模型不保留旧事件字段、旧版本或兼容解析,契约变化时直接修改当前模型,并同步更新 Firstleg/Lastleg/Finance 直接生产者、消费者和全部契约测试。若存在不可变外部事件契约,先单独定义版本升级规则。 - 哈希算法或规范化规则变化必须同步更新全部生产者和消费者,并保留跨语言确定性测试。 -- MQ 事件不携带用户 Access Token;生产者和消费者使用 MQ TLS/ACL 或内部服务身份,事件信封负责事件 ID、业务键、来源/目标区域、版本和 Payload 哈希。 -- `OVERSEAS_CONTAINER_UNLOADED` 固定从 `OVERSEAS` 发往 `CN`,用于回传海外 PDA 卸柜完成及卸柜照片上传时间;预计报空时间由国内头程按照片上传时间加两天计算,实际报空时间允许后续补充。 +- 业务事件不携带用户 Access Token;直接投递使用内部服务身份,信封保留事件ID、业务类型/版本、客户、聚合、追踪及Payload哈希,不再包含sourceRegion/targetRegion。 +- `OVERSEAS_CONTAINER_UNLOADED` 固定由Lastleg Outbox直投Firstleg,用于回传海外 PDA 卸柜完成及卸柜照片上传时间;预计报空时间规则沿用现业务实现,实际报空时间允许后续补充。 - 从父目录运行 `mvn -pl common-event-contract -am test`。 diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/DeliveryAck.java b/common-event-contract/src/main/java/com/baiyue/common/event/DeliveryAck.java new file mode 100644 index 000000000..803416c07 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/DeliveryAck.java @@ -0,0 +1,31 @@ +package com.baiyue.common.event; + +import java.util.Objects; + +/** A consumer returns this only after its durable processing transaction commits. */ +public record DeliveryAck(String eventId, String payloadHash, Outcome outcome) { + public enum Outcome { APPLIED, DUPLICATE, STALE } + + public DeliveryAck { + if (!EventIdentifiers.isSupported(eventId)) throw new IllegalArgumentException("Invalid ACK eventId"); + if (payloadHash == null || !payloadHash.matches("sha256:[0-9a-fA-F]{64}")) { + throw new IllegalArgumentException("Invalid ACK payloadHash"); + } + Objects.requireNonNull(outcome, "ACK outcome is required"); + } + + public static DeliveryAck of(EventEnvelope event, Outcome outcome) { + return new DeliveryAck(event.eventId(), event.payloadHash(), outcome); + } + + /** HTTP and ApiResponse status must also be checked by the transport. */ + public void requireMatches(EventEnvelope sent) { + if (!eventId.equals(sent.eventId()) || !payloadHash.equalsIgnoreCase(sent.payloadHash())) { + throw new IllegalArgumentException("Consumer ACK does not match the sent event identity and payload"); + } + EventDeliveryRoute route = EventDeliveryRoute.forEventType(sent.eventType()); + if (outcome == Outcome.STALE && !route.acceptsStale()) { + throw new IllegalArgumentException("STALE is not an allowed result for " + sent.eventType()); + } + } +} diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventDeliveryRoute.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventDeliveryRoute.java new file mode 100644 index 000000000..485e1563d --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventDeliveryRoute.java @@ -0,0 +1,25 @@ +package com.baiyue.common.event; + +/** Explicit business-event destinations. There is no regional or default destination. */ +public record EventDeliveryRoute(String serviceName, String path, boolean acceptsStale) { + public static EventDeliveryRoute forEventType(String eventType) { + if (eventType == null) throw new IllegalArgumentException("Event type is required"); + return switch (eventType) { + case EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, EventTypes.WALLET_COMMAND_COMPLETED -> + new EventDeliveryRoute("lastleg-service", "/internal/lastleg/events", false); + case EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED -> + new EventDeliveryRoute("lastleg-service", "/internal/lastleg/events", true); + case EventTypes.OVERSEAS_CONTAINER_UNLOADED, EventTypes.OVERSEAS_DELIVERY_COMPLETED -> + new EventDeliveryRoute("firstleg-service", "/internal/firstleg/v1/container-events", false); + case EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED -> + new EventDeliveryRoute("firstleg-service", "/internal/firstleg/v1/container-events", true); + case EventTypes.WALLET_COMMAND_REQUESTED -> + new EventDeliveryRoute("finance-service", "/internal/finance/v1/wallet-command-events", false); + case EventTypes.BUSINESS_FEE_SOURCE_CHANGED -> + new EventDeliveryRoute("finance-service", "/internal/finance/v1/business-fee-source-events", false); + case EventTypes.COMPANY_VEHICLE_COST_SUBMITTED -> + new EventDeliveryRoute("finance-service", "/internal/finance/v1/vehicle-costs/events", false); + default -> throw new IllegalArgumentException("Unsupported direct-delivery event type: " + eventType); + }; + } +} diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventDependencyNotReadyException.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventDependencyNotReadyException.java new file mode 100644 index 000000000..e7b00596c --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventDependencyNotReadyException.java @@ -0,0 +1,7 @@ +package com.baiyue.common.event; + +/** Required business state has not arrived yet; retry the same event with backoff. */ +public class EventDependencyNotReadyException extends IllegalArgumentException { + public EventDependencyNotReadyException(String message) { super(message); } + public EventDependencyNotReadyException(String message, Throwable cause) { super(message, cause); } +} diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventEnvelope.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventEnvelope.java index d4cc2775b..b4d91c3d3 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/EventEnvelope.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventEnvelope.java @@ -5,7 +5,7 @@ import com.fasterxml.jackson.databind.JsonNode; import java.time.Instant; /** - * 跨服务和跨区域传输使用的不可变事件信封。 + * 统一测试环境中跨业务服务传输使用的不可变事件信封。 * *

租户 ID 和聚合 ID 使用字符串,避免跨语言传输时发生大整数精度丢失。 * {@code payloadHash} 是原始规范化负载字节的 SHA-256 摘要。

@@ -14,8 +14,6 @@ import java.time.Instant; * @param eventType 事件类型,不包含版本号 * @param eventVersion 事件契约版本,从 1 开始 * @param occurredAt 业务事实实际发生时间,使用 UTC 时间点 - * @param sourceRegion 事件来源业务单元 - * @param targetRegion 事件目标业务单元;国内一期允许与来源区域相同 * @param tenantId 事件所属客户租户 ID * @param aggregateType 聚合类型 * @param aggregateId 聚合 ID @@ -32,8 +30,6 @@ public record EventEnvelope( String eventType, int eventVersion, Instant occurredAt, - RegionCode sourceRegion, - RegionCode targetRegion, String tenantId, String aggregateType, String aggregateId, diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventEnvelopeValidator.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventEnvelopeValidator.java index 980c7b5b1..f0ca66922 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/EventEnvelopeValidator.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventEnvelopeValidator.java @@ -37,12 +37,6 @@ public final class EventEnvelopeValidator { if (envelope.occurredAt() == null) { errors.add("occurredAt 不能为空"); } - if (envelope.sourceRegion() == null) { - errors.add("sourceRegion 不能为空"); - } - if (envelope.targetRegion() == null) { - errors.add("targetRegion 不能为空"); - } requireText(errors, "tenantId", envelope.tenantId(), MAX_ID_LENGTH); if (envelope.aggregateType() == null || !AGGREGATE_TYPE_PATTERN.matcher(envelope.aggregateType()).matches()) { @@ -75,7 +69,7 @@ public final class EventEnvelopeValidator { public static void requireValid(EventEnvelope envelope) { List errors = validate(envelope); if (!errors.isEmpty()) { - throw new IllegalArgumentException(String.join(";", errors)); + throw new EventProtocolException(String.join(";", errors)); } } diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventProtocolException.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventProtocolException.java new file mode 100644 index 000000000..7438b5507 --- /dev/null +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventProtocolException.java @@ -0,0 +1,7 @@ +package com.baiyue.common.event; + +/** An immutable event violates the consumption protocol; retrying unchanged data cannot fix it. */ +public class EventProtocolException extends IllegalArgumentException { + public EventProtocolException(String message) { super(message); } + public EventProtocolException(String message, Throwable cause) { super(message, cause); } +} diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java index 17172f544..cb25e5b9b 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java @@ -16,11 +16,9 @@ public final class EventTypes { public static final String OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED = "OverseasInboundOrderDetainedChanged"; public static final String OVERSEAS_DELIVERY_COMPLETED = "OverseasDeliveryCompleted"; public static final String BUSINESS_FEE_SOURCE_CHANGED = "BusinessFeeSourceChanged"; + public static final String COMPANY_VEHICLE_COST_SUBMITTED = "CompanyVehicleCostSubmitted"; public static final String WALLET_COMMAND_REQUESTED = "WalletCommandRequested"; public static final String WALLET_COMMAND_COMPLETED = "WalletCommandCompleted"; - public static final String IAM_USER_STATUS_CHANGED = "IamUserStatusChanged"; - public static final String IAM_TENANT_STATUS_CHANGED = "IamTenantStatusChanged"; - public static final String IAM_PERMISSION_VERSION_CHANGED = "IamPermissionVersionChanged"; public static final String LAST_MILE_HANDOFF_ACCEPTED = "LastMileHandoffAccepted"; public static final String LAST_MILE_HANDOFF_REJECTED = "LastMileHandoffRejected"; public static final String LAST_MILE_MILESTONE_CHANGED = "LastMileMilestoneChanged"; diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/IamSecurityEventsV1.java b/common-event-contract/src/main/java/com/baiyue/common/event/IamSecurityEventsV1.java deleted file mode 100644 index 8d1251801..000000000 --- a/common-event-contract/src/main/java/com/baiyue/common/event/IamSecurityEventsV1.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.baiyue.common.event; - -import java.time.Instant; -import java.util.Set; - -/** IAM 向海外服务发布的 V1 安全投影事件负载。 */ -public final class IamSecurityEventsV1 { - public static final int VERSION = 1; - - private IamSecurityEventsV1() { } - - public record UserStatusChanged(long authUserId, long tenantId, String appCode, - String status, long permissionVersion, Instant changedAt) { } - - public record TenantStatusChanged(long tenantId, String status, Instant changedAt) { } - - public record PermissionVersionChanged(long authUserId, long tenantId, String appCode, - long permissionVersion, String reason, Instant changedAt, - Set roles, Set authorities) { - public PermissionVersionChanged { - roles = roles == null ? Set.of() : Set.copyOf(roles); - authorities = authorities == null ? Set.of() : Set.copyOf(authorities); - } - } -} diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/RegionCode.java b/common-event-contract/src/main/java/com/baiyue/common/event/RegionCode.java deleted file mode 100644 index f70c608c2..000000000 --- a/common-event-contract/src/main/java/com/baiyue/common/event/RegionCode.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.baiyue.common.event; - -/** - * 事件来源与目标部署区域。 - * - *

区域表示独立业务单元,不表示用户当前所在地。

- */ -public enum RegionCode { - /** 国内业务单元。 */ - CN, - /** 海外业务单元。 */ - OVERSEAS -} diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/DeliveryAckTest.java b/common-event-contract/src/test/java/com/baiyue/common/event/DeliveryAckTest.java new file mode 100644 index 000000000..1961163a1 --- /dev/null +++ b/common-event-contract/src/test/java/com/baiyue/common/event/DeliveryAckTest.java @@ -0,0 +1,51 @@ +package com.baiyue.common.event; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import java.time.Instant; +import java.util.UUID; +import static org.assertj.core.api.Assertions.*; + +class DeliveryAckTest { + private EventEnvelope event(String type) { + return new EventEnvelope(UUID.randomUUID().toString(), type, 1, Instant.now(), "0", "TEST", "1", 1, null, + null, null, null, new ObjectMapper().createObjectNode(), "sha256:" + "a".repeat(64)); + } + + @Test + void roundTripsAndAcceptsOnlyTheSentIdentityAndHash() throws Exception { + var event = event(EventTypes.WALLET_COMMAND_REQUESTED); + var ack = DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED); + var mapper = new ObjectMapper(); + assertThat(mapper.readValue(mapper.writeValueAsString(ack), DeliveryAck.class)).isEqualTo(ack); + assertThatCode(() -> ack.requireMatches(event)).doesNotThrowAnyException(); + assertThatThrownBy(() -> ack.requireMatches(event(EventTypes.WALLET_COMMAND_REQUESTED))).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new DeliveryAck(event.eventId(), "sha256:" + "b".repeat(64), DeliveryAck.Outcome.APPLIED) + .requireMatches(event)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> mapper.readValue("{\"eventId\":\"" + event.eventId() + "\",\"payloadHash\":\"" + event.payloadHash() + "\",\"outcome\":\"RECEIVED\"}", DeliveryAck.class)) + .isInstanceOf(Exception.class); + } + + @Test + void staleCannotAcknowledgeWalletCommandsResultsOrIndependentFees() { + for (String type : new String[]{EventTypes.WALLET_COMMAND_REQUESTED, EventTypes.WALLET_COMMAND_COMPLETED, + EventTypes.BUSINESS_FEE_SOURCE_CHANGED, EventTypes.OVERSEAS_DELIVERY_COMPLETED, EventTypes.COMPANY_VEHICLE_COST_SUBMITTED}) { + var event = event(type); + assertThatThrownBy(() -> DeliveryAck.of(event, DeliveryAck.Outcome.STALE).requireMatches(event)) + .isInstanceOf(IllegalArgumentException.class); + assertThatCode(() -> DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE).requireMatches(event)).doesNotThrowAnyException(); + } + } + + @Test + void mixedLastlegOutboxRoutesWalletToFinanceAndUnloadingToFirstleg() { + assertThat(EventDeliveryRoute.forEventType(EventTypes.COMPANY_VEHICLE_COST_SUBMITTED)) + .isEqualTo(new EventDeliveryRoute("finance-service", "/internal/finance/v1/vehicle-costs/events", false)); + assertThat(EventDeliveryRoute.forEventType(EventTypes.WALLET_COMMAND_REQUESTED)) + .isEqualTo(new EventDeliveryRoute("finance-service", "/internal/finance/v1/wallet-command-events", false)); + assertThat(EventDeliveryRoute.forEventType(EventTypes.OVERSEAS_CONTAINER_UNLOADED).serviceName()).isEqualTo("firstleg-service"); + assertThat(EventDeliveryRoute.forEventType(EventTypes.WALLET_COMMAND_COMPLETED).serviceName()).isEqualTo("lastleg-service"); + assertThatThrownBy(() -> EventDeliveryRoute.forEventType("UnknownEvent")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> EventDeliveryRoute.forEventType("IamUserStatusChanged")).isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/EventEnvelopeTest.java b/common-event-contract/src/test/java/com/baiyue/common/event/EventEnvelopeTest.java index 569608b99..b2d115827 100644 --- a/common-event-contract/src/test/java/com/baiyue/common/event/EventEnvelopeTest.java +++ b/common-event-contract/src/test/java/com/baiyue/common/event/EventEnvelopeTest.java @@ -13,7 +13,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -@DisplayName("跨区域事件信封契约") +@DisplayName("跨服务事件信封契约") class EventEnvelopeTest { private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); @@ -26,6 +26,8 @@ class EventEnvelopeTest { EventEnvelope restored = objectMapper.readValue(json, EventEnvelope.class); assertThat(restored).isEqualTo(source); + assertThat(objectMapper.readTree(json).has("sourceRegion")).isFalse(); + assertThat(objectMapper.readTree(json).has("targetRegion")).isFalse(); assertThat(objectMapper.readTree(json).path("eventType").asText()) .isEqualTo(EventTypes.FIRST_LEG_SHIPMENT_DEPARTED); } @@ -40,7 +42,7 @@ class EventEnvelopeTest { @Test void validatorReturnsAllBasicErrorsAtOnce() { EventEnvelope envelope = new EventEnvelope( - "bad-id", "bad_type", 0, null, null, null, " ", "bad-type", " ", + "bad-id", "bad_type", 0, null, " ", "bad-type", " ", -1, 0L, " ", " ", "bad-cause", null, "bad-hash"); List errors = EventEnvelopeValidator.validate(envelope); @@ -90,8 +92,6 @@ class EventEnvelopeTest { EventTypes.FIRST_LEG_SHIPMENT_DEPARTED, 1, Instant.parse("2026-07-29T08:30:00Z"), - RegionCode.CN, - RegionCode.OVERSEAS, "20001", "FIRST_LEG_SHIPMENT", "90001", diff --git a/common-security/AGENTS.md b/common-security/AGENTS.md index f53733015..cf49042bd 100644 --- a/common-security/AGENTS.md +++ b/common-security/AGENTS.md @@ -10,19 +10,20 @@ | 功能 | 入口 | |---|---| | 安全链自动配置 | `src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java` | -| JWT 解码模式 | `JwksJwtDecoderAutoConfiguration.java`(国内与海外统一 RS256/JWKS);`HmacJwtDecoderAutoConfiguration.java` 仅作清理前的历史实现定位,不得作为跨区域共享密钥方案 | +| JWT 解码模式 | `JwksJwtDecoderAutoConfiguration.java`(国内与海外统一 RS256/JWKS) | | JWT 过滤与声明映射 | `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java` | | 当前用户与认证对象 | `AuthenticatedUser.java`、`BaiyueJwtAuthenticationToken.java`;海外 PDA 仓范围由 `JwtClaimsMapper.java` 将 `overseas_warehouse_id` 映射为 `overseasWarehouseId` | -| 会话与授权来源 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`(国内登录 Token 即时失效)、`ClaimsAccessTokenSessionValidator.java`(海外访问 Token 本地短时 Claims)、`RedisTokenKeys.java` | +| 会话与授权来源 | `AccessTokenSessionValidator.java`、`RedisAccessTokenSessionValidator.java`(所有服务共享会话和权限快照)、`RedisTokenKeys.java`;`HttpSessionStateVerifier.java` 每请求调用 Auth 权威状态校验,超时/错误失败关闭 | | 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/` | ## 修改与验证 -- 所有资源服务统一使用 `jwt-mode=jwks`、RS256、Issuer 和 JWKS;国内服务验证国内 Audience 并使用 `session-mode=redis`,海外服务只接受登录/刷新时签发的海外 Audience Token 并使用 `session-mode=claims`,不得访问国内 Redis。海外服务应缓存 JWKS 并在密钥轮换时刷新,不得每个请求访问国内 Auth;不得重新引入跨区域共享 HS256 密钥,也不得让海外服务接受国内 Token。 +- 所有资源服务只接受 RS256/JWKS,统一 Issuer、Audience 和 `session-mode=redis`。私钥仅在 Auth,业务服务使用 JWKS 公钥缓存与共享 Redis 会话;不再支持 HMAC、claims 或 gateway 会话模式,不信任请求头携带的角色权限。 - Token Claim、Redis Key、Issuer、Audience、JWKS、会话模式或认证对象变化必须同步检查 `auth/`、`gateway/`、Nacos/部署配置和所有实际业务服务。 -- `overseas_warehouse_id` 只承载 IAM 已确认的海外仓绑定;缺失、格式非法或与实时安全投影不一致时不得放宽 PDA 仓范围,Gateway 与业务服务必须 fail-closed。 +- `overseas_warehouse_id` 只承载 IAM 已确认的海外仓绑定;缺失、格式非法或会话已撤销时不得放宽 PDA 仓范围,Gateway 与业务服务必须 fail-closed。 - `JsonAuthenticationEntryPoint` 与 `JsonAccessDeniedHandler` 属于统一 HTTP 200 业务契约的例外:认证入口仍返回真实 401/403,供前端刷新 Token 和区分授权失败。 -- 不降低服务端租户、会话或权限校验;密钥只从配置中心或环境变量读取。 +- Access 先校验 Redis 会话身份,再通过 `baiyue.security.session-validation-uri` 在线核对 Auth/IAM 持久化状态;禁止正结果缓存、失败降级或请求头信任。内部调用只带服务令牌和 jwtId/userId,不透传 Bearer;4 秒总超时、1 秒连接超时、不跟随重定向。 +- 不降低服务端客户、会话或权限校验;密钥只从配置中心或环境变量读取。 - 从父目录运行 `mvn -pl common-security -am test`。 diff --git a/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java b/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java index f27f21ff9..e6eef8e8d 100644 --- a/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java +++ b/common-security/src/main/java/com/baiyue/common/security/BaiyueSecurityAutoConfiguration.java @@ -23,7 +23,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic * Servlet 服务安全自动配置,建立无状态 JWT 认证链、统一异常响应和审计操作者解析。 */ @AutoConfiguration( - after = {HmacJwtDecoderAutoConfiguration.class, JwksJwtDecoderAutoConfiguration.class}, + after = JwksJwtDecoderAutoConfiguration.class, before = SecurityAutoConfiguration.class) @EnableMethodSecurity public class BaiyueSecurityAutoConfiguration { @@ -50,16 +50,16 @@ public class BaiyueSecurityAutoConfiguration { org.springframework.beans.factory.ObjectProvider redis, ObjectMapper objectMapper, @Value("${baiyue.security.token-key-prefix:baiyue:auth}") String keyPrefix, - @Value("${baiyue.security.gateway-internal-service-token:${baiyue.security.internal-service-token:${INTERNAL_SERVICE_TOKEN:}}}") String gatewayToken, - @Value("${baiyue.security.session-mode:redis}") String sessionMode) { - AccessTokenSessionValidator validator; - if ("claims".equalsIgnoreCase(sessionMode)) validator = new ClaimsAccessTokenSessionValidator(); - else if ("gateway".equalsIgnoreCase(sessionMode)) validator = new GatewayAccessTokenSessionValidator(gatewayToken); - else { - StringRedisTemplate template=redis.getIfAvailable(); - if(template==null) throw new IllegalStateException("session-mode=redis requires StringRedisTemplate"); - validator=new RedisAccessTokenSessionValidator(template,keyPrefix,objectMapper); + @Value("${baiyue.security.session-mode:redis}") String sessionMode, + @Value("${baiyue.security.session-validation-uri:http://auth:9100/internal/auth/accounts/sessions/validate}") String validationUri, + @Value("${baiyue.security.internal-service-token}") String internalToken) { + if (!"redis".equalsIgnoreCase(sessionMode)) { + throw new IllegalStateException("Only shared Redis access sessions are supported"); } + StringRedisTemplate template = redis.getIfAvailable(); + if (template == null) throw new IllegalStateException("Redis access sessions require StringRedisTemplate"); + AccessTokenSessionValidator validator = new RedisAccessTokenSessionValidator(template, keyPrefix, objectMapper, + new HttpSessionStateVerifier(validationUri, internalToken, objectMapper)); return new JwtAuthenticationFilter(jwtDecoder, claimsMapper, entryPoint, validator); } diff --git a/common-security/src/main/java/com/baiyue/common/security/ClaimsAccessTokenSessionValidator.java b/common-security/src/main/java/com/baiyue/common/security/ClaimsAccessTokenSessionValidator.java deleted file mode 100644 index c32b30317..000000000 --- a/common-security/src/main/java/com/baiyue/common/security/ClaimsAccessTokenSessionValidator.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.baiyue.common.security; - -import org.springframework.security.oauth2.jwt.Jwt; -import java.util.LinkedHashSet; -import java.util.Set; - -/** 海外模式直接使用已由 RS256/JWKS 验证的短时令牌授权快照。 */ -public class ClaimsAccessTokenSessionValidator implements AccessTokenSessionValidator { - @Override public AccessTokenSession validate(Jwt jwt) { - Long userId=number(jwt.getClaims().getOrDefault("userId",jwt.getClaims().get("user_id"))); - return new AccessTokenSession(1,userId,values(jwt.getClaims().get("roles")), - values(jwt.getClaims().getOrDefault("authorities",jwt.getClaims().get("permissions")))); - } - private static Long number(Object value){ if(value instanceof Number n)return n.longValue(); try{return Long.valueOf(String.valueOf(value));}catch(Exception e){return null;} } - private static Set values(Object value){ Set result=new LinkedHashSet<>(); if(value instanceof Iterable it)it.forEach(v->result.add(String.valueOf(v))); else if(value!=null)for(String v:String.valueOf(value).split("[ ,]+"))if(!v.isBlank())result.add(v); return result; } -} diff --git a/common-security/src/main/java/com/baiyue/common/security/GatewayAccessTokenSessionValidator.java b/common-security/src/main/java/com/baiyue/common/security/GatewayAccessTokenSessionValidator.java deleted file mode 100644 index 062ff2fa1..000000000 --- a/common-security/src/main/java/com/baiyue/common/security/GatewayAccessTokenSessionValidator.java +++ /dev/null @@ -1,65 +0,0 @@ -package com.baiyue.common.security; - -import jakarta.servlet.http.HttpServletRequest; -import org.springframework.security.oauth2.jwt.Jwt; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.LinkedHashSet; -import java.util.Set; - -/** Uses the overseas gateway's authenticated authorization snapshot instead of large JWT claims. */ -public final class GatewayAccessTokenSessionValidator implements AccessTokenSessionValidator { - public static final String TOKEN_HEADER = "X-Internal-Service-Token"; - public static final String ROLES_HEADER = "X-Overseas-Roles"; - public static final String AUTHORITIES_HEADER = "X-Overseas-Authorities"; - - private final byte[] expectedToken; - - public GatewayAccessTokenSessionValidator(String expectedToken) { - if (expectedToken == null || expectedToken.isBlank()) { - throw new IllegalStateException("session-mode=gateway requires an internal service token"); - } - this.expectedToken = expectedToken.getBytes(StandardCharsets.UTF_8); - } - - @Override - public AccessTokenSession validate(Jwt jwt) { - throw new IllegalArgumentException("Gateway authorization headers are required"); - } - - @Override - public AccessTokenSession validate(Jwt jwt, HttpServletRequest request) { - String supplied = request.getHeader(TOKEN_HEADER); - if (supplied == null || supplied.isBlank()) { - throw new IllegalArgumentException("Missing overseas gateway authorization context"); - } - byte[] suppliedToken = supplied.getBytes(StandardCharsets.UTF_8); - if (!MessageDigest.isEqual(expectedToken, suppliedToken)) { - throw new IllegalArgumentException("Invalid overseas gateway authorization context (expectedBytes=" - + expectedToken.length + ", suppliedBytes=" + suppliedToken.length + ")"); - } - Long userId = number(jwt.getClaims().getOrDefault("userId", jwt.getClaims().get("user_id"))); - if (userId == null) { - userId = number(jwt.getSubject()); - } - return new AccessTokenSession(1, userId, values(request.getHeader(ROLES_HEADER)), - values(request.getHeader(AUTHORITIES_HEADER))); - } - - private static Long number(Object value) { - if (value instanceof Number number) return number.longValue(); - try { return value == null ? null : Long.valueOf(value.toString()); } - catch (NumberFormatException exception) { return null; } - } - - private static Set values(String header) { - if (header == null || header.isBlank()) return Set.of(); - LinkedHashSet values = new LinkedHashSet<>(); - for (String value : header.split(",")) { - String normalized = value.trim(); - if (!normalized.isEmpty()) values.add(normalized); - } - return Set.copyOf(values); - } -} diff --git a/common-security/src/main/java/com/baiyue/common/security/HmacJwtDecoderAutoConfiguration.java b/common-security/src/main/java/com/baiyue/common/security/HmacJwtDecoderAutoConfiguration.java deleted file mode 100644 index b4b8261ed..000000000 --- a/common-security/src/main/java/com/baiyue/common/security/HmacJwtDecoderAutoConfiguration.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.baiyue.common.security; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Bean; -import org.springframework.security.oauth2.jose.jws.MacAlgorithm; -import org.springframework.security.oauth2.jwt.JwtDecoder; -import org.springframework.security.oauth2.jwt.JwtValidators; -import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; - -import javax.crypto.spec.SecretKeySpec; -import java.nio.charset.StandardCharsets; - -/** - * HMAC JWT 解码器自动配置,按共享密钥和签发方构建统一令牌验证器。 - */ -@AutoConfiguration(before = BaiyueSecurityAutoConfiguration.class) -@ConditionalOnProperty(name = "baiyue.security.jwt-mode", havingValue = "hmac", matchIfMissing = true) -public class HmacJwtDecoderAutoConfiguration { - @Bean - @ConditionalOnMissingBean(JwtDecoder.class) - JwtDecoder jwtDecoder(@Value("${baiyue.security.jwt-secret}") String jwtSecret, - @Value("${baiyue.security.issuer:auth-service}") String issuer) { - byte[] secret = jwtSecret.getBytes(StandardCharsets.UTF_8); - if (secret.length < 32) throw new IllegalStateException("JWT_SECRET 至少需要 32 字节"); - NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(new SecretKeySpec(secret, "HmacSHA256")) - .macAlgorithm(MacAlgorithm.HS256).build(); - decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuer)); - return decoder; - } -} diff --git a/common-security/src/main/java/com/baiyue/common/security/HttpSessionStateVerifier.java b/common-security/src/main/java/com/baiyue/common/security/HttpSessionStateVerifier.java new file mode 100644 index 000000000..c9f4f864f --- /dev/null +++ b/common-security/src/main/java/com/baiyue/common/security/HttpSessionStateVerifier.java @@ -0,0 +1,44 @@ +package com.baiyue.common.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Map; + +/** Bounded, non-redirecting internal call. Never forwards an end-user Authorization header. */ +public final class HttpSessionStateVerifier implements SessionStateVerifier { + private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(1)) + .followRedirects(HttpClient.Redirect.NEVER).build(); + private final URI uri; + private final String token; + private final ObjectMapper mapper; + + public HttpSessionStateVerifier(String uri, String token, ObjectMapper mapper) { + this.uri = URI.create(uri); + this.token = token; + this.mapper = mapper; + } + + @Override public void verify(String jwtId, Long userId) { + try { + var request = HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(4)) + .header("Content-Type", "application/json").header("X-Internal-Service-Token", token) + .POST(HttpRequest.BodyPublishers.ofByteArray(mapper.writeValueAsBytes( + Map.of("jwtId", jwtId, "userId", userId)))).build(); + var response = client.send(request, HttpResponse.BodyHandlers.ofString()); + var body = mapper.readTree(response.body()); + if (response.statusCode() != 200 || body == null || !body.path("code").isTextual() || !"200".equals(body.path("code").asText()) + || !body.path("data").isBoolean() || !body.path("data").booleanValue()) { + throw new IllegalArgumentException("Authorization state is revoked or unavailable"); + } + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new IllegalArgumentException("Authorization state validation interrupted", failure); + } catch (Exception failure) { + throw new IllegalArgumentException("Authorization state is revoked or unavailable", failure); + } + } +} diff --git a/common-security/src/main/java/com/baiyue/common/security/JwksJwtDecoderAutoConfiguration.java b/common-security/src/main/java/com/baiyue/common/security/JwksJwtDecoderAutoConfiguration.java index b73770483..7859c7a44 100644 --- a/common-security/src/main/java/com/baiyue/common/security/JwksJwtDecoderAutoConfiguration.java +++ b/common-security/src/main/java/com/baiyue/common/security/JwksJwtDecoderAutoConfiguration.java @@ -3,7 +3,6 @@ package com.baiyue.common.security; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.security.oauth2.jwt.JwtValidators; @@ -14,12 +13,11 @@ import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; import java.util.List; -/** 海外服务仅通过可轮换 JWKS 验证 RS256,不访问国内 Redis。 */ +/** 所有业务服务通过可轮换 JWKS 验证 RS256,会话由共享 Redis 校验。 */ @AutoConfiguration(before = BaiyueSecurityAutoConfiguration.class) -@ConditionalOnProperty(name="baiyue.security.jwt-mode",havingValue="jwks") public class JwksJwtDecoderAutoConfiguration { @Bean @ConditionalOnMissingBean(JwtDecoder.class) - JwtDecoder jwksJwtDecoder(@Value("${baiyue.security.jwk-set-uri}") String uri, + JwtDecoder jwksJwtDecoder(@Value("${baiyue.security.jwk-set-uri:${JWT_JWKS_URI:http://auth-service:9100/api/auth/.well-known/jwks.json}}") String uri, @Value("${baiyue.security.issuer:auth-service}") String issuer, @Value("${baiyue.security.audience:lastleg-api}") String audience) { NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(uri) diff --git a/common-security/src/main/java/com/baiyue/common/security/JwtAuthenticationFilter.java b/common-security/src/main/java/com/baiyue/common/security/JwtAuthenticationFilter.java index 3ea44844c..ea96d2ab9 100644 --- a/common-security/src/main/java/com/baiyue/common/security/JwtAuthenticationFilter.java +++ b/common-security/src/main/java/com/baiyue/common/security/JwtAuthenticationFilter.java @@ -28,10 +28,6 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final AuthenticationEntryPoint authenticationEntryPoint; private final AccessTokenSessionValidator sessionValidator; - public JwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper, - AuthenticationEntryPoint authenticationEntryPoint) { - this(jwtDecoder, claimsMapper, authenticationEntryPoint, jwt -> null); - } public JwtAuthenticationFilter(JwtDecoder jwtDecoder, JwtClaimsMapper claimsMapper, AuthenticationEntryPoint authenticationEntryPoint, diff --git a/common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java b/common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java index 394cecb5d..f82791313 100644 --- a/common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java +++ b/common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java @@ -12,15 +12,14 @@ public final class RedisAccessTokenSessionValidator implements AccessTokenSessio private final String keyPrefix; private final ObjectMapper objectMapper; - public RedisAccessTokenSessionValidator(StringRedisTemplate redisTemplate, String keyPrefix) { - this(redisTemplate, keyPrefix, new ObjectMapper()); - } + private final SessionStateVerifier stateVerifier; public RedisAccessTokenSessionValidator(StringRedisTemplate redisTemplate, String keyPrefix, - ObjectMapper objectMapper) { + ObjectMapper objectMapper, SessionStateVerifier stateVerifier) { this.redisTemplate = redisTemplate; this.keyPrefix = RedisTokenKeys.normalize(keyPrefix); this.objectMapper = objectMapper; + this.stateVerifier = java.util.Objects.requireNonNull(stateVerifier); } @Override @@ -39,6 +38,7 @@ public final class RedisAccessTokenSessionValidator implements AccessTokenSessio if (userId == null || !userId.equals(session.userId())) { throw new IllegalArgumentException("Access token session identity mismatch"); } + stateVerifier.verify(jwtId, userId); return session; } catch (org.springframework.dao.DataAccessException exception) { throw new IllegalArgumentException("Token session store is unavailable", exception); diff --git a/common-security/src/main/java/com/baiyue/common/security/SessionStateVerifier.java b/common-security/src/main/java/com/baiyue/common/security/SessionStateVerifier.java new file mode 100644 index 000000000..b6f4f8823 --- /dev/null +++ b/common-security/src/main/java/com/baiyue/common/security/SessionStateVerifier.java @@ -0,0 +1,7 @@ +package com.baiyue.common.security; + +/** Verifies persisted authorization state, without positive caching. */ +@FunctionalInterface +public interface SessionStateVerifier { + void verify(String jwtId, Long userId); +} diff --git a/common-security/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/common-security/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index d4d261650..6f4cadda1 100644 --- a/common-security/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/common-security/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,3 +1,2 @@ -com.baiyue.common.security.HmacJwtDecoderAutoConfiguration com.baiyue.common.security.JwksJwtDecoderAutoConfiguration com.baiyue.common.security.BaiyueSecurityAutoConfiguration diff --git a/common-security/src/test/java/com/baiyue/common/security/GatewayAccessTokenSessionValidatorTest.java b/common-security/src/test/java/com/baiyue/common/security/GatewayAccessTokenSessionValidatorTest.java deleted file mode 100644 index d9c86a28f..000000000 --- a/common-security/src/test/java/com/baiyue/common/security/GatewayAccessTokenSessionValidatorTest.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.baiyue.common.security; - -import org.junit.jupiter.api.Test; -import org.springframework.mock.web.MockHttpServletRequest; -import org.springframework.security.oauth2.jwt.Jwt; - -import java.time.Instant; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class GatewayAccessTokenSessionValidatorTest { - private final GatewayAccessTokenSessionValidator validator = - new GatewayAccessTokenSessionValidator("gateway-secret"); - - @Test void acceptsOnlyGatewayAuthenticatedAuthorizationSnapshot() { - MockHttpServletRequest request = new MockHttpServletRequest(); - request.addHeader(GatewayAccessTokenSessionValidator.TOKEN_HEADER, "gateway-secret"); - request.addHeader(GatewayAccessTokenSessionValidator.ROLES_HEADER, "PLATFORM_ADMIN"); - request.addHeader(GatewayAccessTokenSessionValidator.AUTHORITIES_HEADER, - "platform:overseas-warehouse:last-mile-order:list,platform:finance:fee-source:view"); - - AccessTokenSession session = validator.validate(jwt(), request); - - assertThat(session.userId()).isEqualTo(42L); - assertThat(session.roles()).containsExactly("PLATFORM_ADMIN"); - assertThat(session.permissions()).containsExactlyInAnyOrder( - "platform:overseas-warehouse:last-mile-order:list", "platform:finance:fee-source:view"); - } - - @Test void rejectsForgedOrMissingGatewayToken() { - MockHttpServletRequest forged = new MockHttpServletRequest(); - forged.addHeader(GatewayAccessTokenSessionValidator.TOKEN_HEADER, "forged"); - forged.addHeader(GatewayAccessTokenSessionValidator.AUTHORITIES_HEADER, - "platform:overseas-warehouse:last-mile-order:list"); - assertThatThrownBy(() -> validator.validate(jwt(), forged)) - .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Invalid overseas gateway"); - - assertThatThrownBy(() -> validator.validate(jwt(), new MockHttpServletRequest())) - .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("Missing overseas gateway"); - } - - private Jwt jwt() { - return Jwt.withTokenValue("small-overseas-token").header("alg", "RS256").subject("42") - .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300)) - .claim("tenant_id", 0L).claim("app_code", "PLATFORM") - .claim("permission_version", 7L).claim("token_version", 1).build(); - } -} diff --git a/common-security/src/test/java/com/baiyue/common/security/HttpSessionStateVerifierTest.java b/common-security/src/test/java/com/baiyue/common/security/HttpSessionStateVerifierTest.java new file mode 100644 index 000000000..52bef14cb --- /dev/null +++ b/common-security/src/test/java/com/baiyue/common/security/HttpSessionStateVerifierTest.java @@ -0,0 +1,45 @@ +package com.baiyue.common.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicReference; +import static org.assertj.core.api.Assertions.*; + +class HttpSessionStateVerifierTest { + @Test void acceptsOnlyExplicitSuccessAndNeverForwardsBearer() throws Exception { + var body = new AtomicReference<>("{\"code\":\"200\",\"data\":true}"); + var auth = new AtomicReference(); + var internal = new AtomicReference(); + var input = new AtomicReference(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/validate", exchange -> { + auth.set(exchange.getRequestHeaders().getFirst("Authorization")); + internal.set(exchange.getRequestHeaders().getFirst("X-Internal-Service-Token")); + input.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + byte[] bytes = body.get().getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, bytes.length); exchange.getResponseBody().write(bytes); exchange.close(); + }); + server.start(); + try { + var verifier = new HttpSessionStateVerifier("http://127.0.0.1:" + server.getAddress().getPort() + "/validate", "internal-only", new ObjectMapper()); + verifier.verify("session", 42L); + assertThat(auth.get()).isNull(); assertThat(internal.get()).isEqualTo("internal-only"); + assertThat(new ObjectMapper().readTree(input.get()).path("userId").longValue()).isEqualTo(42); + for (String rejected : new String[]{"{}", "{\"code\":200,\"data\":true}", "{\"code\":\"200\",\"data\":false}", + "{\"code\":\"A0403\",\"data\":true}", "{\"code\":\"200\",\"data\":\"true\"}", "not-json"}) { + body.set(rejected); + assertThatThrownBy(() -> verifier.verify("session", 42L)).isInstanceOf(IllegalArgumentException.class); + } + } finally { server.stop(0); } + } + + @Test void unavailableAuthorityFailsClosed() throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + int port = server.getAddress().getPort(); server.start(); server.stop(0); + var verifier = new HttpSessionStateVerifier("http://127.0.0.1:" + port + "/validate", "internal", new ObjectMapper()); + assertThatThrownBy(() -> verifier.verify("session", 42L)).isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java b/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java index 9ea0f4fa1..8e94d2033 100644 --- a/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java +++ b/common-security/src/test/java/com/baiyue/common/security/JwtAuthenticationFilterTest.java @@ -29,7 +29,8 @@ class JwtAuthenticationFilterTest { .claim("user_id", 1L).build(); JwtDecoder decoder = token -> jwt; JwtAuthenticationFilter filter = new JwtAuthenticationFilter(decoder, new JwtClaimsMapper(), - new JsonAuthenticationEntryPoint(new ObjectMapper())); + new JsonAuthenticationEntryPoint(new ObjectMapper()), + token -> new AccessTokenSession(1, 1L, java.util.Set.of(), java.util.Set.of())); MockHttpServletRequest request = new MockHttpServletRequest(); request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer valid"); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -43,20 +44,25 @@ class JwtAuthenticationFilterTest { } @Test - void authenticatesMinimalOverseasTokenWithGatewaySnapshot() throws Exception { + void authenticatesUnifiedTokenWithServerSessionIgnoringGatewayHeaders() throws Exception { Jwt jwt = Jwt.withTokenValue("overseas").header("alg", "RS256").subject("42") .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(60)) - .claim("tenant_id", 0L).claim("app_code", "PLATFORM") + .claim("jti", "session-42").claim("user_id", 42L).claim("tenant_id", 0L).claim("app_code", "PLATFORM") .claim("permission_version", 7L).claim("token_version", 1).build(); + var redis = org.mockito.Mockito.mock(org.springframework.data.redis.core.StringRedisTemplate.class); + var values = org.mockito.Mockito.mock(org.springframework.data.redis.core.ValueOperations.class); + org.mockito.Mockito.when(redis.opsForValue()).thenReturn(values); + org.mockito.Mockito.when(values.get("baiyue:auth:access:session-42")) + .thenReturn("{\"version\":1,\"userId\":42,\"roles\":[\"PLATFORM_ADMIN\"],\"permissions\":[\"platform:lastleg:view\"]}"); JwtAuthenticationFilter filter = new JwtAuthenticationFilter(token -> jwt, new JwtClaimsMapper(), new JsonAuthenticationEntryPoint(new ObjectMapper()), - new GatewayAccessTokenSessionValidator("gateway-secret")); + new RedisAccessTokenSessionValidator(redis, "baiyue:auth", new com.fasterxml.jackson.databind.ObjectMapper(), org.mockito.Mockito.mock(SessionStateVerifier.class))); MockHttpServletRequest request = new MockHttpServletRequest(); request.setRequestURI("/api/platform/v1/last-leg/orders"); request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer overseas"); - request.addHeader(GatewayAccessTokenSessionValidator.TOKEN_HEADER, "gateway-secret"); - request.addHeader(GatewayAccessTokenSessionValidator.ROLES_HEADER, "PLATFORM_ADMIN"); - request.addHeader(GatewayAccessTokenSessionValidator.AUTHORITIES_HEADER, "platform:lastleg:view"); + request.addHeader("X-Internal-Service-Token", "gateway-secret"); + request.addHeader("X-Overseas-Roles", "FORGED_ADMIN"); + request.addHeader("X-Overseas-Authorities", "platform:wallet:write"); MockHttpServletResponse response = new MockHttpServletResponse(); AtomicBoolean invoked = new AtomicBoolean(); @@ -64,7 +70,8 @@ class JwtAuthenticationFilterTest { var authentication = SecurityContextHolder.getContext().getAuthentication(); assertThat(authentication).isNotNull(); assertThat(authentication.getAuthorities()).extracting("authority") - .contains("APP_PLATFORM", "ROLE_PLATFORM_ADMIN", "platform:lastleg:view"); + .contains("APP_PLATFORM", "ROLE_PLATFORM_ADMIN", "platform:lastleg:view") + .doesNotContain("ROLE_FORGED_ADMIN", "platform:wallet:write"); invoked.set(true); }); diff --git a/common-security/src/test/java/com/baiyue/common/security/RedisAccessTokenSessionValidatorTest.java b/common-security/src/test/java/com/baiyue/common/security/RedisAccessTokenSessionValidatorTest.java index 1f75a9232..618e0c2a1 100644 --- a/common-security/src/test/java/com/baiyue/common/security/RedisAccessTokenSessionValidatorTest.java +++ b/common-security/src/test/java/com/baiyue/common/security/RedisAccessTokenSessionValidatorTest.java @@ -24,7 +24,7 @@ class RedisAccessTokenSessionValidatorTest { when(values.get("baiyue:auth:access:jti-1")) .thenReturn("{\"version\":1,\"userId\":1,\"roles\":[\"ADMIN\"],\"permissions\":[\"order:read\"]}"); - assertThatCode(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") + assertThatCode(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth", new com.fasterxml.jackson.databind.ObjectMapper(), mock(SessionStateVerifier.class)) .validate(jwt("jti-1"))).doesNotThrowAnyException(); } @@ -35,7 +35,7 @@ class RedisAccessTokenSessionValidatorTest { when(redis.opsForValue()).thenReturn(values); when(values.get("baiyue:auth:access:jti-1")).thenReturn(null); - assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") + assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth", new com.fasterxml.jackson.databind.ObjectMapper(), mock(SessionStateVerifier.class)) .validate(jwt("jti-1"))) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("revoked or expired"); @@ -49,7 +49,7 @@ class RedisAccessTokenSessionValidatorTest { when(values.get("baiyue:auth:access:jti-1")) .thenThrow(new RedisConnectionFailureException("offline")); - assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") + assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth", new com.fasterxml.jackson.databind.ObjectMapper(), mock(SessionStateVerifier.class)) .validate(jwt("jti-1"))) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("unavailable"); @@ -63,7 +63,7 @@ class RedisAccessTokenSessionValidatorTest { when(values.get("baiyue:auth:access:jti-1")) .thenReturn("{\"version\":1,\"userId\":2,\"roles\":[],\"permissions\":[]}"); - assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") + assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth", new com.fasterxml.jackson.databind.ObjectMapper(), mock(SessionStateVerifier.class)) .validate(jwt("jti-1"))) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("identity mismatch"); @@ -76,7 +76,7 @@ class RedisAccessTokenSessionValidatorTest { when(redis.opsForValue()).thenReturn(values); when(values.get("baiyue:auth:access:jti-1")).thenReturn("not-json"); - assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth") + assertThatThrownBy(() -> new RedisAccessTokenSessionValidator(redis, "baiyue:auth", new com.fasterxml.jackson.databind.ObjectMapper(), mock(SessionStateVerifier.class)) .validate(jwt("jti-1"))) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("Invalid access token session"); diff --git a/common-security/src/test/java/com/baiyue/common/security/UnifiedSessionModeTest.java b/common-security/src/test/java/com/baiyue/common/security/UnifiedSessionModeTest.java new file mode 100644 index 000000000..e744ca648 --- /dev/null +++ b/common-security/src/test/java/com/baiyue/common/security/UnifiedSessionModeTest.java @@ -0,0 +1,22 @@ +package com.baiyue.common.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.support.StaticListableBeanFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class UnifiedSessionModeTest { + @Test + void rejectsLegacySessionModesAndMissingRedisInsteadOfTrustingClaimsOrHeaders() { + var configuration = new BaiyueSecurityAutoConfiguration(); + var beans = new StaticListableBeanFactory(); + var redis = beans.getBeanProvider(StringRedisTemplate.class); + for (String mode : new String[]{"claims", "gateway", "unknown", "redis"}) { + assertThatThrownBy(() -> configuration.jwtAuthenticationFilter(token -> null, + new JwtClaimsMapper(), new JsonAuthenticationEntryPoint(new ObjectMapper()), + redis, new ObjectMapper(), "baiyue:auth", mode, "http://auth:9100/internal/auth/accounts/sessions/validate", "test-token")) + .isInstanceOf(IllegalStateException.class); + } + } +} diff --git a/common-web/AGENTS.md b/common-web/AGENTS.md index 019c951df..3820a631b 100644 --- a/common-web/AGENTS.md +++ b/common-web/AGENTS.md @@ -13,6 +13,8 @@ | 请求 ID 过滤器 | `src/main/java/com/baiyue/common/web/CorrelationIdFilter.java` | | W3C Trace Context | `src/main/java/com/baiyue/common/web/TraceParent.java`;严格接收 version 00,响应 `traceparent`、`X-Trace-Id` 与 MDC `traceId` 使用同一 trace ID | | Feign Trace 传播 | `FeignTraceRequestInterceptor.java`、`FeignTracePropagationAutoConfiguration.java`;仅 Feign 存在时装配,optional 依赖不向无 Feign 服务传递 | +| 可靠事件 HTTP 客户端配置 | `EventDeliveryFeignConfiguration.java`;由业务事件 Feign 客户端显式选择,在最终构建阶段固定连接/读取超时、禁止重定向和客户端内部重试,不作为全局自动配置 | +| 投递审计和指标装配 | `EventDeliveryObservationAutoConfiguration.java`、`EventDeliveryMetrics.java`、`EventDeliveryObservation.java`;仅EventDeliveryProducer标记的业务Relay装配必需Journal/Metrics,其他JDBC服务不装配;定时采样固定事件白名单,Prometheus仅读内存快照,采样失败保留旧值并暴露失败/时间,错误标签不含载荷或令牌 | | RestClient Trace 传播 | `RestClientTraceInterceptor.java`、`RestClientTracePropagationAutoConfiguration.java`;Spring 管理的 Builder 自动装配,原始 Builder 可显式复用拦截器 | | 自动配置入口 | `src/main/java/com/baiyue/common/web/CommonWebAutoConfiguration.java`;为只扫描自身包的服务注册异常处理器和 Correlation 过滤器 | | 全局异常映射与业务失败指标 | `src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java`;指标 `baiyue.api.business.failures` 按有限 `code` 标记 | @@ -23,8 +25,8 @@ ## 修改与验证 - 普通 `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`。 +- 网关/Servlet Security 的真实 401/403、协议或基础设施错误、Warehouse readiness 503 与 Audit 202 仍保留真实 HTTP 状态;完整边界见 `../docs/统一响应约定改造方案-HTTP200.md`。 +- 修改自动配置时验证 `audit-service` 等当前构建内、只扫描自身包的直接消费者仍能加载 `GlobalExceptionHandler` 和 `CorrelationIdFilter`;按根 POM 与实际依赖限定范围。 - 修改 Correlation ID 时同步检查当前请求头契约、线程上下文清理和日志链路。 - 入站 Trace 优先级固定为:合法单值 `traceparent` > 完全缺失 `traceparent` 时的 32 位非零十六进制 `X-Trace-Id` > 新 trace。只要 `traceparent` 已出现但重复、超长、格式非法或含全零 ID,就必须生成新 trace,不得回退信任 `X-Trace-Id`;请求 ID 独立按 `X-Request-Id` 校验或生成。 - 同步 Feign 出站固定先移除调用模板中的 `X-Request-Id`、`X-Trace-Id`、`traceparent`,再仅从当前 MDC 的合法请求 ID 和 trace ID 重建;`traceparent` 沿用当前 trace ID、每次调用生成新非零 span ID、flags 固定 `01`。不得从业务参数、远端返回头或无效 MDC 建立上下文;本能力不处理 Reactor、异步线程、重试、超时或 fallback。 diff --git a/common-web/pom.xml b/common-web/pom.xml index ee8c7876b..084dd86b8 100644 --- a/common-web/pom.xml +++ b/common-web/pom.xml @@ -14,6 +14,9 @@ baiyue-common-web + org.springframeworkspring-jdbctrue + com.baiyue.sharedcommon-event-contract${project.version} + com.h2databaseh2test com.baiyue.shared baiyue-common-core diff --git a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryFailure.java b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryFailure.java new file mode 100644 index 000000000..3210dbd2e --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryFailure.java @@ -0,0 +1,56 @@ +package com.baiyue.common.web; + +import com.baiyue.common.core.api.ApiResponse; +import com.fasterxml.jackson.core.JsonProcessingException; +import feign.FeignException; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Set; + +/** Explicit failure classification and bounded, credential-free delivery diagnostics. */ +public class EventDeliveryFailure extends IllegalStateException { + private static final Set PERMANENT_CODES = Set.of("A0401", "A0403", "EVT_PROTOCOL"); + private final boolean permanent; + public EventDeliveryFailure(boolean permanent, String message) { + super(message); + this.permanent = permanent; + } + public static void requireSuccessful(ApiResponse response) { + requireSuccessful(response, Set.of()); + } + public static void requireSuccessful(ApiResponse response, Set additionalPermanentCodes) { + if (response == null) throw new EventDeliveryFailure(false, "Consumer returned an empty response"); + if (!response.successful()) { + throw new EventDeliveryFailure(response.code() != null && (PERMANENT_CODES.contains(response.code()) || additionalPermanentCodes.contains(response.code())), + "Consumer rejected event: code=" + response.code() + ", message=" + response.message() + + ", requestId=" + response.requestId() + ", traceId=" + response.traceId()); + } + if (response.data() == null) throw new EventDeliveryFailure(false, "Consumer ACK is missing"); + } + public static boolean isPermanent(Throwable failure) { + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + for (Throwable current = failure; current != null && seen.add(current); current = current.getCause()) { + if (current instanceof EventDeliveryFailure delivery && delivery.permanent) return true; + if (current instanceof FeignException http && (http.status() == 401 || http.status() == 403)) return true; + } + return false; + } + public static String diagnostic(Throwable failure, String internalToken) { + Throwable current = failure; + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + while (current.getCause() != null && seen.add(current) + && ("NoFallbackAvailableException".equals(current.getClass().getSimpleName()) + || "No fallback available.".equals(current.getMessage()))) current = current.getCause(); + String message; + if (current instanceof feign.codec.DecodeException) message = "Invalid consumer response contract"; + else if (current instanceof FeignException http) message = "HTTP " + http.status(); + else if (current instanceof JsonProcessingException) message = "Invalid event JSON"; + else message = current.getMessage() == null ? "unknown" : current.getMessage(); + String text = current.getClass().getSimpleName() + ": " + message; + if (internalToken != null && !internalToken.isBlank()) text = text.replace(internalToken, "***"); + text = text.replaceAll("(?i)Bearer\\s+[^\\s,;\\\"']+", "Bearer ***") + .replaceAll("(?i)((?:X-Internal-Service-Token|Authorization|access[_-]?token|refresh[_-]?token)\\s*[=:]\\s*)[^\\s,;]+", "$1***") + .replaceAll("[\\r\\n\\t]+", " "); + return text.length() > 500 ? text.substring(0, 500) : text; + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryFeignConfiguration.java b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryFeignConfiguration.java new file mode 100644 index 000000000..39749ef1b --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryFeignConfiguration.java @@ -0,0 +1,64 @@ +package com.baiyue.common.web; + +import feign.Request; +import feign.Capability; +import feign.Retryer; +import feign.codec.Decoder; +import feign.codec.DecodeException; +import com.baiyue.common.core.api.ApiResponse; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.lang.reflect.ParameterizedType; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import java.util.concurrent.TimeUnit; + +/** Explicitly selected by reliable event clients; never registered as a global configuration. */ +public class EventDeliveryFeignConfiguration { + @Bean + public DeliveryTransportCapability eventDeliveryCapability( + @Value("${baiyue.event-delivery.connect-timeout-ms:3000}") int connectTimeout, + @Value("${baiyue.event-delivery.read-timeout-ms:15000}") int readTimeout, + @Value("${baiyue.event-delivery.lease-seconds:60}") long leaseSeconds) { + if (connectTimeout < 1 || readTimeout < 1 || leaseSeconds < 1 + || leaseSeconds > Integer.MAX_VALUE || (long) connectTimeout + readTimeout >= leaseSeconds * 1000) { + throw new IllegalArgumentException("Event delivery timeouts must fit inside the lease"); + } + return new DeliveryTransportCapability(new Request.Options(connectTimeout, TimeUnit.MILLISECONDS, readTimeout, TimeUnit.MILLISECONDS, false)); + } + /** Applied at Feign build time after global/client properties, without changing other clients. */ + public static final class DeliveryTransportCapability implements Capability { + private static final ObjectMapper RESPONSE_JSON = new ObjectMapper() + .enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION) + .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); + private final Request.Options options; + public DeliveryTransportCapability(Request.Options options) { this.options = options; } + @Override public Request.Options enrich(Request.Options ignored) { return options; } + @Override public Retryer enrich(Retryer ignored) { + // The persisted Outbox owns the retry budget. + return Retryer.NEVER_RETRY; + } + @Override public Decoder enrich(Decoder decoder) { + return (response, type) -> { + if (type instanceof ParameterizedType parameterized && parameterized.getRawType() == ApiResponse.class) { + // Jackson normally coerces a numeric 200 into String. Inspect the wire value first. + if (response.body() == null) throw new DecodeException(response.status(), "Missing event response", response.request()); + byte[] bytes; + try (var stream = response.body().asInputStream()) { bytes = stream.readNBytes(65537); } + if (bytes.length > 65536) throw new DecodeException(response.status(), "Event response exceeds limit", response.request()); + try { + var root = RESPONSE_JSON.readTree(bytes); + if (root == null || !root.isObject() || !root.path("code").isTextual()) { + throw new IllegalArgumentException(); + } + } catch (Exception invalid) { + throw new DecodeException(response.status(), "Invalid event response contract", response.request()); + } + return decoder.decode(response.toBuilder().body(bytes).build(), type); + } + return decoder.decode(response, type); + }; + } + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryMetrics.java b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryMetrics.java new file mode 100644 index 000000000..1a293ed2b --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryMetrics.java @@ -0,0 +1,78 @@ +package com.baiyue.common.web; + +import io.micrometer.core.instrument.Gauge; +import com.baiyue.common.event.EventDeliveryRoute; +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.jdbc.core.JdbcTemplate; +import java.sql.Timestamp; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** Fixed labels only. Scheduled samplers query the local schema; scrape callbacks only read memory. */ +public final class EventDeliveryMetrics { + public enum Stream { + FIRSTLEG("firstleg", "fl_inbound_outbox", "publish_status", "PROCESSING", + List.of("FirstLegLoadingPlanCompleted", "FirstLegContainerShippingNodeRecorded")), + LASTLEG("lastleg", "ll_event_outbox", "status", "PROCESSING", + List.of("OverseasContainerUnloaded", "OverseasInboundOrderDetainedChanged", "OverseasDeliveryCompleted", "WalletCommandRequested", "CompanyVehicleCostSubmitted")), + FEE("lastleg", "ll_event_outbox", "status", "PROCESSING", List.of("BusinessFeeSourceChanged")), + FINANCE("finance", "fin_wallet_event_outbox", "status", "SENDING", List.of("WalletCommandCompleted")); + public final String service, table, status, processing; + public final List types; + Stream(String service, String table, String status, String processing, List types) { + this.service = service; this.table = table; this.status = status; this.processing = processing; this.types = types; + } + public String type(String candidate) { return candidate != null && types.contains(candidate) ? candidate : "UNKNOWN"; } + public String target(String type) { + if (!types.contains(type)) return "UNRESOLVED"; // Audit only; never a transport fallback. + var route = EventDeliveryRoute.forEventType(type); + return route.serviceName() + route.path(); + } + } + private record Snapshot(double pending, double dead, double oldestSeconds, double sampledAt, double success) { } + private static final class Cell { volatile Snapshot value = new Snapshot(Double.NaN, Double.NaN, Double.NaN, 0, 0); } + private final JdbcTemplate jdbc; + private final MeterRegistry registry; + private final Map cells = new ConcurrentHashMap<>(); + public EventDeliveryMetrics(JdbcTemplate jdbc, MeterRegistry registry) { this.jdbc = jdbc; this.registry = registry; } + + public void sample(Stream stream, LocalDateTime now) { + for (String type : stream.types) { + Cell cell = cells.computeIfAbsent(stream.name() + ":" + type, ignored -> register(stream, type)); + try { + String filter = stream == Stream.FINANCE ? "" : " WHERE event_type=?"; + String sql = "SELECT SUM(CASE WHEN " + stream.status + " IN ('PENDING','" + stream.processing + + "') THEN 1 ELSE 0 END) AS pending_count, SUM(CASE WHEN " + stream.status + + "='DEAD' THEN 1 ELSE 0 END) AS dead_count, MIN(CASE WHEN " + stream.status + + " IN ('PENDING','" + stream.processing + "') THEN created_at END) AS oldest FROM " + stream.table + filter; + Map result = stream == Stream.FINANCE ? jdbc.queryForMap(sql) : jdbc.queryForMap(sql, type); + LocalDateTime oldest = databaseTime(result.get("oldest")); + cell.value = new Snapshot(count(result.get("pending_count")), count(result.get("dead_count")), + oldest == null ? 0 : Math.max(0, Duration.between(oldest, now).toMillis() / 1000.0), + System.currentTimeMillis() / 1000.0, 1); + } catch (RuntimeException failure) { + Snapshot old = cell.value; + cell.value = new Snapshot(old.pending, old.dead, old.oldestSeconds, old.sampledAt, 0); + } + } + } + private Cell register(Stream stream, String type) { + Cell cell = new Cell(); + Gauge.builder("baiyue.event.delivery.pending", cell, c -> c.value.pending).tags("service", stream.service, "event_type", type).register(registry); + Gauge.builder("baiyue.event.delivery.dead", cell, c -> c.value.dead).tags("service", stream.service, "event_type", type).register(registry); + Gauge.builder("baiyue.event.delivery.oldest.pending.seconds", cell, c -> c.value.oldestSeconds).tags("service", stream.service, "event_type", type).register(registry); + Gauge.builder("baiyue.event.delivery.sample.timestamp", cell, c -> c.value.sampledAt).tags("service", stream.service, "event_type", type).register(registry); + Gauge.builder("baiyue.event.delivery.sample.success", cell, c -> c.value.success).tags("service", stream.service, "event_type", type).register(registry); + return cell; + } + private static LocalDateTime databaseTime(Object value) { + if (value == null) return null; + if (value instanceof LocalDateTime local) return local; + if (value instanceof Timestamp timestamp) return timestamp.toLocalDateTime(); + throw new IllegalArgumentException("Unsupported Outbox timestamp type"); + } + private static double count(Object value) { return value == null ? 0 : ((Number) value).doubleValue(); } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservation.java b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservation.java new file mode 100644 index 000000000..8d38e26b0 --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservation.java @@ -0,0 +1,30 @@ +package com.baiyue.common.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.baiyue.common.event.EventIdentifiers; +import feign.FeignException; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Set; + +/** Diagnostic identity and finite audit error codes; never serialize exception messages or payloads. */ +public final class EventDeliveryObservation { + private EventDeliveryObservation() { } + public static String eventId(ObjectMapper mapper, String json) { + try { + String id = mapper.readTree(json).path("eventId").asText(""); + return EventIdentifiers.isSupported(id) ? id : null; + } catch (Exception invalid) { return null; } // Invalid payloads are still audited by Outbox entry ID. + } + public static String errorCode(Throwable error, boolean permanent, boolean requestStarted) { + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + for (Throwable cause = error; cause != null && seen.add(cause); cause = cause.getCause()) { + if (cause instanceof feign.codec.DecodeException) return "INVALID_RESPONSE"; + if (cause instanceof FeignException http && http.status() >= 100 && http.status() <= 599) { + return "HTTP_" + http.status(); + } + } + if (!requestStarted) return "INVALID_EVENT"; + return permanent ? "PERMANENT_FAILURE" : "DELIVERY_FAILURE"; + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservationAutoConfiguration.java b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservationAutoConfiguration.java new file mode 100644 index 000000000..35c6225f8 --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/EventDeliveryObservationAutoConfiguration.java @@ -0,0 +1,27 @@ +package com.baiyue.common.web; + +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import com.baiyue.common.core.outbox.EventDeliveryProducer; +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; + +@AutoConfiguration(after = {JdbcTemplateAutoConfiguration.class, DataSourceTransactionManagerAutoConfiguration.class}) +@ConditionalOnBean(EventDeliveryProducer.class) +@ConditionalOnClass(name = "org.springframework.jdbc.core.JdbcTemplate") +public class EventDeliveryObservationAutoConfiguration { + @Bean + EventDeliveryJournal eventDeliveryJournal(JdbcTemplate jdbc, PlatformTransactionManager manager) { + return new EventDeliveryJournal(jdbc, manager); + } + @Bean + EventDeliveryMetrics eventDeliveryMetrics(JdbcTemplate jdbc, MeterRegistry registry) { + return new EventDeliveryMetrics(jdbc, registry); + } +} diff --git a/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 3fb8cf813..90fd2005c 100644 --- a/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -3,3 +3,4 @@ com.baiyue.common.web.CommonWebAutoConfiguration com.baiyue.common.web.FeignTracePropagationAutoConfiguration com.baiyue.common.web.RestClientTracePropagationAutoConfiguration com.baiyue.common.web.BaiyueSchedulingAutoConfiguration +com.baiyue.common.web.EventDeliveryObservationAutoConfiguration diff --git a/common-web/src/test/java/com/baiyue/common/web/ApiResponseSerializationTest.java b/common-web/src/test/java/com/baiyue/common/web/ApiResponseSerializationTest.java new file mode 100644 index 000000000..d1aa71cd1 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/ApiResponseSerializationTest.java @@ -0,0 +1,43 @@ +package com.baiyue.common.web; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class ApiResponseSerializationTest { + @Test + void preservesEnvelopeDataUnderNonNullConfiguration() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(JacksonAutoConfiguration.class)) + .withPropertyValues("spring.jackson.default-property-inclusion=non_null") + .run(context -> { + ObjectMapper mapper = context.getBean(ObjectMapper.class); + JsonNode success = mapper.readTree(mapper.writeValueAsString(ApiResponse.success(null))); + assertThat(success.path("code").asText()).isEqualTo("200"); + assertThat(success.has("data")).isTrue(); + assertThat(success.get("data").isNull()).isTrue(); + assertThat(success.has("requestId")).isFalse(); + + JsonNode failure = mapper.readTree(mapper.writeValueAsString( + ApiResponse.failure(ResultCode.CONFLICT, "conflict", "request-1", "trace-1"))); + assertThat(failure.path("code").asText()).isEqualTo("A0409"); + assertThat(failure.has("data")).isTrue(); + assertThat(failure.get("data").isNull()).isTrue(); + assertThat(failure.path("requestId").asText()).isEqualTo("request-1"); + assertThat(failure.path("traceId").asText()).isEqualTo("trace-1"); + + JsonNode populated = mapper.readTree(mapper.writeValueAsString( + ApiResponse.success(Map.of("status", "SUBMITTED")))); + assertThat(populated.path("data").path("status").asText()).isEqualTo("SUBMITTED"); + }); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/EventDeliveryFailureTest.java b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryFailureTest.java new file mode 100644 index 000000000..c62009452 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryFailureTest.java @@ -0,0 +1,55 @@ +package com.baiyue.common.web; + +import com.baiyue.common.core.api.ApiResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import feign.FeignException; +import feign.Request; +import feign.Response; +import org.junit.jupiter.api.Test; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Map; +import static org.assertj.core.api.Assertions.*; + +class EventDeliveryFailureTest { + @Test void onlyExplicitProtocolOrAuthRejectionIsPermanent() { + for (String code : new String[]{"EVT_PROTOCOL", "A0401", "A0403"}) { + Throwable error = catchThrowable(() -> EventDeliveryFailure.requireSuccessful(response(code))); + assertThat(EventDeliveryFailure.isPermanent(error)).as(code).isTrue(); + } + for (String code : new String[]{"EVT_DEPENDENCY", "A0400", "A0409", "A0429", "S0503", "unknown", null}) { + Throwable error = catchThrowable(() -> EventDeliveryFailure.requireSuccessful(response(code))); + assertThat(error).isInstanceOf(EventDeliveryFailure.class); + assertThat(EventDeliveryFailure.isPermanent(error)).as(code).isFalse(); + } + assertThat(EventDeliveryFailure.isPermanent(new IllegalArgumentException("ACK mismatch"))).isFalse(); + } + @Test void httpAuthenticationFailureIsPermanentBut429And503Retry() { + for (int status : new int[]{401, 403, 429, 503}) { + var http = http(status); + assertThat(EventDeliveryFailure.isPermanent(new RuntimeException("wrapper", http))) + .isEqualTo(status == 401 || status == 403); + assertThat(EventDeliveryFailure.diagnostic(http, "secret")).contains("HTTP " + status) + .doesNotContain("remote-body", "secret"); + } + } + @Test void diagnosticsRemoveTokenHeadersSecretsAndLineBreaks() { + var failure = new IllegalStateException("secret-value\nAuthorization=Bearer abc.def.ghi; X-Internal-Service-Token=another-secret, access_token=third-secret"); + String value = EventDeliveryFailure.diagnostic(failure, "secret-value"); + assertThat(value).doesNotContain("secret-value", "abc.def.ghi", "another-secret", "third-secret", "\n"); + assertThat(EventDeliveryFailure.diagnostic(new RuntimeException("x".repeat(1000)), "")).hasSize(500); + } + @Test void malformedJsonDiagnosticNeverCopiesPersistedPayload() { + Throwable failure = catchThrowable(() -> new ObjectMapper().readTree("private-payload-value")); + assertThat(EventDeliveryFailure.diagnostic(failure, "")) + .contains("Invalid event JSON").doesNotContain("private-payload-value"); + } + private ApiResponse response(String code) { + return new ApiResponse<>(code, "consumer-message", null, "request-id", "trace-id", Instant.now()); + } + private FeignException http(int status) { + var request = Request.create(Request.HttpMethod.POST, "http://consumer/events?token=secret", Map.of(), new byte[0], StandardCharsets.UTF_8, null); + return FeignException.errorStatus("receive", Response.builder().request(request).status(status).reason("failure") + .headers(Map.of()).body("remote-body-secret", StandardCharsets.UTF_8).build()); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/EventDeliveryFeignConfigurationTest.java b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryFeignConfigurationTest.java new file mode 100644 index 000000000..962c11d4b --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryFeignConfigurationTest.java @@ -0,0 +1,74 @@ +package com.baiyue.common.web; + +import feign.*; +import org.junit.jupiter.api.Test; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import static org.assertj.core.api.Assertions.*; + +class EventDeliveryFeignConfigurationTest { + interface Endpoint { @RequestLine("POST /events") String send(); } + interface TypedEndpoint { @RequestLine("POST /events") com.baiyue.common.core.api.ApiResponse> send(); } + + @Test void wireCodeMustBeAStringAndAmbiguousJsonCannotBeAcknowledged() { + for (String body : List.of("{\"code\":200}", "{\"code\":true}", "{\"code\":\"200\",\"code\":\"500\"}", "{\"code\":\"200\"} {}", "{}")) { + var decoded = new AtomicInteger(); + TypedEndpoint endpoint = Feign.builder() + .decoder((response, type) -> { decoded.incrementAndGet(); return com.baiyue.common.core.api.ApiResponse.success(Map.of()); }) + .addCapability(new EventDeliveryFeignConfiguration().eventDeliveryCapability(3000, 15000, 60)) + .client((request, options) -> Response.builder().request(request).status(200).reason("OK").headers(Map.of()).body(body, StandardCharsets.UTF_8).build()) + .target(TypedEndpoint.class, "http://event-consumer.invalid"); + assertThatThrownBy(endpoint::send).isInstanceOf(feign.codec.DecodeException.class); + assertThat(decoded.get()).isZero(); + } + } + + @Test void validStringCodePreservesBodyForTheConfiguredDecoder() { + String body = "{\"code\":\"200\",\"data\":{\"outcome\":\"APPLIED\"}}"; + TypedEndpoint endpoint = Feign.builder().decoder((response, type) -> { + assertThat(new String(response.body().asInputStream().readAllBytes(), StandardCharsets.UTF_8)).isEqualTo(body); + return com.baiyue.common.core.api.ApiResponse.success(Map.of("outcome", "APPLIED")); + }).addCapability(new EventDeliveryFeignConfiguration().eventDeliveryCapability(3000, 15000, 60)) + .client((request, options) -> Response.builder().request(request).status(200).reason("OK").headers(Map.of()).body(body, StandardCharsets.UTF_8).build()) + .target(TypedEndpoint.class, "http://event-consumer.invalid"); + assertThat(endpoint.send().data()).containsEntry("outcome", "APPLIED"); + } + + @Test void builtClientOverridesLegacyTimeoutAndRedirectSettings() { + var capability = new EventDeliveryFeignConfiguration().eventDeliveryCapability(3000, 15000, 60); + var observed = new AtomicReference(); + Endpoint endpoint = Feign.builder().options(new Request.Options(1, 5000)) + .addCapability(capability).client((request, options) -> { + observed.set(options); + return Response.builder().request(request).status(200).reason("OK") + .headers(Map.of()).body("ok", StandardCharsets.UTF_8).build(); + }).target(Endpoint.class, "http://event-consumer.invalid"); + assertThat(endpoint.send()).isEqualTo("ok"); + assertThat(observed.get().connectTimeoutMillis()).isEqualTo(3000); + assertThat(observed.get().readTimeoutMillis()).isEqualTo(15000); + assertThat(observed.get().isFollowRedirects()).isFalse(); + } + + @Test void retryAfterResponseCannotTriggerHiddenHttpRetries() { + var calls = new AtomicInteger(); + Endpoint endpoint = Feign.builder().retryer(new Retryer.Default(1, 1, 3)) + .addCapability(new EventDeliveryFeignConfiguration().eventDeliveryCapability(3000, 15000, 60)) + .client((request, options) -> { + calls.incrementAndGet(); + return Response.builder().request(request).status(503).reason("Unavailable") + .headers(Map.of("Retry-After", List.of("0"))).build(); + }).target(Endpoint.class, "http://event-consumer.invalid"); + assertThatThrownBy(endpoint::send).isInstanceOf(RetryableException.class); + assertThat(calls.get()).isEqualTo(1); + } + + @Test void invalidTimeoutsAndTooShortLeaseAreRejected() { + var configuration = new EventDeliveryFeignConfiguration(); + assertThatThrownBy(() -> configuration.eventDeliveryCapability(0, 15000, 60)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> configuration.eventDeliveryCapability(3000, 15000, 18)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> configuration.eventDeliveryCapability(3000, -1, 60)).isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/EventDeliveryMetricsTest.java b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryMetricsTest.java new file mode 100644 index 000000000..6a9e9da25 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryMetricsTest.java @@ -0,0 +1,71 @@ +package com.baiyue.common.web; + +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.time.LocalDateTime; +import java.sql.Timestamp; +import java.util.Map; +import java.util.List; +import static org.mockito.ArgumentMatchers.*; +import java.util.UUID; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class EventDeliveryMetricsTest { + @Test void samplesBoundedTypesAndPreservesPriorValuesWhenDatabaseFailsWithoutQueryingDuringScrapes() { + var jdbc = spy(new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""))); + jdbc.execute("CREATE TABLE ll_event_outbox(event_type VARCHAR(200),status VARCHAR(32),created_at TIMESTAMP)"); + var now = LocalDateTime.now(); + jdbc.update("INSERT INTO ll_event_outbox VALUES('BusinessFeeSourceChanged','PENDING',?)", now.minusSeconds(60)); + jdbc.update("INSERT INTO ll_event_outbox VALUES('BusinessFeeSourceChanged','PROCESSING',?)", now.minusSeconds(30)); + jdbc.update("INSERT INTO ll_event_outbox VALUES('BusinessFeeSourceChanged','DEAD',?)", now.minusSeconds(120)); + jdbc.update("INSERT INTO ll_event_outbox VALUES('user-controlled-unknown-type','PENDING',?)", now.minusYears(1)); + var registry = new SimpleMeterRegistry(); + var metrics = new EventDeliveryMetrics(jdbc, registry); + metrics.sample(EventDeliveryMetrics.Stream.FEE, now); + assertThat(registry.get("baiyue.event.delivery.pending").gauge().value()).isEqualTo(2); + assertThat(registry.get("baiyue.event.delivery.dead").gauge().value()).isEqualTo(1); + assertThat(registry.get("baiyue.event.delivery.oldest.pending.seconds").gauge().value()).isBetween(59.99, 60.01); + double sampledAt = registry.get("baiyue.event.delivery.sample.timestamp").gauge().value(); + jdbc.execute("DROP TABLE ll_event_outbox"); + metrics.sample(EventDeliveryMetrics.Stream.FEE, now.plusMinutes(1)); + clearInvocations(jdbc); + assertThat(registry.get("baiyue.event.delivery.pending").gauge().value()).isEqualTo(2); + assertThat(registry.get("baiyue.event.delivery.sample.timestamp").gauge().value()).isEqualTo(sampledAt); + assertThat(registry.get("baiyue.event.delivery.sample.success").gauge().value()).isZero(); + registry.getMeters().forEach(meter -> { + assertThat(meter.getId().getTag("event_type")).isEqualTo("BusinessFeeSourceChanged"); + meter.measure().forEach(measurement -> measurement.getValue()); + }); + verifyNoInteractions(jdbc); + } + @Test void supportsBothMysqlLocalDateTimeAndJdbcTimestampAndRejectsUnknownTypes() { + var jdbc = mock(JdbcTemplate.class); + var now = LocalDateTime.of(2026, 9, 29, 12, 0); + var registry = new SimpleMeterRegistry(); + var metrics = new EventDeliveryMetrics(jdbc, registry); + for (Object oldest : List.of(now.minusSeconds(45), Timestamp.valueOf(now.minusSeconds(45)))) { + Map row = Map.of("pending_count", 1L, "dead_count", 0L, "oldest", oldest); + when(jdbc.queryForMap(anyString(), eq("BusinessFeeSourceChanged"))).thenReturn(row); + metrics.sample(EventDeliveryMetrics.Stream.FEE, now); + assertThat(registry.get("baiyue.event.delivery.oldest.pending.seconds").gauge().value()).isEqualTo(45); + assertThat(registry.get("baiyue.event.delivery.sample.success").gauge().value()).isEqualTo(1); + } + when(jdbc.queryForMap(anyString(), eq("BusinessFeeSourceChanged"))) + .thenReturn(Map.of("pending_count", 0L, "dead_count", 0L, "oldest", "not a JDBC time")); + metrics.sample(EventDeliveryMetrics.Stream.FEE, now); + assertThat(registry.get("baiyue.event.delivery.pending").gauge().value()).isEqualTo(1); + assertThat(registry.get("baiyue.event.delivery.oldest.pending.seconds").gauge().value()).isEqualTo(45); + assertThat(registry.get("baiyue.event.delivery.sample.success").gauge().value()).isZero(); + } + @Test void initialFailureIsUnknownRatherThanFalseZero() { + var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID(), "sa", "")); + var registry = new SimpleMeterRegistry(); + new EventDeliveryMetrics(jdbc, registry).sample(EventDeliveryMetrics.Stream.FINANCE, LocalDateTime.now()); + assertThat(registry.get("baiyue.event.delivery.pending").gauge().value()).isNaN(); + assertThat(registry.get("baiyue.event.delivery.sample.timestamp").gauge().value()).isZero(); + assertThat(registry.get("baiyue.event.delivery.sample.success").gauge().value()).isZero(); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationAutoConfigurationTest.java b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationAutoConfigurationTest.java new file mode 100644 index 000000000..19a1a9674 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationAutoConfigurationTest.java @@ -0,0 +1,53 @@ +package com.baiyue.common.web; + +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import com.baiyue.common.core.outbox.EventDeliveryProducer; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.PlatformTransactionManager; +import static org.assertj.core.api.Assertions.*; + +class EventDeliveryObservationAutoConfigurationTest { + @Test void configuresMandatoryAuditAndMetricsForServiceLocalJdbc() { + var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:auto", "sa", "")); + new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(EventDeliveryObservationAutoConfiguration.class)) + .withBean(EventDeliveryProducer.class, () -> new EventDeliveryProducer() { }) + .withBean(JdbcTemplate.class, () -> jdbc) + .withBean(PlatformTransactionManager.class, () -> new DataSourceTransactionManager(jdbc.getDataSource())) + .withBean(MeterRegistry.class, SimpleMeterRegistry::new) + .run(context -> { + assertThat(context).hasSingleBean(EventDeliveryJournal.class); + assertThat(context).hasSingleBean(EventDeliveryMetrics.class); + }); + } + @Test void otherJdbcServicesDoNotAcquireTransactionOrMetricsRequirements() { + new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(EventDeliveryObservationAutoConfiguration.class)) + .withBean(JdbcTemplate.class, () -> new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:other", "sa", ""))) + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean(EventDeliveryJournal.class); + assertThat(context).doesNotHaveBean(EventDeliveryMetrics.class); + }); + } + @Test void producerWithoutMeterRegistryFailsInsteadOfSilentlyDisablingObservation() { + var jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:noRegistry", "sa", "")); + new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(EventDeliveryObservationAutoConfiguration.class)) + .withBean(EventDeliveryProducer.class, () -> new EventDeliveryProducer() { }) + .withBean(JdbcTemplate.class, () -> jdbc) + .withBean(PlatformTransactionManager.class, () -> new DataSourceTransactionManager(jdbc.getDataSource())) + .run(context -> assertThat(context).hasFailed()); + } + @Test void missingTransactionManagerFailsRatherThanInstallingNonTransactionalFallback() { + new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(EventDeliveryObservationAutoConfiguration.class)) + .withBean(EventDeliveryProducer.class, () -> new EventDeliveryProducer() { }) + .withBean(JdbcTemplate.class, () -> new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:missing", "sa", ""))) + .withBean(MeterRegistry.class, SimpleMeterRegistry::new) + .run(context -> assertThat(context).hasFailed()); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationTest.java b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationTest.java new file mode 100644 index 000000000..036c54ca4 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/EventDeliveryObservationTest.java @@ -0,0 +1,31 @@ +package com.baiyue.common.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; + +class EventDeliveryObservationTest { + @Test void malformedSuccessResponseIsNotReportedAsAnHttpSuccess() { + var request = feign.Request.create(feign.Request.HttpMethod.POST, "http://consumer.invalid/events", + java.util.Map.of(), new byte[0], java.nio.charset.StandardCharsets.UTF_8, new feign.RequestTemplate()); + var failure = new feign.codec.DecodeException(200, "secret remote payload", request); + assertThat(EventDeliveryObservation.errorCode(failure, false, true)).isEqualTo("INVALID_RESPONSE"); + assertThat(EventDeliveryFailure.diagnostic(failure, "token")) + .contains("Invalid consumer response contract").doesNotContain("secret", "HTTP 200"); + assertThat(EventDeliveryFailure.isPermanent(failure)).isFalse(); + } + @Test void auditIdentitySupportsTheFormalUuidAndUlidContractWithoutSavingMalformedPayload() { + var mapper = new ObjectMapper(); + for (String id : new String[]{"00000000-0000-4000-8000-000000000019", "01ARZ3NDEKTSV4RRFFQ69G5FAV"}) { + assertThat(EventDeliveryObservation.eventId(mapper, "{\"eventId\":\"" + id + "\",\"payload\":\"secret\"}")).isEqualTo(id); + } + assertThat(EventDeliveryObservation.eventId(mapper, "bad secret JSON")).isNull(); + assertThat(EventDeliveryObservation.eventId(mapper, "{\"eventId\":\"Bearer secret\"}")).isNull(); + } + @Test void arbitraryExceptionsAndRemoteMessagesCannotLeakIntoAttemptAudit() { + var failure = new IllegalStateException("payload={secret} Authorization=secret"); + assertThat(EventDeliveryObservation.errorCode(failure, false, true)).isEqualTo("DELIVERY_FAILURE"); + assertThat(EventDeliveryObservation.errorCode(failure, true, true)).isEqualTo("PERMANENT_FAILURE"); + assertThat(EventDeliveryObservation.errorCode(failure, true, false)).isEqualTo("INVALID_EVENT"); + } +} diff --git a/config/nacos/cn/README.md b/config/nacos/cn/README.md deleted file mode 100644 index 704d7ffbf..000000000 --- a/config/nacos/cn/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# 国内 Nacos 配置 - -将本目录文件按原文件名作为 Data ID 发布到 `prod-cn` 命名空间和 `BAIYUE` 分组。配置只引用环境变量,不保存密码或访问密钥。Gateway 必须同时启用 `cn` Profile。 - -尾程配置不进入本仓库;头程和尾程只通过跨区域事件桥协作。 diff --git a/config/nacos/cn/auth-service.yml b/config/nacos/cn/auth-service.yml deleted file mode 100644 index 891768268..000000000 --- a/config/nacos/cn/auth-service.yml +++ /dev/null @@ -1,17 +0,0 @@ -spring: - datasource: - url: ${AUTH_DB_URL} - username: ${AUTH_DB_USERNAME} - password: ${AUTH_DB_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver -baiyue: - security: - access-token-ttl: ${ACCESS_TOKEN_TTL:24m} - refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d} - overseas-jwt: - enabled: ${OVERSEAS_JWT_ENABLED:true} - issuer: ${OVERSEAS_JWT_ISSUER:auth-service} - audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api} - rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:} - current-kid: ${OVERSEAS_JWT_CURRENT_KID:} - access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m} diff --git a/config/nacos/cn/event-bridge-service.yml b/config/nacos/cn/event-bridge-service.yml deleted file mode 100644 index 27a06967c..000000000 --- a/config/nacos/cn/event-bridge-service.yml +++ /dev/null @@ -1,15 +0,0 @@ -spring: - datasource: - url: ${EVENT_BRIDGE_DB_URL} - username: ${EVENT_BRIDGE_DB_USERNAME} - password: ${EVENT_BRIDGE_DB_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver -baiyue: - region: ${REGION_CODE:CN} - deployment-cell: ${DEPLOYMENT_CELL:prod-cn} - data-residency: ${DATA_RESIDENCY:CN} - event-dispatcher: - consumer-url: ${EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events} - firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://firstleg:9202/internal/firstleg/v1/container-events} - finance-consumer-url: ${FINANCE_FEE_EVENT_CONSUMER_URL:http://finance:9203/internal/finance/v1/business-fee-source-events} - finance-wallet-consumer-url: ${FINANCE_WALLET_EVENT_CONSUMER_URL:http://finance:9203/internal/finance/v1/wallet-command-events} diff --git a/config/nacos/cn/finance-service.yml b/config/nacos/cn/finance-service.yml deleted file mode 100644 index aa39d45b0..000000000 --- a/config/nacos/cn/finance-service.yml +++ /dev/null @@ -1,9 +0,0 @@ -spring: - datasource: - url: ${FINANCE_DB_URL} - username: ${FINANCE_DB_USERNAME} - password: ${FINANCE_DB_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver -baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:} -baiyue.finance.recharge.qr-code-url: ${FINANCE_RECHARGE_QR_CODE_URL:} -baiyue.finance.wallet.default-credit-limit: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:0.00} diff --git a/config/nacos/local/README.md b/config/nacos/local/README.md deleted file mode 100644 index ce6d3e445..000000000 --- a/config/nacos/local/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# 头程本地 Nacos 配置 - -本目录配置用于本机开发环境,发布目标为 `public` 命名空间和 `BAIYUE` 分组。 - -配置通过环境变量保留覆盖能力,并提供当前本机可直接启动的默认地址。生产环境必须使用 `config/nacos/cn`,不得使用本目录中的本地密码和本地密钥。 - -发布命令: - -```powershell -powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg\scripts\publish-local-nacos-config.ps1 -``` - -也可以使用统一入口;`local` 会被强制限制在 `public` namespace: - -```powershell -powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg\scripts\publish-nacos-config.ps1 -Environment local -``` - -配置发布成功后,各服务仍由自身的 Nacos Discovery 配置在启动时自动注册,不需要手工创建服务实例。 - -本地海外链路也发布在同一 `public / BAIYUE` 作用域,包括 -`gateway-overseas.yml`、`lastleg-service.yml` 和 -`warehouse-overseas-service.yml`。这些配置默认通过 `127.0.0.1` 连接本机 -Auth、国内仓库、文件和海外服务;需要跨主机运行时使用对应环境变量覆盖。 - -## MyBatis SQL 日志 - -本地公共配置使用 MyBatis `Slf4jImpl`,数据库服务的 Mapper 日志级别默认为 `DEBUG`。执行数据库操作时,控制台会打印 SQL 模板、参数和影响行数。 - -可通过环境变量统一调整: - -```powershell -$env:MYBATIS_SQL_LOG_LEVEL = "INFO" # 关闭 SQL 明细 -$env:MYBATIS_SQL_LOG_LEVEL = "DEBUG" # 打开 SQL 明细 -``` - -修改后重新发布本地 Nacos 配置并重启相关服务。SQL 参数可能包含业务数据,该设置只用于本地开发,不得复制到测试或生产环境。 diff --git a/config/nacos/local/audit-service.yml b/config/nacos/local/audit-service.yml deleted file mode 100644 index f1d414ec6..000000000 --- a/config/nacos/local/audit-service.yml +++ /dev/null @@ -1,5 +0,0 @@ -spring.datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${AUDIT_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_audit?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${AUDIT_DB_USERNAME:root} - password: ${AUDIT_DB_PASSWORD:123456} diff --git a/config/nacos/local/auth-service.yml b/config/nacos/local/auth-service.yml deleted file mode 100644 index 64d7b2be0..000000000 --- a/config/nacos/local/auth-service.yml +++ /dev/null @@ -1,15 +0,0 @@ -spring.datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${AUTH_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${AUTH_DB_USERNAME:root} -password: ${AUTH_DB_PASSWORD:123456} - -baiyue: - security: - overseas-jwt: - enabled: ${OVERSEAS_JWT_ENABLED:true} - issuer: ${OVERSEAS_JWT_ISSUER:auth-service} - audience: ${OVERSEAS_JWT_AUDIENCE:lastleg-api} - rsa-jwk-set: ${OVERSEAS_JWT_RSA_JWK_SET:} - current-kid: ${OVERSEAS_JWT_CURRENT_KID:} - access-token-ttl: ${OVERSEAS_ACCESS_TOKEN_TTL:15m} diff --git a/config/nacos/local/baiyue-common.yml b/config/nacos/local/baiyue-common.yml deleted file mode 100644 index 5acc5cbe2..000000000 --- a/config/nacos/local/baiyue-common.yml +++ /dev/null @@ -1,73 +0,0 @@ -baiyue: - deployment: - region-code: ${REGION_CODE:CN} - cell: ${DEPLOYMENT_CELL:local-cn} - data-residency: ${DATA_RESIDENCY:CN} - security: - jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} - issuer: ${JWT_ISSUER:auth-service} - internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} - token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth} - warehouse: - public-tenant-id: 0 - openapi.enabled: ${SWAGGER_ENABLED:true} - audit: - enabled: true - endpoint: ${AUDIT_SERVICE_URL:http://127.0.0.1:9700}/internal/audit/events - outbox: - enabled: ${AUDIT_OUTBOX_ENABLED:false} - publisher-enabled: ${AUDIT_OUTBOX_PUBLISHER_ENABLED:false} - batch-size: ${AUDIT_OUTBOX_BATCH_SIZE:50} - lease-seconds: ${AUDIT_OUTBOX_LEASE_SECONDS:60} - max-attempts: ${AUDIT_OUTBOX_MAX_ATTEMPTS:5} - max-compensations: ${AUDIT_OUTBOX_MAX_COMPENSATIONS:2} - compensation-delay-minutes: ${AUDIT_OUTBOX_COMPENSATION_DELAY_MINUTES:30} - retention-days: ${AUDIT_OUTBOX_RETENTION_DAYS:30} - rocketmq: - topic: ${MQ_TOPIC_PREFIX:test-}baiyue-audit-event-v1 - send-timeout-ms: ${AUDIT_MQ_SEND_TIMEOUT_MS:3000} -spring: - jackson: - default-property-inclusion: non_null - deserialization.fail-on-unknown-properties: false - serialization.write-dates-as-timestamps: false - data: - redis: - host: ${REDIS_HOST:127.0.0.1} - port: ${REDIS_PORT:6379} - password: ${REDIS_PASSWORD:} - database: ${REDIS_DATABASE:0} - timeout: ${REDIS_TIMEOUT:3s} - cloud: - nacos.discovery: - # Local services must stay reachable through the gateway when VPN adapters change priority. - ip: ${NACOS_DISCOVERY_IP:127.0.0.1} - group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:public} - openfeign: - circuitbreaker.enabled: true - client.config.default: - connect-timeout: 3000 - read-timeout: 5000 -server: - shutdown: graceful - max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB} -management: - endpoints.web.exposure.include: health,info,prometheus - endpoint.health: - probes.enabled: true - show-details: always -mybatis-plus: - configuration: - map-underscore-to-camel-case: true - call-setters-on-nulls: true - global-config: - banner: false - db-config: - id-type: assign_id - logic-delete-field: deleted - logic-delete-value: 1 - logic-not-delete-value: 0 -springdoc: - api-docs.enabled: ${SWAGGER_ENABLED:true} - swagger-ui.enabled: ${SWAGGER_ENABLED:true} diff --git a/config/nacos/local/event-bridge-service.yml b/config/nacos/local/event-bridge-service.yml deleted file mode 100644 index e70f98f7f..000000000 --- a/config/nacos/local/event-bridge-service.yml +++ /dev/null @@ -1,11 +0,0 @@ -spring.datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${EVENT_BRIDGE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_event_bridge?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true} - username: ${EVENT_BRIDGE_DB_USERNAME:root} - password: ${EVENT_BRIDGE_DB_PASSWORD:123456} -baiyue: - region: ${REGION_CODE:CN} - event-bridge: - accept-any-region: ${EVENT_BRIDGE_ACCEPT_ANY_REGION:true} - deployment-cell: ${DEPLOYMENT_CELL:local-cn} - data-residency: ${DATA_RESIDENCY:CN} diff --git a/config/nacos/local/file-service.yml b/config/nacos/local/file-service.yml deleted file mode 100644 index 31fcdb590..000000000 --- a/config/nacos/local/file-service.yml +++ /dev/null @@ -1,123 +0,0 @@ -spring.datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${FILE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_file?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${FILE_DB_USERNAME:root} - password: ${FILE_DB_PASSWORD:123456} -baiyue.file.storage-root: ${FILE_STORAGE_ROOT:D:/baiyue-firstleg-runtime/uploads} -baiyue: - file: - export: - definitions: - CUSTOMER_LIST: - provider-service: iam-service - provider-path: /internal/exports/v1/CUSTOMER_LIST/batches - required-permission: platform:customer:export - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 500 - - DOMESTIC_WAREHOUSE_ORDER_LIST: - provider-service: order-service - provider-path: /internal/exports/v1/DOMESTIC_WAREHOUSE_ORDER_LIST/batches - required-permission: platform:order:export - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 200 - - LOADING_LIST_BUY_DECLARATION_ITEMS: - provider-service: firstleg-service - provider-path: /internal/exports/v1/LOADING_LIST_BUY_DECLARATION_ITEMS/batches - required-permission: platform:firstleg:customs:manage - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 500 - - FINANCE_CUSTOMER_ACCOUNT_LIST: - provider-service: finance-service - provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LIST/batches - required-permission: platform:finance:customer-account:export - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 500 - FINANCE_CUSTOMER_ACCOUNT_LEDGER: - provider-service: finance-service - provider-path: /internal/exports/v1/FINANCE_CUSTOMER_ACCOUNT_LEDGER/batches - required-permission: platform:finance:customer-account:export - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 500 - FINANCE_FEE_SOURCE_LIST: - provider-service: finance-service - provider-path: /internal/exports/v1/FINANCE_FEE_SOURCE_LIST/batches - required-permission: platform:finance:customer-arap:export - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 500 - FINANCE_RECONCILIATION_STATEMENT_LIST: - provider-service: finance-service - provider-path: /internal/exports/v1/FINANCE_RECONCILIATION_STATEMENT_LIST/batches - required-permission: platform:finance:customer-statement:export - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 500 - FINANCE_RECEIVABLE_SETTLEMENT_LIST: - provider-service: finance-service - provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_LIST/batches - required-permission: platform:finance:settlement-ar:export - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 500 - FINANCE_RECEIVABLE_SETTLEMENT_DETAIL: - provider-service: finance-service - provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_SETTLEMENT_DETAIL/batches - required-permission: platform:finance:settlement-ar:export - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 500 - FINANCE_PAYABLE_SETTLEMENT_LIST: - provider-service: finance-service - provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_LIST/batches - required-permission: platform:finance:settlement-ap:export - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 500 - FINANCE_PAYABLE_SETTLEMENT_DETAIL: - provider-service: finance-service - provider-path: /internal/exports/v1/FINANCE_PAYABLE_SETTLEMENT_DETAIL/batches - required-permission: platform:finance:settlement-ap:export - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 500 - FINANCE_RECEIVABLE_STATISTICS: - provider-service: finance-service - provider-path: /internal/exports/v1/FINANCE_RECEIVABLE_STATISTICS/batches - required-permission: platform:finance:receivable-statistics:export - app-scope: PLATFORM - enabled: true - max-rows: 200000 - max-attempts: 3 - batch-size: 500 -baiyue.clients.finance.url: ${FINANCE_SERVICE_URL:} -baiyue.clients.integration.url: ${INTEGRATION_SERVICE_URL:} diff --git a/config/nacos/local/finance-service.yml b/config/nacos/local/finance-service.yml deleted file mode 100644 index 6269c2f7a..000000000 --- a/config/nacos/local/finance-service.yml +++ /dev/null @@ -1,9 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${FINANCE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_finance?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${FINANCE_DB_USERNAME:root} - password: ${FINANCE_DB_PASSWORD:123456} -baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:} -baiyue.finance.recharge.qr-code-url: ${FINANCE_RECHARGE_QR_CODE_URL:/customer-recharge-qr.svg} -baiyue.finance.wallet.default-credit-limit: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:0.00} diff --git a/config/nacos/local/firstleg-service.yml b/config/nacos/local/firstleg-service.yml deleted file mode 100644 index de2ce6708..000000000 --- a/config/nacos/local/firstleg-service.yml +++ /dev/null @@ -1,69 +0,0 @@ -spring.datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${FIRSTLEG_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_firstleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${FIRSTLEG_DB_USERNAME:root} - password: ${FIRSTLEG_DB_PASSWORD:123456} -spring.servlet.multipart.max-file-size: ${FIRSTLEG_MULTIPART_MAX_FILE_SIZE:50MB} -spring.servlet.multipart.max-request-size: ${FIRSTLEG_MULTIPART_MAX_REQUEST_SIZE:52MB} -baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:} -baiyue.clients.order.url: ${ORDER_SERVICE_URL:} -baiyue.clients.file.url: ${FILE_SERVICE_URL:} -baiyue.carrier-schedule.providers.matson-automated: ${MATSON_TRACKING_ENABLED:false} -baiyue.carrier-schedule.providers.sml-automated: ${SML_TRACKING_ENABLED:false} -baiyue.carrier-schedule.providers.zim-official-api-enabled: ${ZIM_TRACKING_ENABLED:false} -baiyue.carrier-schedule.providers.cosco-official-api-enabled: ${COSCO_OPEN_API_ENABLED:false} -baiyue.carrier-schedule.providers.emc-automated: ${EMC_TRACKING_ENABLED:false} -baiyue.carrier-schedule.providers.hede-automated: ${HEDE_TRACKING_ENABLED:false} -baiyue.carrier-schedule.scheduler.zone: ${CARRIER_SCHEDULE_ZONE:Asia/Shanghai} -baiyue.carrier-schedule.scheduler.enabled: ${CARRIER_SCHEDULE_SCHEDULER_ENABLED:false} -baiyue.carrier-schedule.scheduler.scan-fixed-delay: ${CARRIER_SCHEDULE_SCAN_FIXED_DELAY:PT1M} -baiyue.carrier-schedule.scheduler.scan-initial-delay: ${CARRIER_SCHEDULE_SCAN_INITIAL_DELAY:PT10S} -baiyue.carrier-schedule.scheduler.batch-size: ${CARRIER_SCHEDULE_BATCH_SIZE:20} -baiyue.carrier-schedule.scheduler.max-tasks-per-trigger: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:2000} -baiyue.carrier-schedule.scheduler.lease-duration: ${CARRIER_SCHEDULE_LEASE_DURATION:PT5M} -firstleg: - lingxing: - auto-sync-enabled: ${LINGXING_AUTO_SYNC_ENABLED:false} - base-url: ${LINGXING_DIRECT_BASE_URL:https://wms.xlwms.com} - login-origin: ${LINGXING_LOGIN_ORIGIN:https://wms.xlwms.com} - provider-key: ${LINGXING_PROVIDER_KEY:LINGXING_01} - # Firstleg and the isolated Lingxing browser run on the same Docker network. - # Keep the environment override for deployments that use another endpoint. - browser-cdp-endpoint: ${LINGXING_BROWSER_CDP_ENDPOINT:http://lingxing-browser:9222} - username: ${LINGXING_USERNAME:} - password: ${LINGXING_PASSWORD:} - browser-session-required: ${LINGXING_BROWSER_SESSION_REQUIRED:true} - token-ttl-seconds: ${LINGXING_TOKEN_TTL_SECONDS:172800} - token-expiry-safety-seconds: ${LINGXING_TOKEN_EXPIRY_SAFETY_SECONDS:300} - track-key-v2-secret-base64: ${LINGXING_TRACK_KEY_V2_SECRET_BASE64:} - request-timeout: ${LINGXING_REQUEST_TIMEOUT:30s} - warehouses: [US01, US02] - inbound: - allow-rejected-measurement: false - default-pallet-capacity-cbm: ${DEFAULT_PALLET_CAPACITY_CBM:1.200000} - reservation-ttl: ${PALLET_RESERVATION_TTL:30m} - warehouse-resource-tenant-id: 0 - measurement-openapi: - enabled: ${MEASUREMENT_OPENAPI_ENABLED:false} - # 墨家首轮联调暂不验 HMAC;通讯确认后将环境变量改为 true 即可恢复。 - signature-required: ${MEASUREMENT_OPENAPI_SIGNATURE_REQUIRED:false} - max-clock-skew: ${MEASUREMENT_OPENAPI_MAX_CLOCK_SKEW:5m} - nonce-ttl: ${MEASUREMENT_OPENAPI_NONCE_TTL:10m} - apps: - # 墨家使用平台级 AppKey;tenant-id 固定为 0,按全局箱码解析真实客户。 - # warehouse-bindings 的键必须与设备上传的 machineNo 完全一致。 - mojia: - enabled: ${MOJIA_MEASUREMENT_ENABLED:false} - secret: ${MOJIA_MEASUREMENT_SECRET:} - tenant-id: ${MOJIA_MEASUREMENT_TENANT_ID:0} - subject-id: ${MOJIA_MEASUREMENT_SUBJECT_ID:0} - warehouse-bindings: - mojia-yiwu-01: ${MOJIA_MEASUREMENT_WAREHOUSE_ID:0} - # 历史通用应用配置,保留兼容;新接入请使用 mojia。 - device-provider: - enabled: ${MEASUREMENT_DEVICE_PROVIDER_ENABLED:false} - secret: ${MEASUREMENT_DEVICE_PROVIDER_SECRET:} - tenant-id: ${MEASUREMENT_DEVICE_PROVIDER_TENANT_ID:0} - subject-id: ${MEASUREMENT_DEVICE_PROVIDER_SUBJECT_ID:0} - warehouse-bindings: - CN-YW-01: ${MEASUREMENT_DEVICE_PROVIDER_WAREHOUSE_ID:0} diff --git a/config/nacos/local/gateway-overseas.yml b/config/nacos/local/gateway-overseas.yml deleted file mode 100644 index ea2ef8cb5..000000000 --- a/config/nacos/local/gateway-overseas.yml +++ /dev/null @@ -1,16 +0,0 @@ -springdoc: - api-docs.enabled: ${SWAGGER_ENABLED:true} - swagger-ui.enabled: ${SWAGGER_ENABLED:true} -baiyue: - security: - issuer: ${JWT_ISSUER:auth-service} - audience: ${JWT_AUDIENCE:lastleg-api} - jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} - pda-session-validation: - auth-url: ${AUTH_SESSION_VALIDATION_URL:http://127.0.0.1:9100} - internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} - timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s} - overseas-projection: - service-url: ${LASTLEG_SECURITY_SERVICE_URL:http://127.0.0.1:9303} - internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} - timeout: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:2s} diff --git a/config/nacos/local/gateway.yml b/config/nacos/local/gateway.yml deleted file mode 100644 index c31d6422d..000000000 --- a/config/nacos/local/gateway.yml +++ /dev/null @@ -1,11 +0,0 @@ -springdoc: - api-docs.enabled: ${SWAGGER_ENABLED:true} - swagger-ui.enabled: ${SWAGGER_ENABLED:true} -baiyue: - cors: - allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:5174,http://127.0.0.1:5173,http://127.0.0.1:5174} - allowed-methods: ${CORS_ALLOWED_METHODS:GET,POST,PUT,PATCH,DELETE,OPTIONS} - allowed-headers: ${CORS_ALLOWED_HEADERS:*} - exposed-headers: ${CORS_EXPOSED_HEADERS:X-Request-Id,X-Trace-Id} - allow-credentials: ${CORS_ALLOW_CREDENTIALS:true} - max-age: ${CORS_MAX_AGE:3600} diff --git a/config/nacos/local/iam-service.yml b/config/nacos/local/iam-service.yml deleted file mode 100644 index a1a4c5f90..000000000 --- a/config/nacos/local/iam-service.yml +++ /dev/null @@ -1,16 +0,0 @@ -spring.datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${IAM_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_iam?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${IAM_DB_USERNAME:root} - password: ${IAM_DB_PASSWORD:123456} -baiyue: - security-event: - # 本机只运行一个 Event Bridge;部署环境通过 SECURITY_EVENT_BRIDGE_URL 指向海外实例。 - event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://127.0.0.1:9312} - customer-dimension-initialization: - fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000} - max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12} - notification: - scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false} - dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300} - mq-consumer-enabled: ${NOTIFICATION_MQ_CONSUMER_ENABLED:false} diff --git a/config/nacos/local/integration-service.yml b/config/nacos/local/integration-service.yml deleted file mode 100644 index b02d9d323..000000000 --- a/config/nacos/local/integration-service.yml +++ /dev/null @@ -1,53 +0,0 @@ -spring: - servlet: - multipart: - max-file-size: 5MB - max-request-size: 6MB - -baiyue: - integration: - baidu-ocr: - enabled: ${BAIDU_OCR_ENABLED:false} - input-root: ${WEIGHT_OCR_INPUT_ROOT:${java.io.tmpdir}/baiyue-weight-ocr/input} - output-root: ${WEIGHT_OCR_OUTPUT_ROOT:${java.io.tmpdir}/baiyue-weight-ocr/output} - max-image-size: ${WEIGHT_OCR_MAX_IMAGE_SIZE:5MB} - max-image-pixels: ${WEIGHT_OCR_MAX_IMAGE_PIXELS:40000000} - max-batch-size: ${WEIGHT_OCR_MAX_BATCH_SIZE:50} - amazon-sp-api: - enabled: ${AMAZON_SP_API_ENABLED:false} - carrier-schedule: - matson: - enabled: ${MATSON_TRACKING_ENABLED:false} - page-url: ${MATSON_TRACKING_PAGE_URL:https://www.matson.com/shipment-tracking.html} - query-url: ${MATSON_TRACKING_QUERY_URL:https://www.matson.com/wp-content/plugins/matson-plugin/Api_calls/tracking_booking_proxy.php} - connect-timeout: ${MATSON_TRACKING_CONNECT_TIMEOUT:10s} - request-timeout: ${MATSON_TRACKING_REQUEST_TIMEOUT:30s} - min-request-interval: ${MATSON_TRACKING_MIN_REQUEST_INTERVAL:5s} - hede: - enabled: ${HEDE_TRACKING_ENABLED:false} - query-url: ${HEDE_TRACKING_QUERY_URL:http://elines.hedehk.com/getVBilldynamic} - connect-timeout: ${HEDE_TRACKING_CONNECT_TIMEOUT:10s} - request-timeout: ${HEDE_TRACKING_REQUEST_TIMEOUT:30s} - min-request-interval: ${HEDE_TRACKING_MIN_REQUEST_INTERVAL:5s} - sml: - enabled: ${SML_TRACKING_ENABLED:false} - query-url: ${SML_TRACKING_QUERY_URL:https://esvc.smlines.com/smline/CUP_HOM_3301GS.do} - connect-timeout: ${SML_TRACKING_CONNECT_TIMEOUT:10s} - request-timeout: ${SML_TRACKING_REQUEST_TIMEOUT:30s} - min-request-interval: ${SML_TRACKING_MIN_REQUEST_INTERVAL:5s} - zim: - enabled: ${ZIM_TRACKING_ENABLED:false} - authorization-url: ${ZIM_AUTHORIZATION_URL:https://apigw.zim.com/authorize/v1} - tracing-base-url: ${ZIM_TRACING_BASE_URL:https://apigw.zim.com/tracing/v2} - client-id: ${ZIM_CLIENT_ID:} - client-secret: ${ZIM_CLIENT_SECRET:} - subscription-key: ${ZIM_TRACKING_SUBSCRIPTION_KEY:} - connect-timeout: ${ZIM_TRACKING_CONNECT_TIMEOUT:10s} - request-timeout: ${ZIM_TRACKING_REQUEST_TIMEOUT:30s} - max-concurrency: ${ZIM_TRACKING_MAX_CONCURRENCY:10} - evergreen: - enabled: ${EMC_TRACKING_ENABLED:false} - query-url: ${EMC_TRACKING_QUERY_URL:https://www.evergreen-shipping.cn/servlet/TDB1_CargoTracking.do} - connect-timeout: ${EMC_TRACKING_CONNECT_TIMEOUT:10s} - request-timeout: ${EMC_TRACKING_REQUEST_TIMEOUT:30s} - min-request-interval: ${EMC_TRACKING_MIN_REQUEST_INTERVAL:5s} diff --git a/config/nacos/local/lastleg-service.yml b/config/nacos/local/lastleg-service.yml deleted file mode 100644 index 77ec1e8b2..000000000 --- a/config/nacos/local/lastleg-service.yml +++ /dev/null @@ -1,29 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${LASTLEG_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_lastleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${LASTLEG_DB_USERNAME:root} - password: ${LASTLEG_DB_PASSWORD:123456} -lastleg: - dispatch-query-api.enabled: ${LASTLEG_DISPATCH_QUERY_API_ENABLED:true} - smart-dispatch-api.enabled: ${LASTLEG_SMART_DISPATCH_API_ENABLED:true} -baiyue: - clients: - domestic-warehouse: - url: ${DOMESTIC_WAREHOUSE_URL:http://127.0.0.1:9205} - cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} - file: - url: ${FILE_SERVICE_URL:http://127.0.0.1:9206} - event-bridge: - url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:http://127.0.0.1:9312}} - warehouse: - base-url: ${WAREHOUSE_OVERSEAS_SERVICE_URL:http://127.0.0.1:9305} - firstleg: - event-bridge-url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL:http://127.0.0.1:9312}} - security: - internal-service-token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} - jwt-mode: jwks - session-mode: gateway - issuer: ${JWT_ISSUER:auth-service} - audience: ${JWT_AUDIENCE:lastleg-api} - jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} diff --git a/config/nacos/local/order-service.yml b/config/nacos/local/order-service.yml deleted file mode 100644 index 16d034278..000000000 --- a/config/nacos/local/order-service.yml +++ /dev/null @@ -1,11 +0,0 @@ -spring.datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${ORDER_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_order?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${ORDER_DB_USERNAME:root} - password: ${ORDER_DB_PASSWORD:123456} -baiyue.clients: - warehouse.url: ${WAREHOUSE_SERVICE_URL:} - -baiyue.order: - # 默认由新系统自动生成;仅在两套系统并跑期间显式改为 LEGACY_MANUAL。 - inbound-code-mode: ${ORDER_INBOUND_CODE_MODE:AUTO} diff --git a/config/nacos/local/print-service.yml b/config/nacos/local/print-service.yml deleted file mode 100644 index c3fc80ed8..000000000 --- a/config/nacos/local/print-service.yml +++ /dev/null @@ -1,11 +0,0 @@ -print: - pdf: - max-pages: ${PRINT_PDF_MAX_PAGES:2000} - max-request-bytes: ${PRINT_PDF_MAX_REQUEST_BYTES:2097152} - max-output-bytes: ${PRINT_PDF_MAX_OUTPUT_BYTES:33554432} - max-concurrent-generations: ${PRINT_PDF_MAX_CONCURRENT_GENERATIONS:4} - acquire-timeout: ${PRINT_PDF_ACQUIRE_TIMEOUT:1s} - template-code: INBOUND_MARK_V1 - supported-width-mm: 100 - supported-height-mm: 100 - font-resource: fonts/NotoSansSC-Bold.ttf diff --git a/config/nacos/local/warehouse-overseas-service.yml b/config/nacos/local/warehouse-overseas-service.yml deleted file mode 100644 index 7edeaec76..000000000 --- a/config/nacos/local/warehouse-overseas-service.yml +++ /dev/null @@ -1,19 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${WAREHOUSE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_lastlag_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${WAREHOUSE_DB_USERNAME:root} - password: ${WAREHOUSE_DB_PASSWORD:123456} -baiyue: - clients: - domestic-warehouse: - url: ${DOMESTIC_WAREHOUSE_URL:http://127.0.0.1:9205} - cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} - internal-service: - token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} - security: - jwt-mode: jwks - session-mode: gateway - issuer: ${JWT_ISSUER:auth-service} - audience: ${JWT_AUDIENCE:lastleg-api} - jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} diff --git a/config/nacos/local/warehouse-service.yml b/config/nacos/local/warehouse-service.yml deleted file mode 100644 index b0e8a969c..000000000 --- a/config/nacos/local/warehouse-service.yml +++ /dev/null @@ -1,5 +0,0 @@ -spring.datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${WAREHOUSE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${WAREHOUSE_DB_USERNAME:root} - password: ${WAREHOUSE_DB_PASSWORD:123456} diff --git a/config/nacos/overseas/README.md b/config/nacos/overseas/README.md deleted file mode 100644 index 71866504e..000000000 --- a/config/nacos/overseas/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# 海外 Nacos 配置 - -本目录是海外远程部署模板,由 dev/development、test 和 production 共用。配置只引用环境变量,不保存密码或访问密钥;三个环境必须发布到各自独立的 Nacos namespace,Gateway 必须同时启用 `overseas` Profile。 - -海外入口与仓储 Data ID 分别为 `gateway-overseas.yml`、`warehouse-overseas-service.yml`。`print-service.yml` 对应头程仓库保留的共用打印服务,不包含尾程旧 Print 模块。 - -发布示例: - -```powershell -# 开发环境 -powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg\scripts\publish-nacos-config.ps1 ` - -Environment development -Region overseas -ServerAddr -Namespace - -# 测试环境 -powershell -ExecutionPolicy Bypass -File D:\baiyue-firstleg\scripts\publish-nacos-config.ps1 ` - -Environment test -Region overseas -ServerAddr -Namespace -``` - -远程环境禁止使用 `public` namespace。数据库、Redis、JWKS、内部服务令牌和跨区服务地址继续由对应 Jenkins Secret file 提供;不要把真实值写入本目录。 diff --git a/config/nacos/overseas/audit-service.yml b/config/nacos/overseas/audit-service.yml deleted file mode 100644 index 1ac49f251..000000000 --- a/config/nacos/overseas/audit-service.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - datasource: - url: ${AUDIT_DB_URL} - username: ${AUDIT_DB_USERNAME} - password: ${AUDIT_DB_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/config/nacos/overseas/baiyue-common.yml b/config/nacos/overseas/baiyue-common.yml deleted file mode 100644 index a5e5cb364..000000000 --- a/config/nacos/overseas/baiyue-common.yml +++ /dev/null @@ -1,67 +0,0 @@ -baiyue: - deployment: - region-code: ${REGION_CODE:OVERSEAS} - cell: ${DEPLOYMENT_CELL:prod-overseas} - data-residency: ${DATA_RESIDENCY:OVERSEAS} - security: - issuer: ${JWT_ISSUER:auth-service} - audience: ${JWT_AUDIENCE:lastleg-api} - jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} - internal-service-token: ${INTERNAL_SERVICE_TOKEN} - pda-session-validation: - auth-url: ${AUTH_SESSION_VALIDATION_URL} - internal-service-token: ${INTERNAL_SERVICE_TOKEN} - timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s} - permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot} - openapi.enabled: ${SWAGGER_ENABLED:false} - audit.enabled: true -spring: - jackson: - default-property-inclusion: non_null - deserialization: - fail-on-unknown-properties: false - serialization: - write-dates-as-timestamps: false - data: - redis: - host: ${REDIS_HOST:redis-overseas.internal} - port: ${REDIS_PORT:6379} - password: ${REDIS_PASSWORD:} - database: ${REDIS_DATABASE:0} - timeout: ${REDIS_TIMEOUT:3s} - cloud: - nacos: - discovery: - group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:prod-overseas} - openfeign: - circuitbreaker.enabled: true - client.config.default: - connect-timeout: 3000 - read-timeout: 5000 -server: - max-http-request-header-size: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:32KB} - shutdown: graceful -management: - endpoints: - web: - exposure: - include: health,info,prometheus - endpoint: - health: - probes.enabled: true - show-details: never -mybatis-plus: - configuration: - map-underscore-to-camel-case: true - call-setters-on-nulls: true - global-config: - banner: false - db-config: - id-type: assign_id - logic-delete-field: deleted - logic-delete-value: 1 - logic-not-delete-value: 0 -springdoc: - api-docs.enabled: ${SWAGGER_ENABLED:false} - swagger-ui.enabled: ${SWAGGER_ENABLED:false} diff --git a/config/nacos/overseas/event-bridge-service.yml b/config/nacos/overseas/event-bridge-service.yml deleted file mode 100644 index 00a8ac755..000000000 --- a/config/nacos/overseas/event-bridge-service.yml +++ /dev/null @@ -1,21 +0,0 @@ -spring: - datasource: - url: ${EVENT_BRIDGE_DB_URL} - username: ${EVENT_BRIDGE_DB_USERNAME} - password: ${EVENT_BRIDGE_DB_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver -baiyue: - region: ${REGION_CODE:OVERSEAS} - deployment-cell: ${DEPLOYMENT_CELL:prod-overseas} - data-residency: ${DATA_RESIDENCY:OVERSEAS} - event-dispatcher: - consumer-url: ${EVENT_CONSUMER_URL:http://lastleg-service:9303/internal/lastleg/events} - security-consumer-url: ${LASTLEG_SECURITY_EVENT_CONSUMER_URL:http://lastleg-service:9303/internal/lastleg/security/v1/events} - fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000} - batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} - max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} - lease-duration: ${EVENT_DISPATCH_LEASE_DURATION:30s} - initial-backoff: ${EVENT_DISPATCH_INITIAL_BACKOFF:5s} - max-backoff: ${EVENT_DISPATCH_MAX_BACKOFF:15m} - connect-timeout: ${EVENT_DISPATCH_CONNECT_TIMEOUT:3s} - request-timeout: ${EVENT_DISPATCH_REQUEST_TIMEOUT:10s} diff --git a/config/nacos/overseas/file-service.yml b/config/nacos/overseas/file-service.yml deleted file mode 100644 index 971474618..000000000 --- a/config/nacos/overseas/file-service.yml +++ /dev/null @@ -1,13 +0,0 @@ -spring: - datasource: - url: ${FILE_DB_URL} - username: ${FILE_DB_USERNAME} - password: ${FILE_DB_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver -file: - storage: - provider: ${FILE_STORAGE_PROVIDER:s3} - endpoint: ${FILE_STORAGE_ENDPOINT:} - bucket: ${FILE_STORAGE_BUCKET:} - access-key: ${FILE_STORAGE_ACCESS_KEY:} - secret-key: ${FILE_STORAGE_SECRET_KEY:} diff --git a/config/nacos/overseas/gateway-overseas.yml b/config/nacos/overseas/gateway-overseas.yml deleted file mode 100644 index a9c1eadf2..000000000 --- a/config/nacos/overseas/gateway-overseas.yml +++ /dev/null @@ -1,9 +0,0 @@ -springdoc: - api-docs.enabled: ${SWAGGER_ENABLED:false} - swagger-ui.enabled: ${SWAGGER_ENABLED:false} -baiyue: - security: - overseas-projection: - service-url: ${LASTLEG_SECURITY_SERVICE_URL:http://lastleg-service:9303} - internal-service-token: ${INTERNAL_SERVICE_TOKEN} - timeout: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:2s} diff --git a/config/nacos/overseas/integration-service.yml b/config/nacos/overseas/integration-service.yml deleted file mode 100644 index 25617fae1..000000000 --- a/config/nacos/overseas/integration-service.yml +++ /dev/null @@ -1,12 +0,0 @@ -baiyue: - integration: - baidu-ocr: - enabled: ${BAIDU_OCR_ENABLED:false} - amazon-sp-api: - enabled: ${AMAZON_SP_API_ENABLED:true} - client-id: ${AMAZON_SP_API_CLIENT_ID:} - client-secret: ${AMAZON_SP_API_CLIENT_SECRET:} - refresh-token: ${AMAZON_SP_API_REFRESH_TOKEN:} - aws-access-key: ${AMAZON_SP_API_AWS_ACCESS_KEY:} - aws-secret-key: ${AMAZON_SP_API_AWS_SECRET_KEY:} - region: ${AMAZON_SP_API_REGION:} diff --git a/config/nacos/overseas/print-service.yml b/config/nacos/overseas/print-service.yml deleted file mode 100644 index 613433e3f..000000000 --- a/config/nacos/overseas/print-service.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - datasource: - url: ${PRINT_DB_URL} - username: ${PRINT_DB_USERNAME} - password: ${PRINT_DB_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/config/nacos/test/README.md b/config/nacos/test/README.md new file mode 100644 index 000000000..09a6d2b79 --- /dev/null +++ b/config/nacos/test/README.md @@ -0,0 +1,11 @@ +# TEST 单机配置 + +唯一导入目录为本目录的 14 个 YAML:`baiyue-common.yml` 加 13 个服务 Data ID。Group 默认 `BAIYUE`,所有配置和发现共用实际 `NACOS_NAMESPACE` ID;不能填写命名空间显示名。先导入公共配置再导入服务配置。应用不再使用 optional import,缺失配置应阻断启动。 + +变量在服务进程内解析,导入文件不得替换成真实密钥。无密钥模板见 `../../../deploy/env/.env.test.example`。私钥仅传入 Auth;运维令牌仅传入 Firstleg、Finance、Lastleg。各业务数据库 Schema 独立。 + +离线预检:`./scripts/publish-nacos-config.ps1 -ServerAddr '' -Namespace '' -DryRun`(从后端根运行,不联网、不登录)。正式发布去掉 DryRun,会修改指定 Nacos,需授权;认证从进程环境 NACOS_USERNAME/NACOS_PASSWORD 取得。失败立即停止,已发布项不自动撤销,保存本轮配置版本并在停写窗口重新发布完整匹配集合。 + +切换顺序、发布与恢复约束见 `../../../deploy/TEST单机部署执行清单.md`;文件存在不代表环境已发布。 + +发布脚本仅使用 Nacos v3:`POST /nacos/v3/auth/user/login` 取得 accessToken,`GET /nacos/v3/admin/core/namespace/list` 精确核对实际 Namespace ID,再逐项 `POST /nacos/v3/admin/cs/config`(`namespaceId`、`groupName`、`dataId`、`type=yaml`、UTF-8 表单内容)。发布响应必须为 `code=0` 且布尔值 `data=true`;旧 v1 路径、tenant/group 参数和裸 true 响应不再支持。离线合同:`node --test scripts/publish-nacos-config-contract.test.mjs`,全部 HTTP 由测试替身拦截。协议依据:[Admin API](https://nacos.io/en/docs/latest/manual/admin/admin-api/)、[鉴权](https://nacos.io/en/docs/latest/manual/admin/auth/)。 diff --git a/config/nacos/cn/audit-service.yml b/config/nacos/test/audit-service.yml similarity index 100% rename from config/nacos/cn/audit-service.yml rename to config/nacos/test/audit-service.yml diff --git a/config/nacos/test/auth-service.yml b/config/nacos/test/auth-service.yml new file mode 100644 index 000000000..9e4613181 --- /dev/null +++ b/config/nacos/test/auth-service.yml @@ -0,0 +1,20 @@ +spring: + cloud: + openfeign: + client: + config: + iamAuthorizationClient: + connect-timeout: 1000 + read-timeout: 2000 + follow-redirects: false + datasource: + url: ${AUTH_DB_URL} + username: ${AUTH_DB_USERNAME} + password: ${AUTH_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver +baiyue: + security: + access-token-ttl: ${ACCESS_TOKEN_TTL:24m} + refresh-token-ttl: ${REFRESH_TOKEN_TTL:7d} +baiyue.security.jwt.rsa-jwk-set: ${JWT_RSA_JWK_SET} +baiyue.security.jwt.current-kid: ${JWT_CURRENT_KID} diff --git a/config/nacos/cn/baiyue-common.yml b/config/nacos/test/baiyue-common.yml similarity index 81% rename from config/nacos/cn/baiyue-common.yml rename to config/nacos/test/baiyue-common.yml index f7318ae43..db9463ead 100644 --- a/config/nacos/cn/baiyue-common.yml +++ b/config/nacos/test/baiyue-common.yml @@ -1,10 +1,9 @@ baiyue: - deployment: - region-code: ${REGION_CODE:CN} - cell: ${DEPLOYMENT_CELL:prod-cn} - data-residency: ${DATA_RESIDENCY:CN} security: - jwt-secret: ${JWT_SECRET} + jwt-mode: jwks + session-mode: redis + audience: ${JWT_AUDIENCE:lastleg-api} + jwk-set-uri: ${JWT_JWKS_URI:http://auth:9100/api/auth/.well-known/jwks.json} issuer: ${JWT_ISSUER:auth-service} internal-service-token: ${INTERNAL_SERVICE_TOKEN} token-key-prefix: ${REDIS_TOKEN_KEY_PREFIX:baiyue:auth} @@ -24,7 +23,7 @@ baiyue: compensation-delay-minutes: ${AUDIT_OUTBOX_COMPENSATION_DELAY_MINUTES:30} retention-days: ${AUDIT_OUTBOX_RETENTION_DAYS:30} rocketmq: - topic: ${MQ_TOPIC_PREFIX:}baiyue-audit-event-v1 + topic: ${MQ_TOPIC_PREFIX:test-}baiyue-audit-event-v1 send-timeout-ms: ${AUDIT_MQ_SEND_TIMEOUT_MS:3000} spring: jackson: @@ -35,7 +34,8 @@ spring: write-dates-as-timestamps: false data: redis: - host: ${REDIS_HOST:redis-cn.internal} + username: ${REDIS_USERNAME:default} + host: ${REDIS_HOST} port: ${REDIS_PORT:6379} password: ${REDIS_PASSWORD:} database: ${REDIS_DATABASE:0} @@ -44,7 +44,7 @@ spring: nacos: discovery: group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:prod-cn} + namespace: ${NACOS_NAMESPACE} openfeign: circuitbreaker.enabled: true client.config.default: @@ -76,3 +76,5 @@ mybatis-plus: springdoc: api-docs.enabled: ${SWAGGER_ENABLED:false} swagger-ui.enabled: ${SWAGGER_ENABLED:false} + +baiyue.security.session-validation-uri: ${AUTH_SESSION_VALIDATION_URL:http://auth:9100/internal/auth/accounts/sessions/validate} diff --git a/config/nacos/cn/file-service.yml b/config/nacos/test/file-service.yml similarity index 100% rename from config/nacos/cn/file-service.yml rename to config/nacos/test/file-service.yml diff --git a/config/nacos/test/finance-service.yml b/config/nacos/test/finance-service.yml new file mode 100644 index 000000000..dca977209 --- /dev/null +++ b/config/nacos/test/finance-service.yml @@ -0,0 +1,17 @@ +spring: + datasource: + url: ${FINANCE_DB_URL} + username: ${FINANCE_DB_USERNAME} + password: ${FINANCE_DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver +baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:} +baiyue.finance.recharge.qr-code-url: ${FINANCE_RECHARGE_QR_CODE_URL:} +baiyue.finance.wallet.default-credit-limit: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:0.00} +baiyue.event-delivery.operations-token: ${EVENT_OPERATIONS_TOKEN:} +baiyue.event-delivery.connect-timeout-ms: ${EVENT_CONNECT_TIMEOUT_MS:3000} +baiyue.event-delivery.read-timeout-ms: ${EVENT_READ_TIMEOUT_MS:15000} +baiyue.event-delivery.lease-seconds: ${EVENT_LEASE_SECONDS:60} +baiyue.event-delivery.max-attempts: ${EVENT_MAX_ATTEMPTS:12} +baiyue.event-delivery.base-delay-seconds: ${EVENT_BASE_DELAY_SECONDS:5} +baiyue.event-delivery.max-delay-seconds: ${EVENT_MAX_DELAY_SECONDS:1800} +baiyue.event-delivery.jitter: ${EVENT_JITTER:0.1} diff --git a/config/nacos/cn/firstleg-service.yml b/config/nacos/test/firstleg-service.yml similarity index 88% rename from config/nacos/cn/firstleg-service.yml rename to config/nacos/test/firstleg-service.yml index 42b868723..c677a2da4 100644 --- a/config/nacos/cn/firstleg-service.yml +++ b/config/nacos/test/firstleg-service.yml @@ -29,11 +29,8 @@ baiyue: max-tasks-per-trigger: ${CARRIER_SCHEDULE_MAX_TASKS_PER_TRIGGER:2000} lease-duration: ${CARRIER_SCHEDULE_LEASE_DURATION:PT5M} lastleg: - event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL} outbox-relay: fixed-delay-ms: ${LASTLEG_OUTBOX_RELAY_FIXED_DELAY_MS:5000} - lease-seconds: ${LASTLEG_OUTBOX_RELAY_LEASE_SECONDS:60} - retry-seconds: ${LASTLEG_OUTBOX_RELAY_RETRY_SECONDS:30} firstleg: lingxing: # 安全默认关闭;开启后自动任务才允许调用领星新品维护接口。 @@ -81,3 +78,11 @@ firstleg: subject-id: ${MEASUREMENT_DEVICE_PROVIDER_SUBJECT_ID:0} warehouse-bindings: CN-YW-01: ${MEASUREMENT_DEVICE_PROVIDER_WAREHOUSE_ID:0} +baiyue.event-delivery.operations-token: ${EVENT_OPERATIONS_TOKEN:} +baiyue.event-delivery.connect-timeout-ms: ${EVENT_CONNECT_TIMEOUT_MS:3000} +baiyue.event-delivery.read-timeout-ms: ${EVENT_READ_TIMEOUT_MS:15000} +baiyue.event-delivery.lease-seconds: ${EVENT_LEASE_SECONDS:60} +baiyue.event-delivery.max-attempts: ${EVENT_MAX_ATTEMPTS:12} +baiyue.event-delivery.base-delay-seconds: ${EVENT_BASE_DELAY_SECONDS:5} +baiyue.event-delivery.max-delay-seconds: ${EVENT_MAX_DELAY_SECONDS:1800} +baiyue.event-delivery.jitter: ${EVENT_JITTER:0.1} diff --git a/config/nacos/cn/gateway.yml b/config/nacos/test/gateway.yml similarity index 100% rename from config/nacos/cn/gateway.yml rename to config/nacos/test/gateway.yml diff --git a/config/nacos/cn/iam-service.yml b/config/nacos/test/iam-service.yml similarity index 84% rename from config/nacos/cn/iam-service.yml rename to config/nacos/test/iam-service.yml index f735164e7..659d5044e 100644 --- a/config/nacos/cn/iam-service.yml +++ b/config/nacos/test/iam-service.yml @@ -5,8 +5,6 @@ spring: password: ${IAM_DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver baiyue: - security-event: - event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312} customer-dimension-initialization: fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000} max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12} diff --git a/config/nacos/cn/integration-service.yml b/config/nacos/test/integration-service.yml similarity index 100% rename from config/nacos/cn/integration-service.yml rename to config/nacos/test/integration-service.yml diff --git a/config/nacos/overseas/lastleg-service.yml b/config/nacos/test/lastleg-service.yml similarity index 79% rename from config/nacos/overseas/lastleg-service.yml rename to config/nacos/test/lastleg-service.yml index c468507d4..f5da05865 100644 --- a/config/nacos/overseas/lastleg-service.yml +++ b/config/nacos/test/lastleg-service.yml @@ -45,13 +45,16 @@ baiyue: url: ${FILE_SERVICE_URL} domestic-file: url: ${FILE_SERVICE_URL} - event-bridge: - url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL}} warehouse: base-url: ${WAREHOUSE_OVERSEAS_SERVICE_URL:http://warehouse-overseas-service:9305} firstleg: - event-bridge-url: ${CN_EVENT_BRIDGE_URL:${FIRSTLEG_EVENT_BRIDGE_URL}} outbox-relay: fixed-delay-ms: ${FIRSTLEG_OUTBOX_RELAY_FIXED_DELAY_MS:5000} - lease-seconds: ${FIRSTLEG_OUTBOX_RELAY_LEASE_SECONDS:60} - retry-seconds: ${FIRSTLEG_OUTBOX_RELAY_RETRY_SECONDS:30} +baiyue.event-delivery.operations-token: ${EVENT_OPERATIONS_TOKEN:} +baiyue.event-delivery.connect-timeout-ms: ${EVENT_CONNECT_TIMEOUT_MS:3000} +baiyue.event-delivery.read-timeout-ms: ${EVENT_READ_TIMEOUT_MS:15000} +baiyue.event-delivery.lease-seconds: ${EVENT_LEASE_SECONDS:60} +baiyue.event-delivery.max-attempts: ${EVENT_MAX_ATTEMPTS:12} +baiyue.event-delivery.base-delay-seconds: ${EVENT_BASE_DELAY_SECONDS:5} +baiyue.event-delivery.max-delay-seconds: ${EVENT_MAX_DELAY_SECONDS:1800} +baiyue.event-delivery.jitter: ${EVENT_JITTER:0.1} diff --git a/config/nacos/cn/order-service.yml b/config/nacos/test/order-service.yml similarity index 100% rename from config/nacos/cn/order-service.yml rename to config/nacos/test/order-service.yml diff --git a/config/nacos/cn/print-service.yml b/config/nacos/test/print-service.yml similarity index 100% rename from config/nacos/cn/print-service.yml rename to config/nacos/test/print-service.yml diff --git a/config/nacos/overseas/warehouse-overseas-service.yml b/config/nacos/test/warehouse-overseas-service.yml similarity index 100% rename from config/nacos/overseas/warehouse-overseas-service.yml rename to config/nacos/test/warehouse-overseas-service.yml diff --git a/config/nacos/cn/warehouse-service.yml b/config/nacos/test/warehouse-service.yml similarity index 100% rename from config/nacos/cn/warehouse-service.yml rename to config/nacos/test/warehouse-service.yml diff --git a/deploy/.env.overseas-auth.example b/deploy/.env.overseas-auth.example deleted file mode 100644 index 325a37c3d..000000000 --- a/deploy/.env.overseas-auth.example +++ /dev/null @@ -1,12 +0,0 @@ -# Auth signs short-lived overseas RS256 tokens with a private JWK Set. -OVERSEAS_JWT_ENABLED=true -OVERSEAS_JWT_ISSUER=auth-service -OVERSEAS_JWT_AUDIENCE=lastleg-api -OVERSEAS_JWT_CURRENT_KID=replace-with-current-signing-kid -OVERSEAS_ACCESS_TOKEN_TTL=15m -OVERSEAS_JWT_RSA_JWK_SET={"keys":[{"kty":"RSA","kid":"replace-with-current-signing-kid","use":"sig","alg":"RS256","n":"replace-with-modulus","e":"AQAB","d":"replace-with-private-exponent","p":"replace","q":"replace","dp":"replace","dq":"replace","qi":"replace"}]} - -# Overseas services validate tokens against Auth's public JWKS endpoint. -JWT_JWK_SET_URI=https://replace-with-auth-host/api/auth/.well-known/jwks.json -JWT_ISSUER=auth-service -JWT_AUDIENCE=lastleg-api diff --git a/deploy/TEST单机部署执行清单.md b/deploy/TEST单机部署执行清单.md new file mode 100644 index 000000000..0f94fd632 --- /dev/null +++ b/deploy/TEST单机部署执行清单.md @@ -0,0 +1,42 @@ +# TEST 单机部署执行清单 + +本清单定义部署顺序与门禁。单 TEST 统一架构现已部署,13 个保留服务与双 PDA H5 已检查健康;真实 HTTP 与目标事件故障恢复通过,页面核心流程及其余验收缺口仍未全部关闭。唯一入口:`docker-compose.yml`、`service-map.yml`、`env/.env.test.example` 与 `../config/nacos/test/`。正式规则见外层 `docs/测试环境单机部署与配置契约.md`、`docs/统一认证与服务通信契约.md`。 + +## 发布前清单 + +1. 记录13个服务镜像 tag/digest、后端和四端版本、SQL基线及14个Nacos配置版本。统一服务集合:Gateway/Auth/IAM/Order/Firstleg/Finance/Warehouse/Warehouse Overseas/Lastleg/File/Print/Audit/Integration。不存在单独海外Gateway或事件桥发布项。 +2. 人工核对实际Nacos Namespace ID、Group、主机容量、文件卷UID 10001读写权限、MySQL各Schema、Redis DB/会话前缀、现有MQ topic/group。环境模板的Schema名只是待核基线,不能据此重命名运行库。 +3. 所有容器共用 firstleg-test-network,并接外部 INFRA_NETWORK(默认 firstleg-test-infra-network)。中间件运行在宿主机时使用 host.docker.internal;运行在容器时用同网络服务名。禁止跨容器 localhost。Nacos应用名不等于Docker服务名,Auth HTTP地址使用 auth:9100。 +4. Gateway仅向宿主机127.0.0.1发布9000(可配置GATEWAY_PUBLISHED_PORT)。同网络Nginx转发 gateway:9000;其余业务仅内部expose。不可把内部API端口直接发布给用户。 +5. Auth私钥JWT_RSA_JWK_SET与JWT_CURRENT_KID由密钥凭据提供;所有服务共用JWT_JWKS_URI、Redis会话和内部服务令牌。EVENT_OPERATIONS_TOKEN与业务令牌分离,仅三家事件生产服务注入;为空禁用运维操作。禁止把真实env提交到仓库。 +6. 本地Node合同、后端编译/测试及前端门禁完成后才能安排部署。Jenkins总控 backend-java,下游 baiyue-firstleg-,构建节点java-builder,唯一部署节点java-test,凭据firstleg-test-env。旧dev/prod Job必须停用或改回其独立受维护版本,不得指向此TEST流水线;不对既有实际生产环境做改名、删除或部署。 + +## 停机切换 + +停止验收与业务写入并记录当前容器、网络、配置、卷、会话和消息对象。逐项取得部署、SQL和删除授权;本清单不授权执行。 + +先关闭旧写入、Relay和消费进程,再应用审核通过的服务SQL基线、统一Nacos和Compose配置。不执行整机清空Redis/MQ/文件,不删除业务Schema。业务MQ继续保留:文件导出、通知、审计启用参数按现有业务核实,test-前缀隔离;可靠审计可显式叠加docker-compose.audit.yml。文件卷持续挂载/data/files。 + +启动原则:中间件健康 → IAM/Auth和公共能力 → 业务消费者/生产者 → Gateway → 四端。Compose不创建循环depends_on;对每个服务以镜像HEALTHCHECK及真实消费者探针确认就绪后才开放写入。所有服务关闭自动SQL初始化,SQL必须单独审核执行。 + +执行入口deploy-built-service.sh仅接受test与13个保留服务,必须在目标服务器本机执行(TARGET_IS_LOCAL=true);缺失关键环境参数、没有HEALTHCHECK、异常退出、健康超时均失败。deploy-services.sh仅顺序调用此入口,任何失败停止后续服务。构建和部署仍由明确授权启动。 + +统一登录、撤销、客户隔离、仓绑定、文件和资源查询通过后,新造装柜→尾程、资金指令→结果回传数据。完成正式契约全链路与故障恢复矩阵前不得宣称环境跑通。 + +旧海外网关/两区桥退出目标清单不代表已停机。只有直接消费、可靠重试/DEAD治理、认证撤销及四端切换验证通过,且确认无旧调用,才能按明确对象清单退役旧容器、注册和专用配置。禁止提前停止现有线上桥。 + +## 失败恢复 + +架构/Schema不匹配时默认停写、保留证据,修复后发布完整匹配新版本。确需恢复旧测试工作时,由人工按记录整体恢复旧镜像、四端、Nacos/Compose和旧Schema基线;不混跑两套协议。 + +单服务自动回滚默认关闭。仅确认前后版本协议和Schema完全一致时,才能在单服务Jenkins勾选ALLOW_SAME_CONTRACT_ROLLBACK;失败只允许恢复同仓库同服务前一镜像,回滚成功仍将Job判为失败。首次部署、旧镜像不属于该服务或旧新镜像相同均拒绝回滚。 + +## 本地验证入口 + +`node --test scripts/java-topology-contract.test.mjs scripts/overseas-deployment-contract.test.mjs scripts/deploy-built-service-rollback.test.mjs scripts/finance-deployment-contract.test.mjs scripts/audit-deployment-contract.test.mjs` + +PowerShell Nacos发布DryRun只列待发布项,不联网;Shell/Groovy离线合同不能替代真实Jenkins语法校验、Docker Compose渲染、镜像启动或HTTP验收。 + +四端协作确认:Admin/Client各自使用firstleg-test-admin-web与firstleg-test-client-web Compose项目,共接external firstleg-test-network;默认API_UPSTREAM=http://gateway:9000,页面仅绑定127.0.0.1:9001/9002。发布必须提供CLIENT_WEB_URL与VITE_ADMIN_WEB_ORIGIN,不能猜测运行域名。两个PDA保留独立产物,共用统一入口。 + +SQL初始化脚本只接受两份显式传入、已审核的空库baseline路径;未设置ALLOW_TEST_SCHEMA_INITIALIZATION=true与明确TEST_MYSQL_CONTAINER时拒绝。它不检查或创建授权,不允许用于已有数据环境,不自动清库,第一份失败立即停止。两份基线的当前维护入口为deploy/sql/README.md;实际SQL执行仍由独立授权覆盖。 diff --git a/deploy/docker-compose.audit.yml b/deploy/docker-compose.audit.yml index b0d29c2e9..87893998e 100644 --- a/deploy/docker-compose.audit.yml +++ b/deploy/docker-compose.audit.yml @@ -4,40 +4,40 @@ services: AUDIT_OUTBOX_ENABLED: "true" AUDIT_OUTBOX_PUBLISHER_ENABLED: "true" ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER:?可靠审计启用时必须配置ROCKETMQ_NAME_SERVER} - ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX-}auth-service-audit-publisher-v1 - MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-} + ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX:-test-}auth-service-audit-publisher-v1 + MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX:-test-} iam: environment: AUDIT_OUTBOX_ENABLED: "true" AUDIT_OUTBOX_PUBLISHER_ENABLED: "true" ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER:?可靠审计启用时必须配置ROCKETMQ_NAME_SERVER} - ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX-}iam-service-audit-publisher-v1 - MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-} + ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX:-test-}iam-service-audit-publisher-v1 + MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX:-test-} order: environment: AUDIT_OUTBOX_ENABLED: "true" AUDIT_OUTBOX_PUBLISHER_ENABLED: "true" ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER:?可靠审计启用时必须配置ROCKETMQ_NAME_SERVER} - ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX-}order-service-audit-publisher-v1 - MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-} + ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX:-test-}order-service-audit-publisher-v1 + MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX:-test-} firstleg: environment: AUDIT_OUTBOX_ENABLED: "true" AUDIT_OUTBOX_PUBLISHER_ENABLED: "true" ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER:?可靠审计启用时必须配置ROCKETMQ_NAME_SERVER} - ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX-}firstleg-service-audit-publisher-v1 - MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-} + ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX:-test-}firstleg-service-audit-publisher-v1 + MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX:-test-} warehouse: environment: AUDIT_OUTBOX_ENABLED: "true" AUDIT_OUTBOX_PUBLISHER_ENABLED: "true" ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER:?可靠审计启用时必须配置ROCKETMQ_NAME_SERVER} - ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX-}warehouse-service-audit-publisher-v1 - MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-} + ROCKETMQ_PRODUCER_GROUP: ${MQ_TOPIC_PREFIX:-test-}warehouse-service-audit-publisher-v1 + MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX:-test-} audit-service: environment: ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER:?可靠审计启用时必须配置ROCKETMQ_NAME_SERVER} - MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-} + MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX:-test-} AUDIT_MQ_CONSUMER_ENABLED: "true" AUDIT_DLQ_CONSUMER_ENABLED: "true" AUDIT_DLQ_COMPENSATION_ENABLED: "true" diff --git a/deploy/docker-compose.overseas.yml b/deploy/docker-compose.overseas.yml deleted file mode 100644 index 3082caf78..000000000 --- a/deploy/docker-compose.overseas.yml +++ /dev/null @@ -1,116 +0,0 @@ -name: firstleg-${DEPLOY_ENV:-test}-overseas - -x-overseas-common: &overseas-common - restart: "on-failure:3" - stop_grace_period: ${STOP_GRACE_PERIOD:-45s} - networks: [firstleg-overseas-network, infra-network] - extra_hosts: - - "host.docker.internal:host-gateway" - environment: &overseas-environment - BAIYUE_DEPLOY_SLOT: ${DEPLOY_SLOT:-rolling} - BAIYUE_RELEASE_ID: ${RELEASE_ID:-local} - BAIYUE_RUNTIME_SCHEDULING_ENABLED: ${BAIYUE_RUNTIME_SCHEDULING_ENABLED:-true} - SERVER_SHUTDOWN: graceful - SPRING_LIFECYCLE_TIMEOUT_PER_SHUTDOWN_PHASE: ${SHUTDOWN_PHASE_TIMEOUT:-30s} - SPRING_PROFILES_ACTIVE: overseas - NACOS_SERVER_ADDR: ${NACOS_SERVER_ADDR} - NACOS_USERNAME: ${OVERSEAS_NACOS_USERNAME} - NACOS_PASSWORD: ${OVERSEAS_NACOS_PASSWORD} - NACOS_GROUP: ${NACOS_GROUP:-BAIYUE} - NACOS_NAMESPACE: ${OVERSEAS_NACOS_NAMESPACE:-prod-overseas} - REDIS_HOST: ${OVERSEAS_REDIS_HOST:-host.docker.internal} - REDIS_PORT: ${OVERSEAS_REDIS_PORT:-6379} - REDIS_PASSWORD: ${OVERSEAS_REDIS_PASSWORD:-} - REDIS_DATABASE: ${OVERSEAS_REDIS_DATABASE:-0} - INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN} - JWT_ISSUER: ${JWT_ISSUER} - JWT_AUDIENCE: ${OVERSEAS_JWT_AUDIENCE:-lastleg-api} - JWT_JWK_SET_URI: ${JWT_JWK_SET_URI} - REGION_CODE: OVERSEAS - DEPLOYMENT_CELL: ${OVERSEAS_DEPLOYMENT_CELL:-test-overseas} - DATA_RESIDENCY: OVERSEAS - SPRING_SQL_INIT_MODE: never - SERVER_MAX_HTTP_REQUEST_HEADER_SIZE: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:-32KB} - TZ: Asia/Shanghai - logging: - # 应用只写 stdout/stderr;json-file 兼容宝塔查看,大小与数量均受控。 - driver: ${DOCKER_LOG_DRIVER:-json-file} - options: - max-size: ${DOCKER_LOG_MAX_SIZE:-50m} - max-file: "${DOCKER_LOG_MAX_FILE:-3}" - -services: - gateway-overseas: - <<: *overseas-common - image: ${IMAGE_REPOSITORY}/gateway-overseas:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-gateway-overseas${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_OVERSEAS_PUBLISHED_PORT:-${GATEWAY_OVERSEAS_PORT:-9300}}:9300"] - environment: - <<: *overseas-environment - SERVER_PORT: 9300 - SWAGGER_ENABLED: ${SWAGGER_ENABLED:-false} - CORS_ALLOWED_ORIGINS: ${OVERSEAS_CORS_ALLOWED_ORIGINS:-} - CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-false} - AUTH_SESSION_VALIDATION_URL: ${AUTH_SESSION_VALIDATION_URL} - PDA_SESSION_VALIDATION_TIMEOUT: ${PDA_SESSION_VALIDATION_TIMEOUT:-2s} - LASTLEG_SECURITY_SERVICE_URL: ${LASTLEG_SECURITY_SERVICE_URL:-http://lastleg-service:9303} - LASTLEG_SECURITY_VALIDATION_TIMEOUT: ${LASTLEG_SECURITY_VALIDATION_TIMEOUT:-2s} - - lastleg-service: - <<: *overseas-common - image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-lastleg${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${LASTLEG_PUBLISHED_PORT:-${LASTLEG_PORT:-9303}}:9303"] - environment: - <<: *overseas-environment - SERVER_PORT: 9303 - LASTLEG_DB_URL: ${LASTLEG_DB_URL} - LASTLEG_DB_USERNAME: ${LASTLEG_DB_USERNAME} - LASTLEG_DB_PASSWORD: ${LASTLEG_DB_PASSWORD} - CN_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_TARGET_URL:-http://event-bridge-cn:9312} - FIRSTLEG_EVENT_BRIDGE_URL: ${CN_EVENT_BRIDGE_TARGET_URL:-http://event-bridge-cn:9312} - DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL} - FILE_SERVICE_URL: ${FILE_SERVICE_URL} - # Print 注册在国内 Nacos namespace;海外服务通过共享 infra-network 的 Docker DNS 直连。 - PRINT_SERVICE_URL: ${PRINT_SERVICE_URL:-http://print:9207} - WAREHOUSE_OVERSEAS_SERVICE_URL: ${WAREHOUSE_OVERSEAS_SERVICE_URL:-http://warehouse-overseas-service:9305} - - warehouse-overseas-service: - <<: *overseas-common - image: ${IMAGE_REPOSITORY}/warehouse-overseas-service:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-warehouse-overseas${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_OVERSEAS_PUBLISHED_PORT:-${WAREHOUSE_OVERSEAS_PORT:-9305}}:9305"] - environment: - <<: *overseas-environment - SERVER_PORT: 9305 - WAREHOUSE_DB_URL: ${WAREHOUSE_OVERSEAS_DB_URL} - WAREHOUSE_DB_USERNAME: ${WAREHOUSE_OVERSEAS_DB_USERNAME} - WAREHOUSE_DB_PASSWORD: ${WAREHOUSE_OVERSEAS_DB_PASSWORD} - DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL} - # 与海外 BOL 共用国内 Print;不能在海外 Nacos namespace 中发现该服务。 - PRINT_SERVICE_URL: ${PRINT_SERVICE_URL:-http://print:9207} - - event-bridge-service: - <<: *overseas-common - image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge-overseas${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_OVERSEAS_PUBLISHED_PORT:-${EVENT_BRIDGE_OVERSEAS_PORT:-9313}}:9312"] - environment: - <<: *overseas-environment - SERVER_PORT: 9312 - EVENT_BRIDGE_DB_URL: ${EVENT_BRIDGE_OVERSEAS_DB_URL} - EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_OVERSEAS_DB_USERNAME} - EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_OVERSEAS_DB_PASSWORD} - EVENT_CONSUMER_URL: ${EVENT_CONSUMER_URL:-http://lastleg-service:9303/internal/lastleg/events} - LASTLEG_SECURITY_EVENT_CONSUMER_URL: ${LASTLEG_SECURITY_EVENT_CONSUMER_URL:-http://lastleg-service:9303/internal/lastleg/security/v1/events} - networks: - firstleg-overseas-network: - infra-network: - aliases: [event-bridge-overseas] - -networks: - firstleg-overseas-network: - name: firstleg-${DEPLOY_ENV:-test}-overseas-network - infra-network: - external: true - name: ${INFRA_NETWORK:-firstleg-test-infra-network} diff --git a/deploy/docker-compose.rocketmq.yml b/deploy/docker-compose.rocketmq.yml index 7775ff3b9..45a047a6e 100644 --- a/deploy/docker-compose.rocketmq.yml +++ b/deploy/docker-compose.rocketmq.yml @@ -1,4 +1,4 @@ -name: firstleg-${DEPLOY_ENV:-test}-rocketmq +name: firstleg-test-rocketmq x-bounded-logging: &bounded-logging driver: ${DOCKER_LOG_DRIVER:-json-file} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index a4c7dbdac..56f4febb3 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -9,30 +9,35 @@ x-common: &common - "host.docker.internal:host-gateway" environment: &common-environment DEPLOY_ENV: ${DEPLOY_ENV:-test} - BAIYUE_DEPLOY_SLOT: ${DEPLOY_SLOT:-rolling} BAIYUE_RELEASE_ID: ${RELEASE_ID:-local} BAIYUE_RUNTIME_SCHEDULING_ENABLED: ${BAIYUE_RUNTIME_SCHEDULING_ENABLED:-true} SERVER_SHUTDOWN: graceful SPRING_LIFECYCLE_TIMEOUT_PER_SHUTDOWN_PHASE: ${SHUTDOWN_PHASE_TIMEOUT:-30s} - SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-cn} + SPRING_PROFILES_ACTIVE: ${DEPLOY_ENV:-test} NACOS_SERVER_ADDR: ${NACOS_SERVER_ADDR} NACOS_USERNAME: ${NACOS_USERNAME} NACOS_PASSWORD: ${NACOS_PASSWORD} NACOS_GROUP: ${NACOS_GROUP:-BAIYUE} - NACOS_NAMESPACE: ${NACOS_NAMESPACE:-public} + NACOS_NAMESPACE: ${NACOS_NAMESPACE:?actual environment namespace ID required} REDIS_HOST: ${REDIS_HOST} REDIS_PORT: ${REDIS_PORT:-6379} REDIS_PASSWORD: ${REDIS_PASSWORD} + REDIS_USERNAME: ${REDIS_USERNAME:-default} + SPRING_DATA_REDIS_USERNAME: ${REDIS_USERNAME:-default} REDIS_DATABASE: ${REDIS_DATABASE:-0} - JWT_SECRET: ${JWT_SECRET} + AUTH_SESSION_VALIDATION_URL: http://auth:9100/internal/auth/accounts/sessions/validate + JWT_ISSUER: ${JWT_ISSUER:-auth-service} + JWT_AUDIENCE: ${JWT_AUDIENCE:-lastleg-api} + JWT_JWKS_URI: http://auth:9100/api/auth/.well-known/jwks.json + REDIS_TOKEN_KEY_PREFIX: ${REDIS_TOKEN_KEY_PREFIX:-baiyue:auth} + SPRING_SQL_INIT_MODE: never + ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER} + MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX:-test-} INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN} - BAIYUE_AUDIT_ENDPOINT: ${BAIYUE_AUDIT_ENDPOINT:-http://audit-service:9700/internal/audit/events} + BAIYUE_AUDIT_ENDPOINT: http://audit-service:9700/internal/audit/events SERVER_MAX_HTTP_REQUEST_HEADER_SIZE: ${SERVER_MAX_HTTP_REQUEST_HEADER_SIZE:-32KB} AUDIT_OUTBOX_ENABLED: ${AUDIT_OUTBOX_ENABLED:-false} AUDIT_OUTBOX_PUBLISHER_ENABLED: ${AUDIT_OUTBOX_PUBLISHER_ENABLED:-false} - REGION_CODE: ${REGION_CODE:-CN} - DEPLOYMENT_CELL: ${DEPLOYMENT_CELL:-test-cn} - DATA_RESIDENCY: ${DATA_RESIDENCY:-CN} TZ: Asia/Shanghai logging: # 应用只写 stdout/stderr;json-file 兼容宝塔查看,大小与数量均受控。 @@ -45,11 +50,12 @@ services: gateway: <<: *common image: ${IMAGE_REPOSITORY}/gateway:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-gateway${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${GATEWAY_PUBLISHED_PORT:-${GATEWAY_PORT:-9000}}:9000"] + container_name: firstleg-${DEPLOY_ENV:-test}-gateway + ports: ["${PUBLISHED_BIND_ADDRESS:-127.0.0.1}:${GATEWAY_PUBLISHED_PORT:-${GATEWAY_PORT:-9000}}:9000"] environment: <<: *common-environment SERVER_PORT: 9000 + SPRING_PROFILES_ACTIVE: ${DEPLOY_ENV:-test},cn SWAGGER_ENABLED: ${SWAGGER_ENABLED:-false} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS} CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-false} @@ -57,46 +63,53 @@ services: auth: <<: *common image: ${IMAGE_REPOSITORY}/auth:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-auth${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${AUTH_PUBLISHED_PORT:-${AUTH_PORT:-9100}}:9100"] + container_name: firstleg-${DEPLOY_ENV:-test}-auth + expose: ["9100"] environment: <<: *common-environment SERVER_PORT: 9100 + JWT_RSA_JWK_SET: ${JWT_RSA_JWK_SET} + JWT_CURRENT_KID: ${JWT_CURRENT_KID} AUTH_DB_URL: ${AUTH_DB_URL} AUTH_DB_USERNAME: ${AUTH_DB_USERNAME} AUTH_DB_PASSWORD: ${AUTH_DB_PASSWORD} - OVERSEAS_JWT_ENABLED: ${OVERSEAS_JWT_ENABLED:-false} - OVERSEAS_JWT_ISSUER: ${OVERSEAS_JWT_ISSUER:-auth-service} - OVERSEAS_JWT_AUDIENCE: ${OVERSEAS_JWT_AUDIENCE:-lastleg-api} - OVERSEAS_JWT_RSA_JWK_SET: ${OVERSEAS_JWT_RSA_JWK_SET:-} - OVERSEAS_JWT_CURRENT_KID: ${OVERSEAS_JWT_CURRENT_KID:-} - OVERSEAS_ACCESS_TOKEN_TTL: ${OVERSEAS_ACCESS_TOKEN_TTL:-15m} iam: <<: *common image: ${IMAGE_REPOSITORY}/iam:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-iam${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${IAM_PUBLISHED_PORT:-${IAM_PORT:-9201}}:9201"] + container_name: firstleg-${DEPLOY_ENV:-test}-iam + expose: ["9201"] environment: <<: *common-environment SERVER_PORT: 9201 + NOTIFICATION_SCHEDULER_ENABLED: ${NOTIFICATION_SCHEDULER_ENABLED:-false} + NOTIFICATION_MQ_CONSUMER_ENABLED: ${NOTIFICATION_MQ_CONSUMER_ENABLED:-false} IAM_DB_URL: ${IAM_DB_URL} IAM_DB_USERNAME: ${IAM_DB_USERNAME} IAM_DB_PASSWORD: ${IAM_DB_PASSWORD} - SECURITY_EVENT_BRIDGE_URL: ${SECURITY_EVENT_BRIDGE_URL:-http://event-bridge-overseas:9312} firstleg: <<: *common image: ${IMAGE_REPOSITORY}/firstleg:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-business${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FIRSTLEG_PUBLISHED_PORT:-${FIRSTLEG_PORT:-9202}}:9202"] + container_name: firstleg-${DEPLOY_ENV:-test}-business + expose: ["9202"] environment: <<: *common-environment SERVER_PORT: 9202 + EVENT_CONNECT_TIMEOUT_MS: ${EVENT_CONNECT_TIMEOUT_MS:-3000} + EVENT_READ_TIMEOUT_MS: ${EVENT_READ_TIMEOUT_MS:-15000} + EVENT_LEASE_SECONDS: ${EVENT_LEASE_SECONDS:-60} + EVENT_MAX_ATTEMPTS: ${EVENT_MAX_ATTEMPTS:-12} + EVENT_BASE_DELAY_SECONDS: ${EVENT_BASE_DELAY_SECONDS:-5} + EVENT_MAX_DELAY_SECONDS: ${EVENT_MAX_DELAY_SECONDS:-1800} + EVENT_JITTER: ${EVENT_JITTER:-0.1} + EVENT_OPERATIONS_TOKEN: ${EVENT_OPERATIONS_TOKEN:-} + LASTLEG_SERVICE_URL: http://lastleg-service:9303 + FIRSTLEG_SERVICE_URL: http://firstleg:9202 + FINANCE_SERVICE_URL: http://finance:9203 FIRSTLEG_DB_URL: ${FIRSTLEG_DB_URL} FIRSTLEG_DB_USERNAME: ${FIRSTLEG_DB_USERNAME} FIRSTLEG_DB_PASSWORD: ${FIRSTLEG_DB_PASSWORD} - LASTLEG_EVENT_BRIDGE_URL: ${LASTLEG_EVENT_BRIDGE_URL} MATSON_TRACKING_ENABLED: ${MATSON_TRACKING_ENABLED:-false} SML_TRACKING_ENABLED: ${SML_TRACKING_ENABLED:-false} ZIM_TRACKING_ENABLED: ${ZIM_TRACKING_ENABLED:-false} @@ -121,21 +134,31 @@ services: finance: <<: *common image: ${IMAGE_REPOSITORY}/finance:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-finance${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FINANCE_PUBLISHED_PORT:-${FINANCE_PORT:-9203}}:9203"] + container_name: firstleg-${DEPLOY_ENV:-test}-finance + expose: ["9203"] environment: <<: *common-environment SERVER_PORT: 9203 + EVENT_CONNECT_TIMEOUT_MS: ${EVENT_CONNECT_TIMEOUT_MS:-3000} + EVENT_READ_TIMEOUT_MS: ${EVENT_READ_TIMEOUT_MS:-15000} + EVENT_LEASE_SECONDS: ${EVENT_LEASE_SECONDS:-60} + EVENT_MAX_ATTEMPTS: ${EVENT_MAX_ATTEMPTS:-12} + EVENT_BASE_DELAY_SECONDS: ${EVENT_BASE_DELAY_SECONDS:-5} + EVENT_MAX_DELAY_SECONDS: ${EVENT_MAX_DELAY_SECONDS:-1800} + EVENT_JITTER: ${EVENT_JITTER:-0.1} + EVENT_OPERATIONS_TOKEN: ${EVENT_OPERATIONS_TOKEN:-} + LASTLEG_SERVICE_URL: http://lastleg-service:9303 + FIRSTLEG_SERVICE_URL: http://firstleg:9202 + FINANCE_SERVICE_URL: http://finance:9203 FINANCE_DB_URL: ${FINANCE_DB_URL} FINANCE_DB_USERNAME: ${FINANCE_DB_USERNAME} FINANCE_DB_PASSWORD: ${FINANCE_DB_PASSWORD} - OVERSEAS_EVENT_BRIDGE_URL: ${OVERSEAS_EVENT_BRIDGE_URL:-http://event-bridge-overseas:9312} order: <<: *common image: ${IMAGE_REPOSITORY}/order:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-order${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${ORDER_PUBLISHED_PORT:-${ORDER_PORT:-9204}}:9204"] + container_name: firstleg-${DEPLOY_ENV:-test}-order + expose: ["9204"] environment: <<: *common-environment SERVER_PORT: 9204 @@ -146,8 +169,8 @@ services: warehouse: <<: *common image: ${IMAGE_REPOSITORY}/warehouse:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-warehouse${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${WAREHOUSE_PUBLISHED_PORT:-${WAREHOUSE_PORT:-9205}}:9205"] + container_name: firstleg-${DEPLOY_ENV:-test}-warehouse + expose: ["9205"] environment: <<: *common-environment SERVER_PORT: 9205 @@ -158,8 +181,8 @@ services: file: <<: *common image: ${IMAGE_REPOSITORY}/file:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-file${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${FILE_PUBLISHED_PORT:-${FILE_PORT:-9206}}:9206"] + container_name: firstleg-${DEPLOY_ENV:-test}-file + expose: ["9206"] volumes: - "${FILE_UPLOADS_HOST_DIR:-/data/firstleg/uploads}:/data/files" environment: @@ -172,8 +195,6 @@ services: PDA_UPDATE_PUBLIC_BASE_URL: ${PDA_UPDATE_PUBLIC_BASE_URL:-} JAVA_TOOL_OPTIONS: "-Xms1g -Xmx2g -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8" # Compose 会插值整份文件,即使只部署 auth 等其他服务;File 的必填校验由 deploy-services.sh 按服务执行。 - ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER-} - MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-} FILE_EXPORT_CONSUMER_ENABLED: ${FILE_EXPORT_CONSUMER_ENABLED:-false} FILE_EXPORT_CONSUMER_CONCURRENCY: ${FILE_EXPORT_CONSUMER_CONCURRENCY:-4} FILE_EXPORT_PUBLISHER_ENABLED: ${FILE_EXPORT_PUBLISHER_ENABLED:-false} @@ -182,8 +203,8 @@ services: print: <<: *common image: ${IMAGE_REPOSITORY}/print:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-print${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${PRINT_PUBLISHED_PORT:-${PRINT_PORT:-9207}}:9207"] + container_name: firstleg-${DEPLOY_ENV:-test}-print + expose: ["9207"] environment: <<: *common-environment SERVER_PORT: 9207 @@ -192,8 +213,8 @@ services: integration-service: <<: *common image: ${IMAGE_REPOSITORY}/integration-service:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-integration${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${INTEGRATION_PUBLISHED_PORT:-${INTEGRATION_PORT:-9210}}:9210"] + container_name: firstleg-${DEPLOY_ENV:-test}-integration + expose: ["9210"] environment: <<: *common-environment SERVER_PORT: 9210 @@ -238,31 +259,10 @@ services: HEDE_TRACKING_REQUEST_TIMEOUT: ${HEDE_TRACKING_REQUEST_TIMEOUT:-30s} HEDE_TRACKING_MIN_REQUEST_INTERVAL: ${HEDE_TRACKING_MIN_REQUEST_INTERVAL:-5s} - event-bridge-service: - <<: *common - image: ${IMAGE_REPOSITORY}/event-bridge-service:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-event-bridge${DEPLOY_SLOT_SUFFIX:-} - ports: ["${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:${EVENT_BRIDGE_PUBLISHED_PORT:-${EVENT_BRIDGE_PORT:-9312}}:9312"] - environment: - <<: *common-environment - SERVER_PORT: 9312 - EVENT_BRIDGE_DB_URL: ${EVENT_BRIDGE_DB_URL} - EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_DB_USERNAME} - EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_DB_PASSWORD} - EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events} - FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http://firstleg:9202/internal/firstleg/v1/container-events} - FINANCE_FEE_EVENT_CONSUMER_URL: ${FINANCE_FEE_EVENT_CONSUMER_URL:-http://finance:9203/internal/finance/v1/business-fee-source-events} - FINANCE_WALLET_EVENT_CONSUMER_URL: ${FINANCE_WALLET_EVENT_CONSUMER_URL:-http://finance:9203/internal/finance/v1/wallet-command-events} - SPRING_SQL_INIT_MODE: never - networks: - firstleg-network: - infra-network: - aliases: [event-bridge-cn] - audit-service: <<: *common image: ${IMAGE_REPOSITORY}/audit-service:${IMAGE_TAG} - container_name: firstleg-${DEPLOY_ENV:-test}-audit${DEPLOY_SLOT_SUFFIX:-} + container_name: firstleg-${DEPLOY_ENV:-test}-audit expose: ["9700"] environment: <<: *common-environment @@ -274,9 +274,50 @@ services: AUDIT_DLQ_CONSUMER_ENABLED: ${AUDIT_DLQ_CONSUMER_ENABLED:-false} AUDIT_DLQ_COMPENSATION_ENABLED: ${AUDIT_DLQ_COMPENSATION_ENABLED:-false} + lastleg-service: + <<: *common + image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG} + container_name: firstleg-${DEPLOY_ENV:-test}-lastleg + expose: ["9303"] + environment: + <<: *common-environment + SERVER_PORT: 9303 + EVENT_CONNECT_TIMEOUT_MS: ${EVENT_CONNECT_TIMEOUT_MS:-3000} + EVENT_READ_TIMEOUT_MS: ${EVENT_READ_TIMEOUT_MS:-15000} + EVENT_LEASE_SECONDS: ${EVENT_LEASE_SECONDS:-60} + EVENT_MAX_ATTEMPTS: ${EVENT_MAX_ATTEMPTS:-12} + EVENT_BASE_DELAY_SECONDS: ${EVENT_BASE_DELAY_SECONDS:-5} + EVENT_MAX_DELAY_SECONDS: ${EVENT_MAX_DELAY_SECONDS:-1800} + EVENT_JITTER: ${EVENT_JITTER:-0.1} + EVENT_OPERATIONS_TOKEN: ${EVENT_OPERATIONS_TOKEN:-} + LASTLEG_SERVICE_URL: http://lastleg-service:9303 + FIRSTLEG_SERVICE_URL: http://firstleg:9202 + FINANCE_SERVICE_URL: http://finance:9203 + LASTLEG_DB_URL: ${LASTLEG_DB_URL} + LASTLEG_DB_USERNAME: ${LASTLEG_DB_USERNAME} + LASTLEG_DB_PASSWORD: ${LASTLEG_DB_PASSWORD} + DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL:-http://warehouse:9205} + FILE_SERVICE_URL: ${FILE_SERVICE_URL:-http://file:9206} + PRINT_SERVICE_URL: ${PRINT_SERVICE_URL:-http://print:9207} + WAREHOUSE_OVERSEAS_SERVICE_URL: ${WAREHOUSE_OVERSEAS_SERVICE_URL:-http://warehouse-overseas-service:9305} + + warehouse-overseas-service: + <<: *common + image: ${IMAGE_REPOSITORY}/warehouse-overseas-service:${IMAGE_TAG} + container_name: firstleg-${DEPLOY_ENV:-test}-warehouse-overseas + expose: ["9305"] + environment: + <<: *common-environment + SERVER_PORT: 9305 + WAREHOUSE_DB_URL: ${WAREHOUSE_OVERSEAS_DB_URL} + WAREHOUSE_DB_USERNAME: ${WAREHOUSE_OVERSEAS_DB_USERNAME} + WAREHOUSE_DB_PASSWORD: ${WAREHOUSE_OVERSEAS_DB_PASSWORD} + DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL} + PRINT_SERVICE_URL: ${PRINT_SERVICE_URL:-http://print:9207} + networks: firstleg-network: name: firstleg-${DEPLOY_ENV:-test}-network infra-network: external: true - name: ${INFRA_NETWORK:-firstleg-test-infra-network} + name: ${INFRA_NETWORK:-firstleg-${DEPLOY_ENV:-test}-infra-network} diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index ad239b1bf..5826218bd 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -1,4 +1,4 @@ -ARG BASE_IMAGE=172.31.198.7:10082/java-project/eclipse-temurin:17-jre-alpine +ARG BASE_IMAGE=eclipse-temurin:17-jre-alpine FROM ${BASE_IMAGE} AS extractor WORKDIR /workspace diff --git a/deploy/env/.env.infra-test.example b/deploy/env/.env.infra-test.example index 583f4d3ec..c80956a07 100644 --- a/deploy/env/.env.infra-test.example +++ b/deploy/env/.env.infra-test.example @@ -7,7 +7,7 @@ MYSQL_PASSWORD=CHANGE_ME_TEST_MYSQL_PASSWORD REDIS_PASSWORD=CHANGE_ME_TEST_REDIS_PASSWORD # RocketMQ 与 File Service 位于同一 Docker 网络,Broker 向客户端公布服务名。 ROCKETMQ_BROKER_IP=rocketmq-broker -# 测试环境必须显式使用 test-;生产环境在对应凭据文件中显式设置为空字符串。 +# 测试业务 MQ topic/group 使用 test- 前缀。 MQ_TOPIC_PREFIX=test- AUDIT_OUTBOX_ENABLED=false AUDIT_OUTBOX_PUBLISHER_ENABLED=false diff --git a/deploy/env/.env.test.example b/deploy/env/.env.test.example new file mode 100644 index 000000000..13dcc01d5 --- /dev/null +++ b/deploy/env/.env.test.example @@ -0,0 +1,54 @@ +# TEST only. Copy outside the repository and supply secrets securely. +NACOS_SERVER_ADDR=host.docker.internal:8848 +NACOS_NAMESPACE= +NACOS_USERNAME= +NACOS_PASSWORD= +REDIS_HOST=host.docker.internal +REDIS_PORT=6379 +REDIS_USERNAME=default +REDIS_DATABASE=0 +REDIS_TOKEN_KEY_PREFIX=baiyue:auth +ROCKETMQ_NAME_SERVER=rocketmq-namesrv:9876 +MQ_TOPIC_PREFIX=test- +JWT_ISSUER=auth-service +JWT_AUDIENCE=lastleg-api +JWT_RSA_JWK_SET= +JWT_CURRENT_KID= +EVENT_OPERATIONS_TOKEN= +INFRA_NETWORK=firstleg-test-infra-network +IMAGE_REPOSITORY=registry.example.com/team/baiyue-firstleg +IMAGE_TAG= +FILE_UPLOADS_HOST_DIR=/data/firstleg/uploads +CORS_ALLOWED_ORIGINS= +INTERNAL_SERVICE_TOKEN= +REDIS_PASSWORD= +AUTH_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_auth?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai +AUTH_DB_USERNAME= +AUTH_DB_PASSWORD= +IAM_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_iam?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai +IAM_DB_USERNAME= +IAM_DB_PASSWORD= +FIRSTLEG_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_firstleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai +FIRSTLEG_DB_USERNAME= +FIRSTLEG_DB_PASSWORD= +FINANCE_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_finance?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai +FINANCE_DB_USERNAME= +FINANCE_DB_PASSWORD= +ORDER_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_order?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai +ORDER_DB_USERNAME= +ORDER_DB_PASSWORD= +WAREHOUSE_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai +WAREHOUSE_DB_USERNAME= +WAREHOUSE_DB_PASSWORD= +FILE_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_file?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai +FILE_DB_USERNAME= +FILE_DB_PASSWORD= +AUDIT_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_audit?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai +AUDIT_DB_USERNAME= +AUDIT_DB_PASSWORD= +LASTLEG_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_lastleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai +LASTLEG_DB_USERNAME= +LASTLEG_DB_PASSWORD= +WAREHOUSE_OVERSEAS_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_lastlag_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai +WAREHOUSE_OVERSEAS_DB_USERNAME= +WAREHOUSE_OVERSEAS_DB_PASSWORD= diff --git a/deploy/scripts/deploy-blue-green-service.sh b/deploy/scripts/deploy-blue-green-service.sh deleted file mode 100644 index ff2fb20ee..000000000 --- a/deploy/scripts/deploy-blue-green-service.sh +++ /dev/null @@ -1,411 +0,0 @@ -#!/usr/bin/env sh -set -eu - -if [ "$#" -ne 7 ]; then - echo "用法: $0 <环境> <镜像仓库> <镜像标签> <服务> <环境文件> <项目名>" >&2 - exit 2 -fi - -DEPLOY_ENV="$1" -IMAGE_REPOSITORY="$2" -IMAGE_TAG="$3" -SERVICE="$4" -ENV_FILE="$5" -COMPOSE_FILE="$6" -BASE_PROJECT="$7" - -case "$DEPLOY_ENV" in - test|production) ;; - *) echo "蓝绿执行器仅用于 test/production,当前: $DEPLOY_ENV" >&2; exit 2 ;; -esac - -for file in "$ENV_FILE" "$COMPOSE_FILE"; do - [ -f "$file" ] || { echo "必需文件不存在: $file" >&2; exit 2; } -done - -case "$SERVICE" in - *[!A-Za-z0-9_-]*|'') echo "非法服务名: $SERVICE" >&2; exit 2 ;; -esac - -env_value() { - sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r' -} - -service_contract() { - case "$SERVICE:$COMPOSE_FILE" in - gateway:*) echo "gateway|9000|9000|gateway|gateway-active" ;; - auth:*) echo "auth|9100|9100|auth-service|auth" ;; - iam:*) echo "iam|9201|9201|iam-service|iam" ;; - firstleg:*) echo "business|9202|9202|firstleg-service|firstleg" ;; - finance:*) echo "finance|9203|9203|finance-service|finance" ;; - order:*) echo "order|9204|9204|order-service|order" ;; - warehouse:*) echo "warehouse|9205|9205|warehouse-service|warehouse" ;; - file:*) echo "file|9206|9206|file-service|file" ;; - print:*) echo "print|9207|9207|print-service|print" ;; - integration-service:*) echo "integration|9210|9210|integration-service|integration-service" ;; - audit-service:*) echo "audit|9700|9700|audit-service|audit-service" ;; - gateway-overseas:*) echo "gateway-overseas|9300|9300|gateway-overseas|gateway-overseas-active" ;; - lastleg-service:*) echo "lastleg|9303|9303|lastleg-service|lastleg-service" ;; - warehouse-overseas-service:*) echo "warehouse-overseas|9305|9305|warehouse-overseas-service|warehouse-overseas-service" ;; - event-bridge-service:*overseas*) echo "event-bridge-overseas|9312|9313|event-bridge-overseas|event-bridge-overseas" ;; - event-bridge-service:*) echo "event-bridge|9312|9312|event-bridge-cn|event-bridge-cn" ;; - *) echo "不支持蓝绿部署的服务: $SERVICE" >&2; exit 2 ;; - esac -} - -IFS='|' read -r CONTAINER_STEM INTERNAL_PORT BASE_PORT NACOS_SERVICE ROUTING_ALIAS <&2; exit 2 ;; esac -done - -mkdir -p "$STATE_ROOT" -chmod 750 "$STATE_ROOT" -docker network inspect "$ROUTING_NETWORK" >/dev/null 2>&1 || docker network create "$ROUTING_NETWORK" >/dev/null - -legacy_container="firstleg-${DEPLOY_ENV}-${CONTAINER_STEM}" -active_slot="" -active_container="" -active_port="" -active_worker="" -if [ -s "$STATE_FILE" ]; then - IFS='|' read -r active_slot active_container active_port active_image active_worker < "$STATE_FILE" -elif docker inspect "$legacy_container" >/dev/null 2>&1; then - active_slot="legacy" - active_container="$legacy_container" - active_port="$BASE_PORT" -fi - -if [ "$active_slot" = "blue" ]; then - target_slot="green" -else - target_slot="blue" - [ "$active_slot" = "legacy" ] && target_slot="green" -fi - -if [ "$target_slot" = "blue" ]; then - target_port=$((BASE_PORT + 20000)) -else - target_port=$((BASE_PORT + 10000)) -fi -candidate="firstleg-${DEPLOY_ENV}-${CONTAINER_STEM}-${target_slot}" -worker_candidate="${candidate}-worker" - -if docker inspect "$candidate" >/dev/null 2>&1; then - if [ "$candidate" = "$active_container" ]; then - echo "活动槽状态异常,候选容器与活动容器相同: $candidate" >&2 - exit 1 - fi - docker rm -f "$candidate" >/dev/null -fi - -compose() { - docker compose --project-name "$BASE_PROJECT" --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@" -} - -container_health() { - container="$1" - docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' \ - "$container" 2>/dev/null || echo missing -} - -cleanup_candidate() { - docker rm -f "$candidate" >/dev/null 2>&1 || true -} - -wait_for_container() { - container="$1" - label="$2" - elapsed=0 - while [ "$elapsed" -lt "$HEALTH_TIMEOUT" ]; do - state="$(container_health "$container")" - echo "$label 健康状态: $state(已等待 ${elapsed}s)" - case "$state" in - healthy) return 0 ;; - unhealthy|exited|dead|missing) return 1 ;; - esac - sleep "$HEALTH_POLL" - elapsed=$((elapsed + HEALTH_POLL)) - done - return 1 -} - -needs_worker() { - case "$SERVICE" in - iam|firstleg|finance|order|event-bridge-service|lastleg-service) return 0 ;; - file) - [ "$(env_value FILE_EXPORT_CONSUMER_ENABLED)" = true ] \ - || [ "$(env_value FILE_EXPORT_PUBLISHER_ENABLED)" = true ] \ - || [ "$(env_value FILE_EXPORT_RECONCILER_ENABLED)" = true ] \ - || [ "$(env_value AUDIT_OUTBOX_PUBLISHER_ENABLED)" = true ] - ;; - audit-service) - [ "$(env_value AUDIT_MQ_CONSUMER_ENABLED)" = true ] \ - || [ "$(env_value AUDIT_DLQ_CONSUMER_ENABLED)" = true ] \ - || [ "$(env_value AUDIT_DLQ_COMPENSATION_ENABLED)" = true ] - ;; - auth|warehouse|warehouse-overseas-service) - [ "$(env_value AUDIT_OUTBOX_PUBLISHER_ENABLED)" = true ] - ;; - *) return 1 ;; - esac -} - -start_worker_candidate() { - docker rm -f "$worker_candidate" >/dev/null 2>&1 || true - echo "启动单例 Worker: $worker_candidate" - if [ "$SERVICE" = file ]; then - worker_java_options="-Xms512m -Xmx2g -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8" - else - worker_java_options="-Xms256m -Xmx1g -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8" - fi - if ! compose run -d --no-deps --name "$worker_candidate" \ - -e "NACOS_REGISTER_ENABLED=false" \ - -e "BAIYUE_RUNTIME_SCHEDULING_ENABLED=true" \ - -e "BAIYUE_DEPLOY_SLOT=${target_slot}-worker" \ - -e "JAVA_TOOL_OPTIONS=$worker_java_options" \ - -e "BAIYUE_RELEASE_ID=$IMAGE_TAG" "$SERVICE" >/dev/null; then - return 1 - fi - docker network connect "$ROUTING_NETWORK" "$worker_candidate" - if ! wait_for_container "$worker_candidate" "候选 Worker"; then - docker logs --tail 200 "$worker_candidate" 2>&1 || true - docker rm -f "$worker_candidate" >/dev/null 2>&1 || true - return 1 - fi - worker_image="$(docker inspect --format '{{.Config.Image}}' "$worker_candidate")" - [ "$worker_image" = "$IMAGE" ] || return 1 -} - -connect_routing_aliases() { - container="$1" - shift - docker network inspect "$ROUTING_NETWORK" --format '{{json .Containers}}' | grep -q "\"$container\"" \ - && return 0 - set -- docker network connect $(for alias_name in "$@"; do printf '%s\n' --alias "$alias_name"; done) - "$@" "$ROUTING_NETWORK" "$container" -} - -bootstrap_legacy_routes() { - for pair in \ - gateway-active,gateway:gateway auth,auth-service:auth iam,iam-service:iam \ - firstleg,firstleg-service:business finance,finance-service:finance order,order-service:order \ - warehouse,warehouse-service:warehouse file,file-service:file print,print-service:print \ - integration-service:integration audit-service:audit \ - gateway-overseas-active,gateway-overseas:gateway-overseas \ - lastleg-service:lastleg warehouse-overseas-service:warehouse-overseas \ - event-bridge-cn:event-bridge event-bridge-overseas:event-bridge-overseas - do - aliases="${pair%%:*}" - stem="${pair#*:}" - container="firstleg-${DEPLOY_ENV}-${stem}" - if docker inspect "$container" >/dev/null 2>&1; then - connect_routing_aliases "$container" $(printf '%s' "$aliases" | tr ',' ' ') || true - fi - done - for container in firstleg-prod-admin-web firstleg-prod-client-web; do - if docker inspect "$container" >/dev/null 2>&1; then - connect_routing_aliases "$container" "${container##*-}" || true - fi - done -} - -switch_gateway_proxy() { - [ "$SERVICE" = "gateway" ] || [ "$SERVICE" = "gateway-overseas" ] || return 0 - if [ "$DEPLOY_ENV" = test ]; then - echo "测试 Gateway 仍由固定入口承载,不在后端蓝绿执行器中切换" >&2 - return 1 - fi - if [ "$SERVICE" = "gateway" ]; then required_alias=gateway-active; else required_alias=gateway-overseas-active; fi - for frontend_service in admin-web client-web; do - frontend_state="$STATE_ROOT/frontend-$frontend_service.active" - if [ ! -s "$frontend_state" ]; then - echo "$frontend_service 尚未完成首次蓝绿迁移,拒绝切换 Gateway" >&2 - return 1 - fi - IFS='|' read -r frontend_slot frontend frontend_port frontend_image < "$frontend_state" - if ! docker inspect "$frontend" >/dev/null 2>&1 \ - || ! docker exec "$frontend" nginx -T 2>&1 | grep -q "$required_alias"; then - echo "$frontend 尚未使用稳定上游 $required_alias,拒绝切换 Gateway" >&2 - return 1 - fi - done - route_root="${BLUE_GREEN_ROUTE_CONFIG_ROOT:-/www/server/panel/vhost/nginx}" - if [ "$SERVICE" = "gateway" ]; then - config="$route_root/00-blue-green-gateway.conf" - else - config="$route_root/00-blue-green-gateway-overseas.conf" - fi - if [ -z "$config" ] || [ ! -f "$config" ]; then - echo "$SERVICE 蓝绿切换要求环境文件配置有效的 Nginx 配置路径" >&2 - return 1 - fi - backup="$config.blue-green-backup" - cp -f "$config" "$backup" - if ! sed -i "s#127\.0\.0\.1:${active_port:-$BASE_PORT}#127.0.0.1:$target_port#g" "$config" \ - || cmp -s "$config" "$backup"; then - cp -f "$backup" "$config" - echo "未能在 Nginx 配置中切换 $SERVICE 上游端口" >&2 - return 1 - fi - if ! nginx -t || ! nginx -s reload; then - cp -f "$backup" "$config" - nginx -t && nginx -s reload || true - return 1 - fi - rm -f "$backup" -} - -reload_frontend_proxies() { - [ "$SERVICE" = "gateway" ] || [ "$SERVICE" = "gateway-overseas" ] || return 0 - [ "$DEPLOY_ENV" = production ] || return 0 - for frontend_service in admin-web client-web; do - frontend_state="$STATE_ROOT/frontend-$frontend_service.active" - [ -s "$frontend_state" ] || return 1 - IFS='|' read -r frontend_slot frontend frontend_port frontend_image < "$frontend_state" - docker inspect "$frontend" >/dev/null 2>&1 || return 1 - docker exec "$frontend" nginx -t - docker exec "$frontend" nginx -s reload - done -} - -rollback_gateway_proxy() { - [ "$SERVICE" = "gateway" ] || [ "$SERVICE" = "gateway-overseas" ] || return 0 - [ "$DEPLOY_ENV" = production ] || return 0 - route_root="${BLUE_GREEN_ROUTE_CONFIG_ROOT:-/www/server/panel/vhost/nginx}" - if [ "$SERVICE" = "gateway" ]; then - config="$route_root/00-blue-green-gateway.conf" - else - config="$route_root/00-blue-green-gateway-overseas.conf" - fi - old_port="${active_port:-$BASE_PORT}" - sed -i "s#127\.0\.0\.1:$target_port#127.0.0.1:$old_port#g" "$config" - nginx -t && nginx -s reload -} - -bootstrap_legacy_routes - -export DEPLOY_ENV IMAGE_REPOSITORY IMAGE_TAG -echo "启动候选槽: service=$SERVICE slot=$target_slot port=$target_port image=$IMAGE" -if ! compose run -d --no-deps --name "$candidate" \ - -e "BAIYUE_DEPLOY_SLOT=$target_slot" -e "BAIYUE_RELEASE_ID=$IMAGE_TAG" \ - -e "BAIYUE_RUNTIME_SCHEDULING_ENABLED=false" \ - -e "AUDIT_OUTBOX_PUBLISHER_ENABLED=false" \ - -e "NOTIFICATION_SCHEDULER_ENABLED=false" \ - -e "NOTIFICATION_MQ_CONSUMER_ENABLED=false" \ - -e "FILE_EXPORT_CONSUMER_ENABLED=false" \ - -e "FILE_EXPORT_PUBLISHER_ENABLED=false" \ - -e "FILE_EXPORT_RECONCILER_ENABLED=false" \ - -e "AUDIT_MQ_CONSUMER_ENABLED=false" \ - -e "AUDIT_DLQ_CONSUMER_ENABLED=false" \ - -e "AUDIT_DLQ_COMPENSATION_ENABLED=false" \ - -p "${PUBLISHED_BIND_ADDRESS:-0.0.0.0}:$target_port:$INTERNAL_PORT" "$SERVICE" >/dev/null; then - cleanup_candidate - exit 1 -fi - -if ! wait_for_container "$candidate" "候选 Web 槽 $target_slot"; then - echo "候选槽未通过健康检查,保留旧实例" >&2 - docker logs --tail 200 "$candidate" 2>&1 || true - cleanup_candidate - exit 1 -fi - -running_image="$(docker inspect --format '{{.Config.Image}}' "$candidate")" -if [ "$running_image" != "$IMAGE" ]; then - echo "候选镜像不一致: expected=$IMAGE actual=$running_image" >&2 - cleanup_candidate - exit 1 -fi - -if [ "$NACOS_SERVICE" = "$ROUTING_ALIAS" ]; then - connect_routing_aliases "$candidate" "$ROUTING_ALIAS" -else - connect_routing_aliases "$candidate" "$ROUTING_ALIAS" "$NACOS_SERVICE" -fi - -if [ "$STABILIZATION_SECONDS" -gt 0 ]; then - echo "候选槽稳定观察 ${STABILIZATION_SECONDS}s" - sleep "$STABILIZATION_SECONDS" - [ "$(container_health "$candidate")" = "healthy" ] || { cleanup_candidate; exit 1; } -fi - -if ! switch_gateway_proxy; then - cleanup_candidate - exit 1 -fi - -worker_required=false -if needs_worker; then worker_required=true; fi - -if [ "$worker_required" = true ] && [ "$active_slot" != legacy ]; then - if [ -n "$active_worker" ] && docker inspect "$active_worker" >/dev/null 2>&1; then - echo "暂停旧 Worker,避免新旧任务并行: $active_worker" - docker stop --time "$STOP_TIMEOUT" "$active_worker" >/dev/null - fi - if ! start_worker_candidate; then - [ -z "$active_worker" ] || docker start "$active_worker" >/dev/null 2>&1 || true - rollback_gateway_proxy || true - cleanup_candidate - exit 1 - fi -fi - -old_container_stopped=false -if [ -n "$active_container" ] && docker inspect "$active_container" >/dev/null 2>&1; then - if [ "$DRAIN_SECONDS" -gt 0 ]; then - echo "蓝绿切换后连接排空 ${DRAIN_SECONDS}s,旧 Web 实例保持运行: $active_container" - sleep "$DRAIN_SECONDS" - fi - echo "优雅停止旧 Web 实例: $active_container" - docker stop --time "$STOP_TIMEOUT" "$active_container" >/dev/null - old_container_stopped=true -fi - -if [ "$worker_required" = true ] && [ "$active_slot" = legacy ]; then - if ! start_worker_candidate; then - [ "$old_container_stopped" = false ] || docker start "$active_container" >/dev/null 2>&1 || true - rollback_gateway_proxy || true - cleanup_candidate - exit 1 - fi -fi - -if ! reload_frontend_proxies; then - [ "$old_container_stopped" = false ] || docker start "$active_container" >/dev/null 2>&1 || true - if [ "$worker_required" = true ]; then - docker rm -f "$worker_candidate" >/dev/null 2>&1 || true - [ -z "$active_worker" ] || docker start "$active_worker" >/dev/null 2>&1 || true - fi - rollback_gateway_proxy || true - cleanup_candidate - reload_frontend_proxies || true - exit 1 -fi - -[ -z "$active_worker" ] || [ "$active_worker" = "$worker_candidate" ] \ - || docker rm "$active_worker" >/dev/null 2>&1 || true -[ "$old_container_stopped" = false ] || docker rm "$active_container" >/dev/null - -if [ "$worker_required" = true ]; then - next_worker="$worker_candidate" -else - next_worker="" -fi -printf '%s|%s|%s|%s|%s\n' "$target_slot" "$candidate" "$target_port" "$IMAGE" "$next_worker" > "$STATE_FILE.tmp" -chmod 640 "$STATE_FILE.tmp" -mv -f "$STATE_FILE.tmp" "$STATE_FILE" -echo "蓝绿切换完成: service=$SERVICE active=$target_slot container=$candidate port=$target_port" diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index a658d1e77..995da8f58 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -28,177 +28,50 @@ for value_name in HEALTH_TIMEOUT_SECONDS HEALTH_POLL_SECONDS; do esac done -case "$DEPLOY_ENV" in - development|test|production) ;; - *) - echo "仅支持 development/test/production,当前: $DEPLOY_ENV" >&2 - exit 2 - ;; -esac - -if [ "$DEPLOY_ENV" = "development" ]; then - PUBLISHED_BIND_ADDRESS=127.0.0.1 -else - PUBLISHED_BIND_ADDRESS="${PUBLISHED_BIND_ADDRESS:-0.0.0.0}" -fi -export PUBLISHED_BIND_ADDRESS +case "$DEPLOY_ENV" in test|production) ;; *) echo "Only test and production are supported" >&2; exit 2 ;; esac case "$SERVICE" in - *[!A-Za-z0-9_-]*|'') - echo "非法服务名: $SERVICE" >&2 - exit 2 - ;; + gateway|auth|iam|firstleg|finance|order|warehouse|file|print|integration-service|lastleg-service|warehouse-overseas-service|audit-service) ;; + *) echo "Unknown or retired service: $SERVICE" >&2; exit 2 ;; esac - -if [ ! -f "$ENV_FILE" ]; then - echo "必需文件不存在: $ENV_FILE" >&2 - exit 2 +case "$IMAGE_REPOSITORY" in ''|*[!a-zA-Z0-9._:/-]*) echo "Invalid image repository" >&2; exit 2 ;; esac +case "$IMAGE_TAG" in ''|*[!a-zA-Z0-9_.-]*) echo "Invalid image tag" >&2; exit 2 ;; esac +case "${ALLOW_SAME_CONTRACT_ROLLBACK:-false}" in true|false) ;; *) exit 2 ;; esac +if [ "$DEPLOY_ENV" = "test" ] && [ "${TARGET_IS_LOCAL:-false}" != "true" ]; then + echo "TEST deploy must run on the target host" >&2; exit 2 fi - -env_file_has_key() { - grep -q "^$1=" "$ENV_FILE" -} - -env_file_value() { - sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r' -} - -case "$SERVICE" in - gateway-overseas|lastleg-service|warehouse-overseas-service) - COMPOSE_FILE="deploy/docker-compose.overseas.yml" - PROJECT_NAME="firstleg-$DEPLOY_ENV-overseas" - ;; - event-bridge-service) - BRIDGE_REGION="${EVENT_BRIDGE_REGION_OVERRIDE:-}" - if [ -z "$BRIDGE_REGION" ] && env_file_has_key REGION_CODE; then - BRIDGE_REGION="$(env_file_value REGION_CODE)" - fi - if [ -z "$BRIDGE_REGION" ]; then - echo "部署 event-bridge-service 时,环境文件必须显式包含 REGION_CODE=CN 或 REGION_CODE=OVERSEAS" >&2 - exit 2 - fi - case "$BRIDGE_REGION" in - CN) ;; - OVERSEAS) - COMPOSE_FILE="deploy/docker-compose.overseas.yml" - PROJECT_NAME="firstleg-$DEPLOY_ENV-overseas" - ;; - *) - echo "部署 event-bridge-service 时,REGION_CODE 只能为 CN 或 OVERSEAS" >&2 - exit 2 - ;; - esac - ;; -esac - -if [ "$SERVICE" = "gateway-overseas" ]; then - if ! env_file_has_key AUTH_SESSION_VALIDATION_URL || [ -z "$(env_file_value AUTH_SESSION_VALIDATION_URL)" ]; then - echo "部署 gateway-overseas 时,环境文件必须包含非空 AUTH_SESSION_VALIDATION_URL" >&2 - exit 2 - fi -fi - -for file in "$ENV_FILE" "$COMPOSE_FILE"; do - if [ ! -f "$file" ]; then - echo "必需文件不存在: $file" >&2 - exit 2 - fi +[ -f "$ENV_FILE" ] || { echo "Environment file missing" >&2; exit 2; } +env_file_value() { sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r'; } +for key in NACOS_SERVER_ADDR NACOS_NAMESPACE NACOS_USERNAME NACOS_PASSWORD REDIS_HOST REDIS_PORT REDIS_DATABASE REDIS_TOKEN_KEY_PREFIX INTERNAL_SERVICE_TOKEN JWT_RSA_JWK_SET JWT_CURRENT_KID ROCKETMQ_NAME_SERVER; do + [ -n "$(env_file_value "$key")" ] || { echo "Missing $key" >&2; exit 2; } done - -if [ "$SERVICE" = "finance" ]; then - for key in FINANCE_DB_URL FINANCE_DB_USERNAME FINANCE_DB_PASSWORD; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署 finance 服务时,环境文件必须包含非空 $key" >&2 - exit 2 - fi +[ "$(env_file_value MQ_TOPIC_PREFIX)" = "$DEPLOY_ENV-" ] || { echo "MQ_TOPIC_PREFIX must be $DEPLOY_ENV-" >&2; exit 2; } +case "$SERVICE" in + gateway|print|integration-service) db_prefix='' ;; + lastleg-service) db_prefix=LASTLEG ;; + warehouse-overseas-service) db_prefix=WAREHOUSE_OVERSEAS ;; + audit-service) db_prefix=AUDIT ;; + *) db_prefix=$(printf '%s' "$SERVICE" | tr '[:lower:]' '[:upper:]') ;; +esac +if [ -n "$db_prefix" ]; then + for suffix in URL USERNAME PASSWORD; do + key="${db_prefix}_DB_${suffix}" + [ -n "$(env_file_value "$key")" ] || { echo "Missing $key" >&2; exit 2; } done fi - -if [ "$PROJECT_NAME" = "firstleg-$DEPLOY_ENV-overseas" ]; then - for key in NACOS_SERVER_ADDR OVERSEAS_NACOS_USERNAME OVERSEAS_NACOS_PASSWORD OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN JWT_ISSUER OVERSEAS_JWT_AUDIENCE JWT_JWK_SET_URI; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署海外服务时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done -fi - -if [ "$SERVICE" = "firstleg" ]; then - if ! env_file_has_key LASTLEG_EVENT_BRIDGE_URL || [ -z "$(env_file_value LASTLEG_EVENT_BRIDGE_URL)" ]; then - echo "部署 firstleg 时,环境文件必须包含非空 LASTLEG_EVENT_BRIDGE_URL" >&2 - exit 2 - fi - if [ "$(env_file_value TERMINAL_DRAYAGE_MAIL_ENABLED)" = "true" ]; then - for key in TERMINAL_DRAYAGE_MAIL_HOST TERMINAL_DRAYAGE_MAIL_PORT TERMINAL_DRAYAGE_MAIL_USERNAME TERMINAL_DRAYAGE_MAIL_PASSWORD; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "启用码头车队邮件时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done - fi -fi - -if [ "$SERVICE" = "lastleg-service" ]; then - for key in LASTLEG_DB_URL LASTLEG_DB_USERNAME LASTLEG_DB_PASSWORD FIRSTLEG_EVENT_BRIDGE_URL DOMESTIC_WAREHOUSE_URL FILE_SERVICE_URL; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署 lastleg-service 时,环境文件必须包含非空 $key" >&2 - exit 2 - fi +if [ "$SERVICE" = "firstleg" ] && [ "$(env_file_value TERMINAL_DRAYAGE_MAIL_ENABLED)" = "true" ]; then + for key in TERMINAL_DRAYAGE_MAIL_HOST TERMINAL_DRAYAGE_MAIL_PORT TERMINAL_DRAYAGE_MAIL_USERNAME TERMINAL_DRAYAGE_MAIL_PASSWORD; do + [ -n "$(env_file_value "$key")" ] || { echo "Missing $key" >&2; exit 2; } done fi - -if [ "$SERVICE" = "warehouse-overseas-service" ]; then - for key in WAREHOUSE_OVERSEAS_DB_URL WAREHOUSE_OVERSEAS_DB_USERNAME WAREHOUSE_OVERSEAS_DB_PASSWORD; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署 warehouse-overseas-service 时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done -fi - -if [ "$SERVICE" = "event-bridge-service" ]; then - if [ "$PROJECT_NAME" = "firstleg-$DEPLOY_ENV-overseas" ]; then - EVENT_BRIDGE_KEYS="EVENT_BRIDGE_OVERSEAS_DB_URL EVENT_BRIDGE_OVERSEAS_DB_USERNAME EVENT_BRIDGE_OVERSEAS_DB_PASSWORD EVENT_CONSUMER_URL" - else - EVENT_BRIDGE_KEYS="EVENT_BRIDGE_DB_URL EVENT_BRIDGE_DB_USERNAME EVENT_BRIDGE_DB_PASSWORD FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL" - fi - for key in $EVENT_BRIDGE_KEYS; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署 event-bridge-service 时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done -fi - -if [ "$SERVICE" = "file" ]; then - if ! env_file_has_key ROCKETMQ_NAME_SERVER || [ -z "$(env_file_value ROCKETMQ_NAME_SERVER)" ]; then - echo "部署 file 服务时,环境文件必须包含非空 ROCKETMQ_NAME_SERVER" >&2 - exit 2 - fi - if ! env_file_has_key MQ_TOPIC_PREFIX; then - echo "部署 file 服务时,环境文件必须显式包含 MQ_TOPIC_PREFIX" >&2 - exit 2 - fi - MQ_PREFIX=$(env_file_value MQ_TOPIC_PREFIX) - if [ "$DEPLOY_ENV" = "test" ] && [ "$MQ_PREFIX" != "test-" ]; then - echo "测试环境部署 file 服务时 MQ_TOPIC_PREFIX 必须为 test-" >&2 - exit 2 - fi - if [ "$DEPLOY_ENV" = "development" ] && { [ -z "$MQ_PREFIX" ] || [ "$MQ_PREFIX" = "test-" ]; }; then - echo "开发环境部署 file 服务时 MQ_TOPIC_PREFIX 必须非空且不能使用 test-" >&2 - exit 2 - fi - if [ "$DEPLOY_ENV" = "production" ] && [ -n "$MQ_PREFIX" ]; then - echo "生产环境部署 file 服务时 MQ_TOPIC_PREFIX 必须为空,使用正式 Topic" >&2 - exit 2 - fi -fi - +# Architecture/schema changes must use whole-release recovery, never an implicit old image. +export PUBLISHED_BIND_ADDRESS=127.0.0.1 : "${REGISTRY_USER:?REGISTRY_USER 未设置}" : "${REGISTRY_PASSWORD:?REGISTRY_PASSWORD 未设置}" local_registry_logout() { docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true + if [ -n "${deploy_docker_config:-}" ]; then rm -rf -- "$deploy_docker_config"; fi } local_compose() { @@ -255,8 +128,8 @@ wait_for_local_health() { return 0 ;; no-health:running) - echo "容器未配置 HEALTHCHECK,以运行状态作为成功依据" >&2 - return 0 + echo "Missing HEALTHCHECK; deployment failed" >&2 + return 1 ;; unhealthy|no-health:exited|no-health:dead) return 1 @@ -270,6 +143,11 @@ wait_for_local_health() { } deploy_local() { + export DEPLOY_ENV IMAGE_REPOSITORY IMAGE_TAG + deploy_docker_config=$(mktemp -d) + DOCKER_CONFIG="$deploy_docker_config" + export DOCKER_CONFIG + trap local_registry_logout EXIT HUP INT TERM printf '%s' "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST" trap local_registry_logout EXIT HUP INT TERM previous_image="$(local_container_image || true)" @@ -306,6 +184,11 @@ deploy_local() { else echo "新镜像容器不存在,无法读取启动日志" >&2 fi + if [ "${ALLOW_SAME_CONTRACT_ROLLBACK:-false}" != "true" ]; then + echo "Automatic rollback disabled: keep writes closed and recover the matched release" >&2 + local_compose stop "$SERVICE" >/dev/null 2>&1 || true + return 1 + fi expected_prefix="$IMAGE_REPOSITORY/$SERVICE:" case "$previous_image" in "$expected_prefix"*) @@ -346,241 +229,37 @@ deploy_local() { return 1 } -if [ "${TARGET_IS_LOCAL:-false}" = "true" ]; then - echo "镜像已在目标服务器本机,直接部署: $IMAGE" - if [ "$DEPLOY_ENV" = "production" ]; then - printf '%s' "$REGISTRY_PASSWORD" | docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST" - trap local_registry_logout EXIT HUP INT TERM - docker pull "$IMAGE" - sh deploy/scripts/deploy-blue-green-service.sh \ - "$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE" "$ENV_FILE" "$COMPOSE_FILE" "$PROJECT_NAME" - local_registry_logout - trap - EXIT HUP INT TERM - exit 0 - fi - deploy_local - exit 0 -fi - -SSH_TARGET="${TARGET_SSH_TARGET:?TARGET_SSH_TARGET 未设置}" -SSH_KEY="${TARGET_SSH_KEY:?TARGET_SSH_KEY 未设置}" -SSH_KNOWN_HOSTS="${TARGET_SSH_KNOWN_HOSTS:?TARGET_SSH_KNOWN_HOSTS 未设置}" -REMOTE_DIR="${TARGET_DEPLOY_DIR:?TARGET_DEPLOY_DIR 未设置}" - -for file in "$SSH_KEY" "$SSH_KNOWN_HOSTS"; do - if [ ! -f "$file" ]; then - echo "SSH 文件不存在: $file" >&2 - exit 2 - fi -done - -run_ssh() { - ssh -i "$SSH_KEY" \ - -o BatchMode=yes \ - -o Compression=no \ - -o StrictHostKeyChecking=yes \ - -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" \ - -o ConnectTimeout=10 \ - "$SSH_TARGET" "$@" -} - -run_scp() { - scp -i "$SSH_KEY" \ - -o BatchMode=yes \ - -o Compression=no \ - -o StrictHostKeyChecking=yes \ - -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" \ - -o ConnectTimeout=10 \ - "$@" -} - -run_ssh install -d -m 750 "$REMOTE_DIR" - -SUFFIX="${IMAGE_TAG}.$$.${SERVICE}" -REMOTE_ENV_NAME=".env.$PROJECT_NAME" -REMOTE_COMPOSE_NAME="docker-compose.$PROJECT_NAME.yml" -REMOTE_BLUE_GREEN_NAME="deploy-blue-green-service.sh" -REMOTE_ENV_TMP="$REMOTE_DIR/$REMOTE_ENV_NAME.$SUFFIX.tmp" -REMOTE_COMPOSE_TMP="$REMOTE_DIR/$REMOTE_COMPOSE_NAME.$SUFFIX.tmp" -REMOTE_BLUE_GREEN_TMP="$REMOTE_DIR/$REMOTE_BLUE_GREEN_NAME.$SUFFIX.tmp" -run_scp "$ENV_FILE" "$SSH_TARGET:$REMOTE_ENV_TMP" -run_scp "$COMPOSE_FILE" "$SSH_TARGET:$REMOTE_COMPOSE_TMP" -run_scp "deploy/scripts/deploy-blue-green-service.sh" "$SSH_TARGET:$REMOTE_BLUE_GREEN_TMP" -run_ssh sh -s -- "$REMOTE_ENV_TMP" "$REMOTE_COMPOSE_TMP" "$REMOTE_BLUE_GREEN_TMP" \ - "$REMOTE_DIR/$REMOTE_ENV_NAME" "$REMOTE_DIR/$REMOTE_COMPOSE_NAME" "$REMOTE_DIR/$REMOTE_BLUE_GREEN_NAME" <<'REMOTE_PREPARE' -set -eu -env_tmp="$1" -compose_tmp="$2" -blue_green_tmp="$3" -env_target="$4" -compose_target="$5" -blue_green_target="$6" -chmod 600 "$env_tmp" -chmod 644 "$compose_tmp" -chmod 750 "$blue_green_tmp" -mv -f "$env_tmp" "$env_target" -mv -f "$compose_tmp" "$compose_target" -mv -f "$blue_green_tmp" "$blue_green_target" -REMOTE_PREPARE - -remote_registry_logout() { - run_ssh docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true -} - -echo "目标服务器从共享仓库拉取增量镜像: $IMAGE" -printf '%s' "$REGISTRY_PASSWORD" | run_ssh docker login -u "$REGISTRY_USER" --password-stdin "$REGISTRY_HOST" -trap remote_registry_logout EXIT HUP INT TERM -run_ssh docker pull "$IMAGE" - -if [ "$DEPLOY_ENV" = "production" ]; then - echo "$DEPLOY_ENV 服务使用蓝绿执行器,旧实例在候选槽健康前保持运行" - run_ssh sh "$REMOTE_DIR/$REMOTE_BLUE_GREEN_NAME" \ - "$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE" \ - "$REMOTE_DIR/$REMOTE_ENV_NAME" "$REMOTE_DIR/$REMOTE_COMPOSE_NAME" "$PROJECT_NAME" - remote_registry_logout +deploy_remote() { + : "${TARGET_SSH_TARGET:?TARGET_SSH_TARGET required}" + : "${TARGET_SSH_KEY:?TARGET_SSH_KEY required}" + : "${TARGET_SSH_KNOWN_HOSTS:?TARGET_SSH_KNOWN_HOSTS required}" + [ -f "$TARGET_SSH_KEY" ] && [ -f "$TARGET_SSH_KNOWN_HOSTS" ] || exit 2 + run_ssh() { + ssh -i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=10 "$TARGET_SSH_TARGET" "$@" + } + run_scp() { + scp -i "$TARGET_SSH_KEY" -o BatchMode=yes -o StrictHostKeyChecking=yes \ + -o UserKnownHostsFile="$TARGET_SSH_KNOWN_HOSTS" -o ConnectTimeout=10 "$@" + } + remote_dir=$(run_ssh 'umask 077; mktemp -d /tmp/firstleg-production-release.XXXXXXXXXX') + case "$remote_dir" in /tmp/firstleg-production-release.*) ;; *) echo "Invalid remote temporary directory" >&2; exit 2 ;; esac + case "$remote_dir" in *[!a-zA-Z0-9/._-]*) exit 2 ;; esac + cleanup_remote() { run_ssh "rm -rf -- '$remote_dir'" >/dev/null 2>&1; } + trap cleanup_remote EXIT HUP INT TERM + run_ssh "mkdir -p '$remote_dir/deploy/scripts'" + run_scp "$ENV_FILE" "$TARGET_SSH_TARGET:$remote_dir/deployment.env" + run_scp "$COMPOSE_FILE" "$TARGET_SSH_TARGET:$remote_dir/deploy/docker-compose.yml" + run_scp deploy/scripts/deploy-built-service.sh "$TARGET_SSH_TARGET:$remote_dir/deploy/scripts/deploy-built-service.sh" + # Registry credentials only travel over stdin, never in process arguments or files. + { printf '%s\n' "$REGISTRY_USER"; printf '%s' "$REGISTRY_PASSWORD"; } | run_ssh \ + "cd '$remote_dir' && IFS= read -r REGISTRY_USER && REGISTRY_PASSWORD=\$(cat) && export REGISTRY_USER REGISTRY_PASSWORD TARGET_IS_LOCAL=true ALLOW_SAME_CONTRACT_ROLLBACK='${ALLOW_SAME_CONTRACT_ROLLBACK:-false}' DEPLOY_HEALTH_TIMEOUT_SECONDS='$HEALTH_TIMEOUT_SECONDS' DEPLOY_HEALTH_POLL_SECONDS='$HEALTH_POLL_SECONDS' && sh deploy/scripts/deploy-built-service.sh production '$IMAGE_REPOSITORY' '$IMAGE_TAG' '$SERVICE' deployment.env" + cleanup_remote trap - EXIT HUP INT TERM - exit 0 -fi +} -run_ssh sh -s -- "$REMOTE_DIR" "$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE" "$PROJECT_NAME" "$REMOTE_ENV_NAME" "$REMOTE_COMPOSE_NAME" "$HEALTH_TIMEOUT_SECONDS" "$HEALTH_POLL_SECONDS" <<'REMOTE_DEPLOY' -set -eu -remote_dir="$1" -deploy_env="$2" -image_repository="$3" -image_tag="$4" -service="$5" -project_name="$6" -env_file="$7" -compose_file="$8" -health_timeout_seconds="$9" -health_poll_seconds="${10}" -cd "$remote_dir" -export DEPLOY_ENV="$deploy_env" -export IMAGE_REPOSITORY="$image_repository" -export IMAGE_TAG="$image_tag" -if [ "$deploy_env" = "development" ]; then - export PUBLISHED_BIND_ADDRESS=127.0.0.1 +if [ "${TARGET_IS_LOCAL:-false}" = "true" ]; then + deploy_local else - export PUBLISHED_BIND_ADDRESS="${PUBLISHED_BIND_ADDRESS:-0.0.0.0}" + deploy_remote fi - -compose() { - docker compose \ - --project-name "$project_name" \ - --env-file "$env_file" \ - -f "$compose_file" \ - "$@" -} - -container_id() { - compose ps --all --quiet "$service" | head -n 1 -} - -container_image() { - id="$(container_id)" - if [ -n "$id" ]; then - docker inspect --format '{{.Config.Image}}' "$id" - fi -} - -health_state() { - id="$(container_id)" - if [ -z "$id" ]; then - echo missing - return - fi - docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}no-health:{{.State.Status}}{{end}}' "$id" 2>/dev/null || echo missing -} - -wait_for_health() { - elapsed=0 - while [ "$elapsed" -lt "$health_timeout_seconds" ]; do - state="$(health_state)" - echo "容器健康状态: $state(已等待 ${elapsed}s)" - case "$state" in - healthy) return 0 ;; - no-health:running) - echo "容器未配置 HEALTHCHECK,以运行状态作为成功依据" >&2 - return 0 - ;; - unhealthy|no-health:exited|no-health:dead) return 1 ;; - esac - sleep "$health_poll_seconds" - elapsed=$((elapsed + health_poll_seconds)) - done - echo "等待容器健康超时: ${health_timeout_seconds}s" >&2 - return 1 -} - -new_image="$image_repository/$service:$image_tag" -previous_image="$(container_image || true)" -if [ -n "$previous_image" ]; then - echo "部署前运行镜像: $previous_image" -else - echo "首次部署,未发现可回滚的旧镜像" -fi - -new_started=true -if ! compose up -d --no-deps --pull never "$service"; then - echo "新镜像启动命令失败: $new_image" >&2 - new_started=false -fi -if [ "$new_started" = "true" ] && wait_for_health; then - running_image="$(container_image || true)" - if [ "$running_image" != "$new_image" ]; then - echo "运行容器镜像与请求镜像不一致: expected=$new_image actual=${running_image:-missing}" >&2 - exit 1 - fi - echo "新镜像健康检查通过: $new_image" - compose ps "$service" - exit 0 -fi - -echo "新镜像健康检查失败: $new_image" >&2 -failed_container_id="$(container_id || true)" -if [ -n "$failed_container_id" ]; then - echo "新镜像容器最近日志(最多 200 行):" >&2 - docker logs --tail 200 "$failed_container_id" 2>&1 || true -else - echo "新镜像容器不存在,无法读取启动日志" >&2 -fi -expected_prefix="$image_repository/$service:" -case "$previous_image" in - "$expected_prefix"*) previous_tag="${previous_image#"$expected_prefix"}" ;; - '') - echo "首次部署没有可回滚镜像,部署失败" >&2 - compose stop "$service" >/dev/null 2>&1 || true - exit 1 - ;; - *) - echo "旧镜像不属于当前服务,拒绝自动回滚: $previous_image" >&2 - exit 1 - ;; -esac -if [ "$previous_image" = "$new_image" ]; then - echo "旧镜像与新镜像相同,无法自动回滚: $new_image" >&2 - exit 1 -fi - -echo "开始自动回滚到: $previous_image" >&2 -if ! docker pull "$previous_image"; then - echo "自动回滚失败,无法拉取旧镜像: $previous_image" >&2 - exit 1 -fi -IMAGE_TAG="$previous_tag" -export IMAGE_TAG -if ! compose up -d --no-deps --pull never "$service"; then - echo "自动回滚失败,旧镜像启动命令失败: $previous_image" >&2 - exit 1 -fi -if wait_for_health; then - echo "自动回滚成功: $previous_image" >&2 - compose ps "$service" || true -else - echo "自动回滚失败,旧镜像未恢复健康: $previous_image" >&2 -fi -exit 1 -REMOTE_DEPLOY - -remote_registry_logout -trap - EXIT HUP INT TERM diff --git a/deploy/scripts/deploy-development-service.sh b/deploy/scripts/deploy-development-service.sh deleted file mode 100644 index 23343f7a4..000000000 --- a/deploy/scripts/deploy-development-service.sh +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env sh -set -eu - -if [ "$#" -ne 4 ]; then - echo "用法: $0 <镜像仓库> <镜像标签> <逗号分隔服务> <环境文件>" >&2 - exit 2 -fi - -IMAGE_REPOSITORY="$1" -IMAGE_TAG="$2" -SERVICE_CSV="$3" -ENV_FILE="$4" - -SSH_TARGET="${DEVELOPMENT_SSH_TARGET:-deploy@172.31.198.8}" -SSH_KEY="${DEVELOPMENT_SSH_KEY:-/var/jenkins_home/.ssh/dev_deploy}" -SSH_KNOWN_HOSTS="${DEVELOPMENT_SSH_KNOWN_HOSTS:-/var/jenkins_home/.ssh/known_hosts}" -REMOTE_DIR="${DEVELOPMENT_DEPLOY_DIR:-/opt/firstleg/application}" -COMPOSE_FILE="${DEPLOY_COMPOSE_FILE:-deploy/docker-compose.yml}" -PROJECT_NAME="${DEPLOY_PROJECT_NAME:-firstleg-development}" - -if [ ! -f "$SSH_KEY" ]; then - echo "开发环境 SSH 私钥不存在: $SSH_KEY" >&2 - exit 2 -fi -if [ ! -f "$SSH_KNOWN_HOSTS" ]; then - echo "SSH 主机指纹文件不存在: $SSH_KNOWN_HOSTS" >&2 - exit 2 -fi -if [ ! -f "$ENV_FILE" ]; then - echo "开发环境变量文件不存在: $ENV_FILE" >&2 - exit 2 -fi -if [ ! -f "$COMPOSE_FILE" ]; then - echo "Compose 文件不存在: $COMPOSE_FILE" >&2 - exit 2 -fi - -SERVICES=$(printf '%s' "$SERVICE_CSV" | tr ',' ' ') -for service in $SERVICES; do - case "$service" in - *[!A-Za-z0-9_-]*|'') - echo "非法服务名: $service" >&2 - exit 2 - ;; - esac -done - -if [ "${DEVELOPMENT_DEPLOY_LOCAL:-false}" = "true" ]; then - echo "开发镜像已在开发节点本机构建,直接部署: $SERVICE_CSV" - install -d -m 750 "$REMOTE_DIR" - - SUFFIX="${IMAGE_TAG}.$$.${SERVICE_CSV}" - LOCAL_ENV_TMP="$REMOTE_DIR/.env.$SUFFIX.tmp" - LOCAL_COMPOSE_TMP="$REMOTE_DIR/docker-compose.yml.$SUFFIX.tmp" - install -m 600 "$ENV_FILE" "$LOCAL_ENV_TMP" - install -m 644 "$COMPOSE_FILE" "$LOCAL_COMPOSE_TMP" - mv -f "$LOCAL_ENV_TMP" "$REMOTE_DIR/.env" - mv -f "$LOCAL_COMPOSE_TMP" "$REMOTE_DIR/docker-compose.yml" - - for service in $SERVICES; do - docker image inspect "$IMAGE_REPOSITORY/$service:$IMAGE_TAG" >/dev/null - done - - cd "$REMOTE_DIR" - export DEPLOY_ENV=development - export IMAGE_REPOSITORY IMAGE_TAG - export PUBLISHED_BIND_ADDRESS=127.0.0.1 - - docker compose \ - --project-name "$PROJECT_NAME" \ - --env-file .env \ - -f docker-compose.yml \ - pull $SERVICES - - docker compose \ - --project-name "$PROJECT_NAME" \ - --env-file .env \ - -f docker-compose.yml \ - up -d --remove-orphans --pull never $SERVICES - - docker compose \ - --project-name "$PROJECT_NAME" \ - --env-file .env \ - -f docker-compose.yml \ - ps $SERVICES - exit 0 -fi - -run_ssh() { - ssh -i "$SSH_KEY" \ - -o BatchMode=yes \ - -o StrictHostKeyChecking=yes \ - -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" \ - -o ConnectTimeout=10 \ - "$SSH_TARGET" "$@" -} - -run_scp() { - scp -i "$SSH_KEY" \ - -o BatchMode=yes \ - -o StrictHostKeyChecking=yes \ - -o UserKnownHostsFile="$SSH_KNOWN_HOSTS" \ - -o ConnectTimeout=10 \ - "$@" -} - -run_ssh install -d -m 750 "$REMOTE_DIR" - -SUFFIX="${IMAGE_TAG}.$$.${SERVICE_CSV}" -REMOTE_ENV_TMP="$REMOTE_DIR/.env.$SUFFIX.tmp" -REMOTE_COMPOSE_TMP="$REMOTE_DIR/docker-compose.yml.$SUFFIX.tmp" - -run_scp "$ENV_FILE" "$SSH_TARGET:$REMOTE_ENV_TMP" -run_scp "$COMPOSE_FILE" "$SSH_TARGET:$REMOTE_COMPOSE_TMP" - -run_ssh sh -s -- "$REMOTE_DIR" "$REMOTE_ENV_TMP" "$REMOTE_COMPOSE_TMP" <<'REMOTE_PREPARE' -set -eu -remote_dir="$1" -env_tmp="$2" -compose_tmp="$3" -chmod 600 "$env_tmp" -chmod 644 "$compose_tmp" -mv -f "$env_tmp" "$remote_dir/.env" -mv -f "$compose_tmp" "$remote_dir/docker-compose.yml" -REMOTE_PREPARE - -for service in $SERVICES; do - image="$IMAGE_REPOSITORY/$service:$IMAGE_TAG" - docker image inspect "$image" >/dev/null - echo "传输开发镜像: $image" - docker save "$image" | gzip -1 | run_ssh 'gzip -dc | docker load' -done - -run_ssh sh -s -- "$REMOTE_DIR" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE_CSV" "$PROJECT_NAME" <<'REMOTE_DEPLOY' -set -eu -remote_dir="$1" -image_repository="$2" -image_tag="$3" -service_csv="$4" -project_name="$5" -services=$(printf '%s' "$service_csv" | tr ',' ' ') - -cd "$remote_dir" -export DEPLOY_ENV=development -export IMAGE_REPOSITORY="$image_repository" -export IMAGE_TAG="$image_tag" -export PUBLISHED_BIND_ADDRESS=127.0.0.1 - -docker compose \ - --project-name "$project_name" \ - --env-file .env \ - -f docker-compose.yml \ - up -d --remove-orphans --pull never $services - -docker compose \ - --project-name "$project_name" \ - --env-file .env \ - -f docker-compose.yml \ - ps $services -REMOTE_DEPLOY diff --git a/deploy/scripts/deploy-services.sh b/deploy/scripts/deploy-services.sh index 8c720a1fe..cbff74731 100644 --- a/deploy/scripts/deploy-services.sh +++ b/deploy/scripts/deploy-services.sh @@ -1,225 +1,7 @@ #!/usr/bin/env sh set -eu - -if [ "$#" -ne 5 ]; then - echo "用法: $0 <环境> <镜像仓库> <镜像标签> <逗号分隔服务> <环境文件>" >&2 - exit 2 -fi - -DEPLOY_ENV="$1" -IMAGE_REPOSITORY="$2" -IMAGE_TAG="$3" -SERVICE_CSV="$4" -ENV_FILE="$5" - -if [ ! -f "$ENV_FILE" ]; then - echo "环境文件不存在: $ENV_FILE" >&2 - exit 2 -fi - -SERVICES=$(printf '%s' "$SERVICE_CSV" | tr ',' ' ') - -for service in $SERVICES; do - case "$service" in - *[!A-Za-z0-9_-]*|'') - echo "非法服务名: $service" >&2 - exit 2 - ;; - esac +[ "$#" -eq 5 ] || { echo "Usage: $0 test repository tag service-csv env-file" >&2; exit 2; } +[ "$1" = "test" ] || exit 2 +for service in $(printf '%s' "$4" | tr ',' ' '); do + sh deploy/scripts/deploy-built-service.sh "$1" "$2" "$3" "$service" "$5" done - -env_file_has_key() { - grep -q "^$1=" "$ENV_FILE" -} - -env_file_value() { - sed -n "s/^$1=//p" "$ENV_FILE" | tail -n 1 | tr -d '\r' -} - -DEPLOY_REGION=CN -for service in $SERVICES; do - case "$service" in - gateway-overseas|lastleg-service|warehouse-overseas-service) DEPLOY_REGION=OVERSEAS ;; - event-bridge-service) - BRIDGE_REGION="${EVENT_BRIDGE_REGION_OVERRIDE:-}" - if [ -z "$BRIDGE_REGION" ] && env_file_has_key REGION_CODE; then - BRIDGE_REGION="$(env_file_value REGION_CODE)" - fi - if [ -z "$BRIDGE_REGION" ]; then - echo "部署 event-bridge-service 时,环境文件必须显式包含 REGION_CODE=CN 或 REGION_CODE=OVERSEAS" >&2 - exit 2 - fi - case "$BRIDGE_REGION" in - CN) ;; - OVERSEAS) DEPLOY_REGION=OVERSEAS ;; - *) echo "部署 event-bridge-service 时,REGION_CODE 只能为 CN 或 OVERSEAS" >&2; exit 2 ;; - esac - ;; - esac -done -if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then - for service in $SERVICES; do - case "$service" in - gateway-overseas|lastleg-service|warehouse-overseas-service|event-bridge-service) ;; - *) echo "一次部署不得混合国内与海外服务: $SERVICE_CSV" >&2; exit 2 ;; - esac - done - COMPOSE_FILE="deploy/docker-compose.overseas.yml" - PROJECT_NAME="firstleg-${DEPLOY_ENV}-overseas" -else - COMPOSE_FILE="deploy/docker-compose.yml" - PROJECT_NAME="firstleg-${DEPLOY_ENV}" -fi - -if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then - for key in NACOS_SERVER_ADDR OVERSEAS_NACOS_USERNAME OVERSEAS_NACOS_PASSWORD OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN JWT_ISSUER OVERSEAS_JWT_AUDIENCE JWT_JWK_SET_URI; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署海外服务时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done -fi - -case " $SERVICES " in - *" gateway-overseas "*) - if ! env_file_has_key AUTH_SESSION_VALIDATION_URL || [ -z "$(env_file_value AUTH_SESSION_VALIDATION_URL)" ]; then - echo "部署 gateway-overseas 时,环境文件必须包含非空 AUTH_SESSION_VALIDATION_URL" >&2 - exit 2 - fi - ;; -esac - -case " $SERVICES " in - *" finance "*) - for key in FINANCE_DB_URL FINANCE_DB_USERNAME FINANCE_DB_PASSWORD; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署 finance 服务时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done - ;; -esac - -case " $SERVICES " in - *" firstleg "*) - if ! env_file_has_key LASTLEG_EVENT_BRIDGE_URL || [ -z "$(env_file_value LASTLEG_EVENT_BRIDGE_URL)" ]; then - echo "部署 firstleg 时,环境文件必须包含非空 LASTLEG_EVENT_BRIDGE_URL" >&2 - exit 2 - fi - ;; -esac - -case " $SERVICES " in - *" lastleg-service "*) - for key in LASTLEG_DB_URL LASTLEG_DB_USERNAME LASTLEG_DB_PASSWORD FIRSTLEG_EVENT_BRIDGE_URL DOMESTIC_WAREHOUSE_URL FILE_SERVICE_URL; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署 lastleg-service 时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done - ;; -esac - -case " $SERVICES " in - *" warehouse-overseas-service "*) - for key in WAREHOUSE_OVERSEAS_DB_URL WAREHOUSE_OVERSEAS_DB_USERNAME WAREHOUSE_OVERSEAS_DB_PASSWORD; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署 warehouse-overseas-service 时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done - ;; -esac - -case " $SERVICES " in - *" event-bridge-service "*) - if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then - EVENT_BRIDGE_KEYS="EVENT_BRIDGE_OVERSEAS_DB_URL EVENT_BRIDGE_OVERSEAS_DB_USERNAME EVENT_BRIDGE_OVERSEAS_DB_PASSWORD EVENT_CONSUMER_URL" - else - EVENT_BRIDGE_KEYS="EVENT_BRIDGE_DB_URL EVENT_BRIDGE_DB_USERNAME EVENT_BRIDGE_DB_PASSWORD FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL" - fi - for key in $EVENT_BRIDGE_KEYS; do - if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then - echo "部署 event-bridge-service 时,环境文件必须包含非空 $key" >&2 - exit 2 - fi - done - ;; -esac - -case " $SERVICES " in - *" file "*) - if ! env_file_has_key ROCKETMQ_NAME_SERVER || [ -z "$(env_file_value ROCKETMQ_NAME_SERVER)" ]; then - echo "部署 file 服务时,环境文件必须包含非空 ROCKETMQ_NAME_SERVER" >&2 - exit 2 - fi - if ! env_file_has_key MQ_TOPIC_PREFIX; then - echo "部署 file 服务时,环境文件必须显式包含 MQ_TOPIC_PREFIX:测试填 test-,生产填空字符串" >&2 - exit 2 - fi - MQ_PREFIX=$(env_file_value MQ_TOPIC_PREFIX) - case "$DEPLOY_ENV" in - test) - if [ "$MQ_PREFIX" != "test-" ]; then - echo "测试环境部署 file 服务时 MQ_TOPIC_PREFIX 必须为 test-" >&2 - exit 2 - fi - ;; - production) - if [ -n "$MQ_PREFIX" ]; then - echo "生产环境部署 file 服务时 MQ_TOPIC_PREFIX 必须显式为空字符串" >&2 - exit 2 - fi - ;; - *) - if [ -z "$MQ_PREFIX" ] || [ "$MQ_PREFIX" = "test-" ]; then - echo "预发布环境部署 file 服务时 MQ_TOPIC_PREFIX 必须为非空且不能使用 test-" >&2 - exit 2 - fi - ;; - esac - ;; -esac - -export DEPLOY_ENV IMAGE_REPOSITORY IMAGE_TAG - -if [ "$DEPLOY_ENV" = "development" ]; then - DEPLOY_COMPOSE_FILE="$COMPOSE_FILE" DEPLOY_PROJECT_NAME="$PROJECT_NAME" exec sh deploy/scripts/deploy-development-service.sh \ - "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$SERVICE_CSV" "$ENV_FILE" -fi - -if [ "$DEPLOY_ENV" = "test" ] || [ "$DEPLOY_ENV" = "production" ]; then - docker compose \ - --project-name "$PROJECT_NAME" \ - --env-file "$ENV_FILE" \ - -f "$COMPOSE_FILE" \ - pull $SERVICES - for service in $SERVICES; do - if [ "$DEPLOY_ENV" = test ]; then - docker compose --project-name "$PROJECT_NAME" --env-file "$ENV_FILE" -f "$COMPOSE_FILE" \ - up -d --no-deps --pull never "$service" - continue - fi - sh deploy/scripts/deploy-blue-green-service.sh \ - "$DEPLOY_ENV" "$IMAGE_REPOSITORY" "$IMAGE_TAG" "$service" "$ENV_FILE" "$COMPOSE_FILE" "$PROJECT_NAME" - done - exit 0 -fi - -docker compose \ - --project-name "$PROJECT_NAME" \ - --env-file "$ENV_FILE" \ - -f "$COMPOSE_FILE" \ - pull $SERVICES - -docker compose \ - --project-name "$PROJECT_NAME" \ - --env-file "$ENV_FILE" \ - -f "$COMPOSE_FILE" \ - up -d --remove-orphans --pull never $SERVICES - -docker compose \ - --project-name "$PROJECT_NAME" \ - --env-file "$ENV_FILE" \ - -f "$COMPOSE_FILE" \ - ps $SERVICES diff --git a/deploy/scripts/initialize-test-databases.sh b/deploy/scripts/initialize-test-databases.sh index 9e97a52b6..f4960bd38 100644 --- a/deploy/scripts/initialize-test-databases.sh +++ b/deploy/scripts/initialize-test-databases.sh @@ -1,47 +1,15 @@ #!/usr/bin/env sh set -eu - -# 仅用于测试基础设施容器。所有 SQL 均使用 CREATE ... IF NOT EXISTS 或幂等扩展语句, -# 不删除已有业务数据;仓库样例数据使用固定 ID 重复写入。 -MYSQL_CONTAINER="${TEST_MYSQL_CONTAINER:-firstleg-test-mysql}" - -if ! docker inspect --type container "$MYSQL_CONTAINER" >/dev/null 2>&1; then - echo "测试 MySQL 容器不存在:$MYSQL_CONTAINER" >&2 - echo "请先执行 docker compose -f docker-compose-infra.yml up -d" >&2 - exit 1 -fi - -if [ "$(docker inspect -f '{{.State.Running}}' "$MYSQL_CONTAINER")" != "true" ]; then - echo "测试 MySQL 容器未运行:$MYSQL_CONTAINER" >&2 - exit 1 -fi - -for sql_file in \ - auth/src/main/resources/db/auth.sql \ - iam/src/main/resources/db/iam.sql \ - auth/src/main/resources/db/phase1-admin-accounts.sql \ - iam/src/main/resources/db/phase1-admin-rbac.sql \ - iam/src/main/resources/db/phase1-onboarding.sql \ - iam/src/main/resources/db/phase1-customer-profile.sql \ - iam/src/main/resources/db/phase1-member-permissions.sql \ - iam/src/main/resources/db/rbac-role-management-v2.sql \ - iam/src/main/resources/db/phase1-platform-master-data-and-permission-management.sql \ - order/src/main/resources/db/order.sql \ - order/src/main/resources/db/phase1-order-extension.sql \ - order/src/main/resources/db/phase1-masterdata-extension.sql \ - warehouse/src/main/resources/db/warehouse.sql \ - warehouse/src/main/resources/db/warehouse-partner.sql \ - warehouse/src/main/resources/db/warehouse-express-company.sql \ - warehouse/src/main/resources/db/phase1-warehouse-schema-migration.sql \ - warehouse/src/main/resources/db/warehouse-seed.sql \ - file/src/main/resources/db/file.sql -do - if [ ! -f "$sql_file" ]; then - echo "找不到初始化 SQL:$sql_file" >&2 - exit 1 - fi - echo "初始化测试数据库:$sql_file" - docker exec -i "$MYSQL_CONTAINER" sh -c 'exec mysql -uroot -p"$MYSQL_ROOT_PASSWORD" --default-character-set=utf8mb4' < "$sql_file" +# Empty-schema baseline only. Never invoked by Compose/Jenkins automatically. +[ "${ALLOW_TEST_SCHEMA_INITIALIZATION:-false}" = "true" ] || { echo 'Explicit TEST SQL authorization required' >&2; exit 2; } +: "${TEST_MYSQL_CONTAINER:?Specify the authorized TEST MySQL container}" +[ "$#" -eq 2 ] || { echo 'Usage: initialize-test-databases.sh ' >&2; exit 2; } +for sql_file in "$1" "$2"; do + [ -f "$sql_file" ] || { echo "Missing reviewed baseline: $sql_file" >&2; exit 2; } +done +[ "$1" != "$2" ] || { echo 'Two distinct reviewed baselines required' >&2; exit 2; } +[ "$(docker inspect -f '{{.State.Running}}' "$TEST_MYSQL_CONTAINER")" = 'true' ] || { echo 'TEST MySQL is not running' >&2; exit 1; } +for sql_file in "$1" "$2"; do + echo "Applying reviewed TEST baseline: $sql_file" + docker exec -i "$TEST_MYSQL_CONTAINER" sh -c 'exec mysql -uroot -p"$MYSQL_ROOT_PASSWORD" --default-character-set=utf8mb4' < "$sql_file" done - -echo "测试数据库初始化完成。" diff --git a/deploy/scripts/run-ci-maven.sh b/deploy/scripts/run-ci-maven.sh new file mode 100644 index 000000000..c300267e2 --- /dev/null +++ b/deploy/scripts/run-ci-maven.sh @@ -0,0 +1,37 @@ +#!/bin/sh +# Linux Jenkins entry; no service deployment, data cleanup or dependency installation. +set -eu + +for tool in flock timeout mvn; do + command -v "$tool" >/dev/null 2>&1 || { + echo "Required CI tool missing: $tool" >&2 + exit 69 + } +done +for argument in "$@"; do + case "$argument" in + -T*|--threads*) + echo 'Parallel Maven reactors are disabled on the shared TEST builder.' >&2 + exit 64 + ;; + esac +done + +# All jobs run as the same builder account. Keep the inode: flock releases on close. +# A controller-side abort must not permit another build over a surviving local build. +umask 077 +lock_dir="${HOME:?HOME is required}/.cache/chongchong-ci" +mkdir -p "$lock_dir" +exec 9>"$lock_dir/maven.lock" +if ! flock -n 9; then + echo 'Another local Maven build still holds the CI lock; inspect it before retrying.' >&2 + exit 75 +fi + +# Separate budgets: this process is Maven; ci-bounded configures its test JVM. +MAVEN_OPTS='-Xms128m -Xmx768m -XX:MaxMetaspaceSize=256m -XX:MaxDirectMemorySize=128m -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8 -Dmaven.wagon.http.retryHandler.count=3' +export MAVEN_OPTS + +# Local watchdog still runs if the Jenkins controller/agent connection is lost. +# Do not use --foreground: timeout must signal the forked process group too. +exec timeout --signal=TERM --kill-after=30s 45m mvn -Pci-bounded "$@" diff --git a/deploy/service-map.yml b/deploy/service-map.yml index e22eec804..7fefc9ca5 100644 --- a/deploy/service-map.yml +++ b/deploy/service-map.yml @@ -3,11 +3,6 @@ services: artifactId: gateway jar: gateway/target/gateway-1.0.0-SNAPSHOT.jar port: 9000 - gateway-overseas: - artifactId: gateway-overseas - jar: gateway-overseas/target/gateway-overseas-1.0.0-SNAPSHOT.jar - port: 9300 - regions: [OVERSEAS] auth: artifactId: auth-service jar: auth/target/auth-service-1.0.0-SNAPSHOT.jar @@ -44,17 +39,10 @@ services: artifactId: lastleg-service jar: lastleg-service/target/lastleg-service-1.0.0-SNAPSHOT.jar port: 9303 - regions: [OVERSEAS] warehouse-overseas-service: artifactId: warehouse-overseas-service jar: warehouse-overseas-service/target/warehouse-overseas-service-1.0.0-SNAPSHOT.jar port: 9305 - regions: [OVERSEAS] - event-bridge-service: - artifactId: event-bridge-service - jar: event-bridge-service/target/event-bridge-service-1.0.0-SNAPSHOT.jar - port: 9312 - regions: [CN, OVERSEAS] audit-service: artifactId: audit-service jar: audit-service/target/audit-service-1.0.0-SNAPSHOT.jar diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 1045b098b..de832a3cd 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -1,4 +1,4 @@ -# 区域数据库空库初始化 +# 单测试环境数据库空库初始化 ## 唯一入口与区域边界 @@ -6,12 +6,17 @@ | 区域 | 完整入口 | 服务 schema | 表数 | |---|---|---|---:| -| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance` | 223 | -| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse`、`baiyue_event_bridge_overseas` | 138 | +| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 226 | +| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 154 | -- 国内和海外是两个部署区域,不是把微服务合并为两个 schema。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 -- 两区域可分别在独立空实例执行,没有跨区域 SQL 依赖。海外不访问国内 IAM/Warehouse;国内不创建、查询或修改海外业务表。 +- 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 +- 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。 - 国内 IAM 是全部平台与客户权限的唯一权威,包括海外仓、海外业务、海外主数据及财务权限;这些权限保留在国内种子中。 +- 车辆成本、报价、出车实测及在途交接表对应Lastleg模块基线;公司车辆费用三表对应Finance模块基线。公司财务权限源为`iam/src/main/resources/db/fleet-finance-permissions.sql`,已合入国内统一种子,不以模块文件代替完整空库初始化。 +- 外接预订的`quote_source_json`保存可选销售报价来源版本快照,实际成交价仍在货物快照及`agreed_amount`中;手填成交价时无报价来源。该字段随Lastleg模块基线与海外统一入口维护,执行状态只见正式部署契约。 +- 异常退回的`return_context_json`保存取消时所需的原始业务状态;逐来源`received_packages`累计实际入仓量且不得超过批准退回量。同一派送可以保留多张已取消/已结束的历史退回单,活动退回仍由派送行锁内校验;普通`idx_ll_exception_delivery_return`替代跨全部历史的唯一索引。数量和状态不能通过回填旧单据伪造。 +- 派送`ll_delivery_order.signed_packages`保存签收资料确认时冻结的实际签收箱数;NULL表示未确认,不当作全量签收,数量须在0到本次实发箱数之间。后续退回保留原签收历史,不用当前退回量倒改实签。 +- `ll_return_stock_receipt`与真实退回库存入账同事务保存,按客户/入账流水唯一,保留原订单和出库位置以及实际入仓位置。重派候选、草稿锁及拣货显式引用该凭据;拣货`receipt_id`与`return_receipt_id`恰有一个非空。退回不增加原订单箱数,不回退历史出库流水;可重新发运量由实际退回及已消耗凭据核平。 - 国内 Warehouse 管理包括海外仓在内的公共仓库主档;海外 Warehouse 只保存资源与主档 ID 引用。 - Gateway、Print、Integration 不新增数据库。Compose 继续使用 `SPRING_SQL_INIT_MODE=never`,服务启动不执行这些文件。 @@ -24,7 +29,7 @@ 5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。 6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。 -成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=223/138`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 +成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=226/154`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 ## 平台管理员密码 @@ -40,13 +45,13 @@ | 所属库 | 保留内容 | 不包含 | |---|---|---| -| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、370 个权限、552 条角色权限、7 个数据范围、客户编号起点 | 测试客户、固定客户账号、额外扩权 | +| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、412 个权限、593 条角色权限、7 个数据范围、客户编号起点;无人工完成订单权限 | 测试客户、固定客户账号、额外扩权 | | Warehouse | 既有公共仓目录 318 条及地址;义乌、东莞、洛杉矶、纽约 4 个作业仓收货能力 | 示例供应商、快递、往来单位、托盘、库位、预占、库存 | | Firstleg | 112 个费用项目、15 个渠道目录、7 个始发地别名、4 个公共仓映射 | 客户报价、订单、入库、实测或履约记录 | | File / Finance | 导出互斥键 `GLOBAL_CREATE`、默认平台财务主体 | 文件任务、资金、账单或交易流水 | -| Lastleg | 国内固定管理员对应的 USER/AUTHORIZATION version=0 安全投影,当前角色及完整 106 项海外权限 | 客户、订单、库存、发运门禁事实或新账号 | +| Lastleg | 无身份或授权种子;复用统一Auth/IAM会话 | 客户、订单、库存、发运门禁事实或独立账号 | -海外管理员投影只是国内已定义身份的初始化快照,不是独立账号或权限中心。后续变更仍由 IAM 事件同步,不提供历史投影回填。 +IAM保留subject/tenant持久版本表;安全事件专用Outbox及Lastleg授权投影表已退出新空库基线。旧库不在本次脚本中执行删除或历史回填。 扣货写权限只默认授予平台管理员,与 IAM 扣货权限合同一致;旧脚本重放时通过范围授权额外授予订单运营的那一条关联不进入新基线。 @@ -67,12 +72,12 @@ | 区域 / schema | 迁移 | 目的 | |---|---|---| | CN / `baiyue_iam` | `migrations/cn/baiyue_iam/V1__align_schema_baseline.sql`、`V2__add_pda_release_permissions.sql`、`V3__align_domestic_pda_mojia_sailing_permissions.sql` | 收敛客户编号、昵称和资质原文件名定义;补齐 PDA 发布、国内作业仓绑定、墨家 SKU 同步、仓管 PDA 和船期日历权限 | -| CN / `baiyue_firstleg` | `migrations/cn/baiyue_firstleg/V1__align_schema_baseline.sql`、`V2__align_measurement_and_fee_schema.sql`、`V3__add_customs_receipt_declaration_schema.sql`、`V4__add_sailing_calendar_schema.sql` | 收敛一件代发托盘码非空定义;补齐墨家测量和人工报价字段、报关行回执/申报事实、船期日历事实表;V2 保留未上报重量为 NULL,放宽已有非空重量列,不回填或删除收件记录 | +| CN / `baiyue_firstleg` | `migrations/cn/baiyue_firstleg/V1__align_schema_baseline.sql`、`V2__align_measurement_and_fee_schema.sql`、`V3__add_customs_receipt_declaration_schema.sql`、`V4__add_sailing_calendar_schema.sql`、`V5__event_delivery_retry_audit.sql`、`V6__event_delivery_attempt.sql`、`V7__settle_partial_loaded_cargo.sql` | 收敛一件代发托盘码非空定义;补齐测量/报价、报关、船期和事件重试审计;V7 仅更新装柜明细保护触发器,允许普通箱件按冻结分配和实际拣货精确收敛件数、重量、体积和托数,保留身份及已完成冻结保护,不回填原事件或库存 | | CN / `baiyue_file` | `migrations/cn/baiyue_file/V1__align_schema_baseline.sql`、`V2__widen_business_type.sql` | 补齐 PDA 发布表和固定渠道,并支持正式长业务类型 | -| OVERSEAS / `baiyue_lastleg` | `migrations/overseas/baiyue_lastleg/V1__align_lastleg_production_schema.sql`、`V2__align_schema_baseline.sql`、`V3__relax_valid_nullable_fields.sql`、`V4__cargo_discard_return_inventory.sql`、`V5__allow_multiple_relabel_applications.sql` | 补齐尾程字段并收敛字段定义;放宽退货出库和预入仓批量 SKU 换标合法空值;货物废弃支持退货库存箱;允许同一尾程订单多个换标申请 | +| OVERSEAS / `baiyue_lastleg` | `migrations/overseas/baiyue_lastleg/V1__align_lastleg_production_schema.sql`、`V2__align_schema_baseline.sql`、`V3__relax_valid_nullable_fields.sql`、`V4__cargo_discard_return_inventory.sql`、`V5__allow_multiple_relabel_applications.sql`、`V6__event_delivery_retry_audit.sql`、`V7__event_delivery_attempt.sql`、`V8__bind_stock_transfer_relabel_sources.sql` | 补齐尾程字段、退货/换标/废弃结构与事件投递审计;V8 保存已有库存转运的实际 SKU、原分配和换标明细关联,允许同位置历史与当前分配共存,保留活动占用唯一约束,不回填旧业务或库存 | | OVERSEAS / `baiyue_lastlag_warehouse` | `migrations/overseas/baiyue_lastlag_warehouse/V1__add_shared_overflow_location.sql`、`V2__retire_location_zone_and_rebuild_query_index.sql` | 补齐共享溢出库位并移除退役 `zone_code`;V2 含 `DROP COLUMN`,生产执行必须单独授权并确认无外部依赖 | -上述迁移候选只完成编写和离线检查,尚未在测试或生产执行。`db-migrations.mjs plan --services iam,firstleg,lastleg-service,warehouse-overseas-service --regions CN,OVERSEAS` 生成的 manifest 目标版本应为 `baiyue_iam=3`、`baiyue_firstleg=4`、`baiyue_lastleg=5`、`baiyue_lastlag_warehouse=2`。测试通过后必须复用同一份 SQL 和 manifest 晋级生产,不得重新生成或修改候选。`scripts/prod-database-schema-acceptance.mjs` 仍只负责历史尾程 V1 的专用只读核对/受控执行,不代表本批跨 schema 发布入口;本批按迁移 manifest 管理,实际执行须等待环境执行器接入或当前轮明确授权后的受控人工执行。 +候选编写和离线检查不证明环境已执行;测试环境执行事实只维护在 `../../../docs/测试环境单机部署与配置契约.md`,生产执行须另有明确授权。`db-migrations.mjs plan --services iam,firstleg,lastleg-service,warehouse-overseas-service --regions CN,OVERSEAS` 生成的 manifest 目标版本应为 `baiyue_iam=3`、`baiyue_firstleg=7`、`baiyue_lastleg=8`、`baiyue_lastlag_warehouse=2`。测试通过后必须复用同一份 SQL 和 manifest 晋级生产,不得重新生成或修改候选。`scripts/prod-database-schema-acceptance.mjs` 仍只负责历史尾程 V1 的专用只读核对/受控执行,不代表本批跨 schema 发布入口;本批按迁移 manifest 管理,实际执行须等待环境执行器接入或当前轮明确授权后的受控人工执行。 迁移范围映射位于 `migration-scopes.json`,按区域、schema、所属服务和顺序登记。迁移文件位于 `migrations/{cn|overseas}//`,一个文件只允许修改一个 schema。目录中的 `.gitkeep` 仅用于保留空目录,不是迁移文件。 diff --git a/deploy/sql/migration-scopes.json b/deploy/sql/migration-scopes.json index ebe28e67e..aecc3c5a6 100644 --- a/deploy/sql/migration-scopes.json +++ b/deploy/sql/migration-scopes.json @@ -2,7 +2,6 @@ "schemaVersion": 1, "services": [ "gateway", - "gateway-overseas", "auth", "iam", "order", @@ -10,7 +9,6 @@ "firstleg", "file", "audit-service", - "event-bridge-service", "finance", "integration-service", "print", @@ -19,7 +17,7 @@ ], "regions": { "CN": { - "baseline": "baseline/cn/cn-baseline.sql", + "baseline": "头程数据库统一增量-MySQL8.sql", "schemas": [ { "name": "baiyue_auth", "services": ["auth"], "order": 10 }, { "name": "baiyue_iam", "services": ["iam"], "order": 20 }, @@ -28,16 +26,14 @@ { "name": "baiyue_firstleg", "services": ["firstleg"], "order": 50 }, { "name": "baiyue_file", "services": ["file"], "order": 60 }, { "name": "baiyue_audit", "services": ["audit-service"], "order": 70 }, - { "name": "baiyue_event_bridge", "services": ["event-bridge-service"], "order": 80 }, { "name": "baiyue_finance", "services": ["finance"], "order": 90 } ] }, "OVERSEAS": { - "baseline": "baseline/overseas/overseas-baseline.sql", + "baseline": "海外区域统一增量-MySQL8.sql", "schemas": [ { "name": "baiyue_lastleg", "services": ["lastleg-service"], "order": 10 }, - { "name": "baiyue_lastlag_warehouse", "services": ["warehouse-overseas-service"], "order": 20 }, - { "name": "baiyue_event_bridge_overseas", "services": ["event-bridge-service"], "order": 30 } + { "name": "baiyue_lastlag_warehouse", "services": ["warehouse-overseas-service"], "order": 20 } ] } } diff --git a/deploy/sql/migrations/cn/baiyue_finance/V1__event_delivery_retry_audit.sql b/deploy/sql/migrations/cn/baiyue_finance/V1__event_delivery_retry_audit.sql new file mode 100644 index 000000000..25c2b32ae --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_finance/V1__event_delivery_retry_audit.sql @@ -0,0 +1,22 @@ +-- Region: CN +-- Schema: baiyue_finance +-- Purpose: transactional manual retry audit for immutable business Outbox entries. +-- Preconditions: service Outbox schema exists; authorized schema migration only. +-- Postconditions: retry state and operator audit can commit atomically. +SET NAMES utf8mb4; +CREATE TABLE event_delivery_retry_audit ( + request_id VARCHAR(36) NOT NULL COMMENT '人工重试请求UUID和幂等键', + producer_table VARCHAR(64) NOT NULL COMMENT '本服务发件箱表名', + entry_id VARCHAR(64) NOT NULL COMMENT '发件箱记录主键', + event_id VARCHAR(128) NOT NULL COMMENT '原事件全局标识', + event_type VARCHAR(64) NOT NULL COMMENT '原事件类型', + payload_hash VARCHAR(71) NOT NULL COMMENT '原事件不可变负载摘要', + operator_name VARCHAR(128) NOT NULL COMMENT '持有运维凭据的操作人标识', + reason VARCHAR(500) NOT NULL COMMENT '人工重试原因', + previous_failures INT NOT NULL COMMENT '重试前累计失败次数', + previous_error VARCHAR(1000) NULL COMMENT '重试前最后一次脱敏错误', + requested_at DATETIME(3) NOT NULL COMMENT '重试申请提交时间', + PRIMARY KEY (request_id), + KEY idx_event_retry_entry (producer_table,entry_id,requested_at), + KEY idx_event_retry_event (event_id,requested_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务事件人工重试审计,与恢复待发送状态同事务提交'; diff --git a/deploy/sql/migrations/cn/baiyue_finance/V2__event_delivery_attempt.sql b/deploy/sql/migrations/cn/baiyue_finance/V2__event_delivery_attempt.sql new file mode 100644 index 000000000..37bcf1e53 --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_finance/V2__event_delivery_attempt.sql @@ -0,0 +1,23 @@ +-- Region: CN +-- Schema: baiyue_finance +-- Purpose: durable delivery attempts committed with Outbox lease transitions. +-- Preconditions: authorized schema migration before starting the new producer. +-- Postconditions: per-attempt audit exists without cross-schema access. +SET NAMES utf8mb4; +CREATE TABLE event_delivery_attempt ( + attempt_id VARCHAR(36) NOT NULL COMMENT '本次投递租约UUID和审计主键', + service_name VARCHAR(32) NOT NULL COMMENT '本库所属生产服务', + event_type VARCHAR(96) NOT NULL COMMENT '白名单业务事件类型', + entry_id VARCHAR(128) NOT NULL COMMENT '生产者发件箱主键', + event_id VARCHAR(128) NULL COMMENT '合法原事件UUID或ULID;格式错误时为空', + target_name VARCHAR(64) NOT NULL COMMENT '固定目标业务服务', + started_at DATETIME(3) NOT NULL COMMENT '领取及审计事务提交前的开始时间', + finished_at DATETIME(3) NULL COMMENT '尝试完成或租约过期恢复时间', + duration_ms BIGINT NULL COMMENT '尝试耗时毫秒', + outcome VARCHAR(24) NOT NULL COMMENT 'STARTED/APPLIED/DUPLICATE/STALE/RETRY/DEAD/LEASE_EXPIRED', + error_code VARCHAR(64) NULL COMMENT '有限错误类别或HTTP状态;不含载荷与令牌', + next_retry_at DATETIME(3) NULL COMMENT '本次失败后下次计划投递时间', + PRIMARY KEY (attempt_id), + KEY idx_delivery_attempt_entry (service_name,entry_id,started_at), + KEY idx_delivery_attempt_outcome (outcome,started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; diff --git a/deploy/sql/migrations/cn/baiyue_firstleg/V5__event_delivery_retry_audit.sql b/deploy/sql/migrations/cn/baiyue_firstleg/V5__event_delivery_retry_audit.sql new file mode 100644 index 000000000..65a2dc56d --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_firstleg/V5__event_delivery_retry_audit.sql @@ -0,0 +1,22 @@ +-- Region: CN +-- Schema: baiyue_firstleg +-- Purpose: transactional manual retry audit for immutable business Outbox entries. +-- Preconditions: service Outbox schema exists; authorized schema migration only. +-- Postconditions: retry state and operator audit can commit atomically. +SET NAMES utf8mb4; +CREATE TABLE event_delivery_retry_audit ( + request_id VARCHAR(36) NOT NULL COMMENT '人工重试请求UUID和幂等键', + producer_table VARCHAR(64) NOT NULL COMMENT '本服务发件箱表名', + entry_id VARCHAR(64) NOT NULL COMMENT '发件箱记录主键', + event_id VARCHAR(128) NOT NULL COMMENT '原事件全局标识', + event_type VARCHAR(64) NOT NULL COMMENT '原事件类型', + payload_hash VARCHAR(71) NOT NULL COMMENT '原事件不可变负载摘要', + operator_name VARCHAR(128) NOT NULL COMMENT '持有运维凭据的操作人标识', + reason VARCHAR(500) NOT NULL COMMENT '人工重试原因', + previous_failures INT NOT NULL COMMENT '重试前累计失败次数', + previous_error VARCHAR(1000) NULL COMMENT '重试前最后一次脱敏错误', + requested_at DATETIME(3) NOT NULL COMMENT '重试申请提交时间', + PRIMARY KEY (request_id), + KEY idx_event_retry_entry (producer_table,entry_id,requested_at), + KEY idx_event_retry_event (event_id,requested_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务事件人工重试审计,与恢复待发送状态同事务提交'; diff --git a/deploy/sql/migrations/cn/baiyue_firstleg/V6__event_delivery_attempt.sql b/deploy/sql/migrations/cn/baiyue_firstleg/V6__event_delivery_attempt.sql new file mode 100644 index 000000000..c0d3cd9c8 --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_firstleg/V6__event_delivery_attempt.sql @@ -0,0 +1,23 @@ +-- Region: CN +-- Schema: baiyue_firstleg +-- Purpose: durable delivery attempts committed with Outbox lease transitions. +-- Preconditions: authorized schema migration before starting the new producer. +-- Postconditions: per-attempt audit exists without cross-schema access. +SET NAMES utf8mb4; +CREATE TABLE event_delivery_attempt ( + attempt_id VARCHAR(36) NOT NULL COMMENT '本次投递租约UUID和审计主键', + service_name VARCHAR(32) NOT NULL COMMENT '本库所属生产服务', + event_type VARCHAR(96) NOT NULL COMMENT '白名单业务事件类型', + entry_id VARCHAR(128) NOT NULL COMMENT '生产者发件箱主键', + event_id VARCHAR(128) NULL COMMENT '合法原事件UUID或ULID;格式错误时为空', + target_name VARCHAR(64) NOT NULL COMMENT '固定目标业务服务', + started_at DATETIME(3) NOT NULL COMMENT '领取及审计事务提交前的开始时间', + finished_at DATETIME(3) NULL COMMENT '尝试完成或租约过期恢复时间', + duration_ms BIGINT NULL COMMENT '尝试耗时毫秒', + outcome VARCHAR(24) NOT NULL COMMENT 'STARTED/APPLIED/DUPLICATE/STALE/RETRY/DEAD/LEASE_EXPIRED', + error_code VARCHAR(64) NULL COMMENT '有限错误类别或HTTP状态;不含载荷与令牌', + next_retry_at DATETIME(3) NULL COMMENT '本次失败后下次计划投递时间', + PRIMARY KEY (attempt_id), + KEY idx_delivery_attempt_entry (service_name,entry_id,started_at), + KEY idx_delivery_attempt_outcome (outcome,started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; diff --git a/deploy/sql/migrations/cn/baiyue_firstleg/V7__settle_partial_loaded_cargo.sql b/deploy/sql/migrations/cn/baiyue_firstleg/V7__settle_partial_loaded_cargo.sql new file mode 100644 index 000000000..8ceefa595 --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_firstleg/V7__settle_partial_loaded_cargo.sql @@ -0,0 +1,124 @@ +-- Region: CN +-- Schema: baiyue_firstleg +-- Purpose: settle_partial_loaded_cargo +-- Preconditions: existing loading item/pick tables and trg_loading_item_before_update. +-- Postconditions: exact PACKAGE partial convergence; all other frozen fields remain protected. +-- No business-row backfill or replay of existing loading events. +DROP TRIGGER IF EXISTS trg_loading_item_before_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_item_before_update +BEFORE UPDATE ON fl_loading_plan_item +FOR EACH ROW +BEGIN + DECLARE current_plan_status VARCHAR(24); + SELECT MAX(plan_status) INTO current_plan_status + FROM fl_loading_plan WHERE id = OLD.loading_plan_id; + IF current_plan_status = 'LOADING' AND EXISTS ( + SELECT 1 FROM fl_loading_pick_item + WHERE loading_plan_item_id = OLD.id AND picked_package_count > 0) THEN + IF NOT (NEW.internal_remark <=> OLD.internal_remark) + OR NOT (NEW.warehouse_remark <=> OLD.warehouse_remark) + OR NOT (NEW.customer_service_remark <=> OLD.customer_service_remark) THEN + IF NEW.package_count <> OLD.package_count OR NEW.deleted <> OLD.deleted + OR NOT (NEW.customer_name <=> OLD.customer_name) + OR NOT (NEW.inbound_id <=> OLD.inbound_id) + OR NOT (NEW.inbound_detail_id <=> OLD.inbound_detail_id) + OR NOT (NEW.order_id <=> OLD.order_id) + OR NOT (NEW.order_no <=> OLD.order_no) + OR NOT (NEW.inbound_code <=> OLD.inbound_code) + OR NOT (NEW.destination_warehouse_id <=> OLD.destination_warehouse_id) + OR NOT (NEW.destination_warehouse_code <=> OLD.destination_warehouse_code) + OR NOT (NEW.destination_warehouse_name <=> OLD.destination_warehouse_name) + OR NOT (NEW.sku_code <=> OLD.sku_code) + OR NOT (NEW.product_name_zh <=> OLD.product_name_zh) + OR NOT (NEW.product_name_en <=> OLD.product_name_en) + OR NOT (NEW.product_image_file_id <=> OLD.product_image_file_id) + OR NOT (NEW.product_image_url <=> OLD.product_image_url) + OR NOT (NEW.product_quantity <=> OLD.product_quantity) + OR NOT (NEW.units_per_carton <=> OLD.units_per_carton) + OR NOT (NEW.pallet_count <=> OLD.pallet_count) + OR NOT (NEW.gross_weight_kg <=> OLD.gross_weight_kg) + OR NOT (NEW.net_weight_kg <=> OLD.net_weight_kg) + OR NOT (NEW.dimensional_weight_kg <=> OLD.dimensional_weight_kg) + OR NOT (NEW.volume_cbm <=> OLD.volume_cbm) + OR NOT (NEW.fba_tracking_no <=> OLD.fba_tracking_no) + OR NOT (NEW.shipment_no <=> OLD.shipment_no) + OR NOT (NEW.shipping_mark <=> OLD.shipping_mark) + OR NOT (NEW.load_sequence <=> OLD.load_sequence) + OR NOT (NEW.position_snapshot <=> OLD.position_snapshot) + OR NOT (NEW.customer_remark <=> OLD.customer_remark) + OR NOT (NEW.frozen_at <=> OLD.frozen_at) + OR NOT (NEW.frozen_by <=> OLD.frozen_by) + OR NOT (NEW.removed_by <=> OLD.removed_by) + OR NOT (NEW.removed_by_name <=> OLD.removed_by_name) + OR NOT (NEW.removed_at <=> OLD.removed_at) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细修改备注时不得变更其他业务字段'; + END IF; + ELSEIF NOT EXISTS ( + SELECT 1 FROM fl_loading_pick_item actual_pick + JOIN fl_loading_pick_task actual_task ON actual_task.id=actual_pick.pick_task_id + WHERE actual_pick.loading_plan_item_id = OLD.id + AND actual_task.loading_plan_id = OLD.loading_plan_id + AND actual_pick.item_status = 'PARTIAL' + AND OLD.unit_type = 'PACKAGE' AND OLD.pallet_id IS NULL + AND actual_pick.picked_package_count > 0 + AND actual_pick.picked_package_count < actual_pick.planned_package_count + AND OLD.package_count = actual_pick.planned_package_count + AND NEW.deleted = 0 + AND NEW.package_count = actual_pick.picked_package_count + ) OR NEW.deleted <> OLD.deleted + OR NOT (NEW.customer_name <=> OLD.customer_name) + OR NOT (NEW.inbound_id <=> OLD.inbound_id) + OR NOT (NEW.inbound_detail_id <=> OLD.inbound_detail_id) + OR NOT (NEW.order_id <=> OLD.order_id) + OR NOT (NEW.order_no <=> OLD.order_no) + OR NOT (NEW.inbound_code <=> OLD.inbound_code) + OR NOT (NEW.destination_warehouse_id <=> OLD.destination_warehouse_id) + OR NOT (NEW.destination_warehouse_code <=> OLD.destination_warehouse_code) + OR NOT (NEW.destination_warehouse_name <=> OLD.destination_warehouse_name) + OR NOT (NEW.sku_code <=> OLD.sku_code) + OR NOT (NEW.product_name_zh <=> OLD.product_name_zh) + OR NOT (NEW.product_name_en <=> OLD.product_name_en) + OR NOT (NEW.product_image_file_id <=> OLD.product_image_file_id) + OR NOT (NEW.product_image_url <=> OLD.product_image_url) + OR NOT (NEW.product_quantity <=> CASE WHEN OLD.units_per_carton IS NOT NULL THEN OLD.units_per_carton * NEW.package_count ELSE ROUND(CAST(OLD.product_quantity AS DECIMAL(30,6)) * NEW.package_count / NULLIF(OLD.package_count,0),0) END) + OR NOT (NEW.units_per_carton <=> OLD.units_per_carton) + OR NOT (NEW.pallet_count <=> CEIL(CAST(COALESCE(OLD.pallet_count,0) AS DECIMAL(30,6)) * NEW.package_count / NULLIF(OLD.package_count,0))) + OR NOT (NEW.gross_weight_kg <=> ROUND(OLD.gross_weight_kg * NEW.package_count / NULLIF(OLD.package_count,0),3)) + OR NOT (NEW.net_weight_kg <=> ROUND(OLD.net_weight_kg * NEW.package_count / NULLIF(OLD.package_count,0),3)) + OR NOT (NEW.dimensional_weight_kg <=> ROUND(OLD.dimensional_weight_kg * NEW.package_count / NULLIF(OLD.package_count,0),3)) + OR NOT (NEW.volume_cbm <=> ROUND(OLD.volume_cbm * NEW.package_count / NULLIF(OLD.package_count,0),4)) + OR NOT (NEW.fba_tracking_no <=> OLD.fba_tracking_no) + OR NOT (NEW.shipment_no <=> OLD.shipment_no) + OR NOT (NEW.shipping_mark <=> OLD.shipping_mark) + OR NOT (NEW.load_sequence <=> OLD.load_sequence) + OR NOT (NEW.position_snapshot <=> OLD.position_snapshot) + OR NOT (NEW.customer_remark <=> OLD.customer_remark) + OR NOT (NEW.frozen_at <=> OLD.frozen_at) + OR NOT (NEW.frozen_by <=> OLD.frozen_by) + OR NOT (NEW.removed_by <=> OLD.removed_by) + OR NOT (NEW.removed_by_name <=> OLD.removed_by_name) + OR NOT (NEW.removed_at <=> OLD.removed_at) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细仅允许按冻结分配比例收敛实际装载'; + END IF; + END IF; + IF OLD.frozen_at IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后的明细永久冻结'; + END IF; + IF OLD.deleted = 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已删除装柜明细不可恢复或修改'; + END IF; + IF NEW.loading_plan_id <> OLD.loading_plan_id OR NEW.account_id <> OLD.account_id + OR NEW.inbound_detail_id <> OLD.inbound_detail_id THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜明细归属键不可修改,请删除后重新分配'; + END IF; + IF NEW.deleted = 1 THEN + IF NEW.removed_by IS NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '删除装柜明细必须记录操作人'; + END IF; + SET NEW.removed_at = COALESCE(NEW.removed_at, CURRENT_TIMESTAMP(3)); + ELSEIF NEW.removed_by IS NOT NULL OR NEW.removed_by_name IS NOT NULL OR NEW.removed_at IS NOT NULL THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '删除审计字段只能在逻辑删除时写入'; + END IF; +END$$ +DELIMITER ; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V6__event_delivery_retry_audit.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V6__event_delivery_retry_audit.sql new file mode 100644 index 000000000..dba0a5a22 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V6__event_delivery_retry_audit.sql @@ -0,0 +1,22 @@ +-- Region: OVERSEAS +-- Schema: baiyue_lastleg +-- Purpose: transactional manual retry audit for immutable business Outbox entries. +-- Preconditions: service Outbox schema exists; authorized schema migration only. +-- Postconditions: retry state and operator audit can commit atomically. +SET NAMES utf8mb4; +CREATE TABLE event_delivery_retry_audit ( + request_id VARCHAR(36) NOT NULL COMMENT '人工重试请求UUID和幂等键', + producer_table VARCHAR(64) NOT NULL COMMENT '本服务发件箱表名', + entry_id VARCHAR(64) NOT NULL COMMENT '发件箱记录主键', + event_id VARCHAR(128) NOT NULL COMMENT '原事件全局标识', + event_type VARCHAR(64) NOT NULL COMMENT '原事件类型', + payload_hash VARCHAR(71) NOT NULL COMMENT '原事件不可变负载摘要', + operator_name VARCHAR(128) NOT NULL COMMENT '持有运维凭据的操作人标识', + reason VARCHAR(500) NOT NULL COMMENT '人工重试原因', + previous_failures INT NOT NULL COMMENT '重试前累计失败次数', + previous_error VARCHAR(1000) NULL COMMENT '重试前最后一次脱敏错误', + requested_at DATETIME(3) NOT NULL COMMENT '重试申请提交时间', + PRIMARY KEY (request_id), + KEY idx_event_retry_entry (producer_table,entry_id,requested_at), + KEY idx_event_retry_event (event_id,requested_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务事件人工重试审计,与恢复待发送状态同事务提交'; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V7__event_delivery_attempt.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V7__event_delivery_attempt.sql new file mode 100644 index 000000000..deb635d30 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V7__event_delivery_attempt.sql @@ -0,0 +1,23 @@ +-- Region: OVERSEAS +-- Schema: baiyue_lastleg +-- Purpose: durable delivery attempts committed with Outbox lease transitions. +-- Preconditions: authorized schema migration before starting the new producer. +-- Postconditions: per-attempt audit exists without cross-schema access. +SET NAMES utf8mb4; +CREATE TABLE event_delivery_attempt ( + attempt_id VARCHAR(36) NOT NULL COMMENT '本次投递租约UUID和审计主键', + service_name VARCHAR(32) NOT NULL COMMENT '本库所属生产服务', + event_type VARCHAR(96) NOT NULL COMMENT '白名单业务事件类型', + entry_id VARCHAR(128) NOT NULL COMMENT '生产者发件箱主键', + event_id VARCHAR(128) NULL COMMENT '合法原事件UUID或ULID;格式错误时为空', + target_name VARCHAR(64) NOT NULL COMMENT '固定目标业务服务', + started_at DATETIME(3) NOT NULL COMMENT '领取及审计事务提交前的开始时间', + finished_at DATETIME(3) NULL COMMENT '尝试完成或租约过期恢复时间', + duration_ms BIGINT NULL COMMENT '尝试耗时毫秒', + outcome VARCHAR(24) NOT NULL COMMENT 'STARTED/APPLIED/DUPLICATE/STALE/RETRY/DEAD/LEASE_EXPIRED', + error_code VARCHAR(64) NULL COMMENT '有限错误类别或HTTP状态;不含载荷与令牌', + next_retry_at DATETIME(3) NULL COMMENT '本次失败后下次计划投递时间', + PRIMARY KEY (attempt_id), + KEY idx_delivery_attempt_entry (service_name,entry_id,started_at), + KEY idx_delivery_attempt_outcome (outcome,started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V8__bind_stock_transfer_relabel_sources.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V8__bind_stock_transfer_relabel_sources.sql new file mode 100644 index 000000000..33672f620 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V8__bind_stock_transfer_relabel_sources.sql @@ -0,0 +1,14 @@ +-- Region: OVERSEAS +-- Schema: baiyue_lastleg +-- Purpose: bind_stock_transfer_relabel_sources +-- Preconditions: existing transfer allocation/binding tables and original position unique index. +-- Postconditions: exact source SKU and relabel links; active ownership remains unique, history is retained. +-- No business-row backfill, relabel completion fabrication, or inventory mutation. +ALTER TABLE ll_warehouse_transfer_inventory_allocation + ADD COLUMN sku_code VARCHAR(128) NULL COMMENT '本次来源分配的实际SKU快照' AFTER package_count, + DROP INDEX uk_ll_wh_transfer_allocation_position, + ADD KEY idx_ll_wh_transfer_allocation_position (transfer_order_id, inventory_position_id); + +ALTER TABLE ll_warehouse_transfer_relabel_binding + ADD COLUMN source_allocation_id BIGINT NULL COMMENT '已有库存转运的原始来源分配ID' AFTER remark, + ADD COLUMN relabel_item_id BIGINT NULL COMMENT '本票关联换标明细ID' AFTER source_allocation_id; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V9__add_transfer_receipt_resource_command.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V9__add_transfer_receipt_resource_command.sql new file mode 100644 index 000000000..2e0e6f4cc --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V9__add_transfer_receipt_resource_command.sql @@ -0,0 +1,30 @@ +-- Region: OVERSEAS +-- Schema: baiyue_lastleg +-- Purpose: persist original receipt commands before reserving shared target pallet resources. +-- Preconditions: target schema exists; this new table is absent. Do not replay after an unknown result. +-- Postconditions: per-source original payload and shared target reservation identity are durable. +-- No business-row backfill, old receipt repair, inventory change, or resource occupation. +CREATE TABLE ll_warehouse_transfer_receipt_command ( + id BIGINT NOT NULL COMMENT '收货命令主键', + customer_id VARCHAR(128) NOT NULL COMMENT '客户标识', + transfer_order_id BIGINT NOT NULL COMMENT '转运单标识', + source_inventory_position_id BIGINT NOT NULL COMMENT '本次收货来源库存位置', + idempotency_key VARCHAR(255) NOT NULL COMMENT '原始收货幂等键', + request_hash CHAR(64) NOT NULL COMMENT '完整原始收货请求摘要', + request_json JSON NOT NULL COMMENT '完整原始收货请求快照', + target_warehouse_id BIGINT NOT NULL COMMENT '实际目标海外收货仓', + target_pallet_id BIGINT NOT NULL COMMENT '目标实物托盘', + target_location_id BIGINT NOT NULL COMMENT '目标实际库位', + target_hash CHAR(64) NOT NULL COMMENT '共享目标资源绑定摘要', + target_json JSON NOT NULL COMMENT '权威目标仓托盘库位及目的仓快照', + reservation_key VARCHAR(128) NOT NULL COMMENT '同转运同目标托盘共享资源占用键', + resource_lock_id BIGINT NULL COMMENT '已确认有效的海外资源锁标识', + status VARCHAR(32) NOT NULL COMMENT '命令状态PREPARED准备完成RESERVED资源占用COMPLETED收货完成', + created_at DATETIME(3) NOT NULL COMMENT '命令创建时间', + updated_at DATETIME(3) NOT NULL COMMENT '命令最后更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_wh_receipt_cmd_source (transfer_order_id, source_inventory_position_id), + UNIQUE KEY uk_ll_wh_receipt_cmd_key (customer_id, idempotency_key), + KEY idx_ll_wh_receipt_cmd_target (customer_id, transfer_order_id, target_pallet_id), + CONSTRAINT ck_ll_wh_receipt_cmd_status CHECK (status IN ('PREPARED','RESERVED','COMPLETED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转运逐来源收货原请求与共享目标资源占用命令'; diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index c7ffeb09f..ceef1fc90 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -1,5 +1,5 @@ -- CN 当前纯空库初始化入口(文件名沿用原入口,不提供增量升级语义)。 --- MySQL 8.0+;只允许目标区域的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。 +-- MySQL 8.0+;只允许目标测试环境的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。 -- 字段、索引、约束及注释直接定义在 CREATE TABLE 内;不回填、不清理、不改造存量。 -- 国内 IAM 管理两区域权限;海外不访问国内库。执行方法及系统种子清单见同目录 README.md。 -- DDL 隐式提交;失败后停止,不从中间续跑,也不把部分初始化当成成功。 @@ -319,25 +319,6 @@ CREATE TABLE iam_role_permission ( KEY idx_rp_tenant (tenant_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='角色权限关联表:定义角色可访问的菜单、页面、按钮和API资源'; -CREATE TABLE iam_security_event_outbox ( - event_id varchar(64) NOT NULL COMMENT '全局事件ID,消费者幂等键', - event_type varchar(64) NOT NULL COMMENT '安全事件类型', - aggregate_type varchar(32) NOT NULL COMMENT 'USER、TENANT或AUTHORIZATION', - aggregate_id varchar(64) NOT NULL COMMENT '聚合标识', - payload_json json NOT NULL COMMENT '标准跨区事件信封,不含密码、令牌和权限全集', - status varchar(16) NOT NULL DEFAULT 'PENDING' COMMENT '发送状态:PENDING、SENDING、SENT、DEAD', - attempt_count int NOT NULL DEFAULT '0' COMMENT '发送尝试次数', - next_retry_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次重试时间', - lease_owner varchar(128) DEFAULT NULL COMMENT '租约持有者', - lease_until datetime(3) DEFAULT NULL COMMENT '租约到期时间', - last_error varchar(1000) DEFAULT NULL COMMENT '最近一次错误', - sent_at datetime(3) DEFAULT 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 (event_id), - KEY idx_security_outbox_publish (status,next_retry_at,lease_until,created_at), - KEY idx_security_outbox_aggregate (aggregate_type,aggregate_id,created_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='IAM可靠安全事件本地发送箱'; CREATE TABLE iam_security_subject_version ( auth_user_id bigint NOT NULL COMMENT '认证账号ID', @@ -3740,7 +3721,7 @@ CREATE TABLE fl_port_schedule ( CONSTRAINT fk_port_schedule_booking FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking (id), CONSTRAINT fk_port_schedule_container FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking (id), CONSTRAINT chk_port_schedule_booking_cutoff CHECK (((booking_cutoff_at is null) or (etd_at is null) or (booking_cutoff_at <= etd_at))), - CONSTRAINT chk_port_schedule_customer CHECK ((customer_id > 0)), + CONSTRAINT chk_port_schedule_customer CHECK ((customer_id >= 0)), CONSTRAINT chk_port_schedule_customs_cutoff CHECK (((customs_cutoff_at is null) or (etd_at is null) or (customs_cutoff_at <= etd_at))), CONSTRAINT chk_port_schedule_eta CHECK (((etd_at is null) or (eta_at is null) or (eta_at >= etd_at))), CONSTRAINT chk_port_schedule_expiry CHECK ((expires_at > observed_at)), @@ -4661,88 +4642,6 @@ CREATE TABLE sys_audit_log ( KEY idx_audit_request (request_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='统一业务审计日志表:追加记录敏感操作的主体、租户、资源、结果及前后快照;不同于消息Outbox和消费幂等表'; --- 服务 schema:baiyue_event_bridge -CREATE DATABASE IF NOT EXISTS baiyue_event_bridge DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -USE baiyue_event_bridge; - -CREATE TABLE bridge_delivery_attempt ( - id bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - event_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件全局标识', - source_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源区域', - target_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目标区域', - attempt_no int NOT NULL COMMENT '投递序号', - status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '投递结果', - response_code varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '对端响应码', - error_message varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '错误信息', - started_at datetime(3) NOT NULL COMMENT '开始时间', - finished_at datetime(3) DEFAULT NULL COMMENT '完成时间', - PRIMARY KEY (id), - UNIQUE KEY uk_bridge_attempt (event_id,attempt_no), - KEY idx_bridge_attempt_event (event_id,started_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='跨区域事件投递尝试记录'; - -CREATE TABLE bridge_inbox ( - event_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件全局标识', - idempotency_key varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '接收幂等键', - event_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件类型', - event_version int NOT NULL COMMENT '事件契约版本', - source_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源区域', - target_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目标区域', - customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径', - aggregate_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合类型', - aggregate_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合标识', - aggregate_version bigint NOT NULL COMMENT '聚合版本', - handover_version bigint DEFAULT NULL COMMENT '跨区交接版本', - trace_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '链路标识', - correlation_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务关联标识', - causation_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '上游事件标识', - occurred_at datetime(3) NOT NULL COMMENT '业务事实发生时间', - payload_hash varchar(71) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化负载摘要', - payload_json json NOT NULL COMMENT '事件负载', - status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态', - lease_owner varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '消费租约持有者', - lease_until datetime(3) DEFAULT NULL COMMENT '消费租约截止时间', - retry_count int NOT NULL DEFAULT '0' COMMENT '消费重试次数', - next_retry_at datetime(3) DEFAULT NULL COMMENT '下次消费时间', - last_error varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最后错误', - received_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', - processed_at datetime(3) DEFAULT NULL COMMENT '消费完成时间', - PRIMARY KEY (event_id), - UNIQUE KEY uk_bridge_inbox_idempotency (idempotency_key), - KEY idx_bridge_inbox_consume (status,next_retry_at,lease_until) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='跨区域事件已接收箱'; - -CREATE TABLE bridge_outbox ( - event_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件全局标识', - idempotency_key varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '发送幂等键', - event_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件类型', - event_version int NOT NULL COMMENT '事件契约版本', - source_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源区域', - target_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目标区域', - customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户或业务隔离标识;跨区域事件沿用来源系统口径', - aggregate_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合类型', - aggregate_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合标识', - aggregate_version bigint NOT NULL COMMENT '聚合版本', - handover_version bigint DEFAULT NULL COMMENT '跨区交接版本', - trace_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '链路标识', - correlation_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务关联标识', - causation_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '上游事件标识', - occurred_at datetime(3) NOT NULL COMMENT '业务事实发生时间', - payload_hash varchar(71) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化负载摘要', - payload_json json NOT NULL COMMENT '事件负载', - status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT '投递状态', - retry_count int NOT NULL DEFAULT '0' COMMENT '已重试次数', - next_retry_at datetime(3) DEFAULT NULL COMMENT '下次重试时间', - lease_owner varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '租约持有者', - lease_until datetime(3) DEFAULT NULL COMMENT '租约截止时间', - last_error varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最后错误', - created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - delivered_at datetime(3) DEFAULT NULL COMMENT '投递成功时间', - PRIMARY KEY (event_id), - UNIQUE KEY uk_bridge_outbox_idempotency (idempotency_key), - KEY idx_bridge_outbox_schedule (target_region,status,next_retry_at,lease_until) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='跨区域事件待投递箱'; - -- 服务 schema:baiyue_finance CREATE DATABASE IF NOT EXISTS baiyue_finance DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; USE baiyue_finance; @@ -5174,6 +5073,65 @@ CREATE TABLE fin_subject ( UNIQUE KEY uk_fin_subject_code (subject_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台财务主体;首期仅一个有效主体'; +CREATE TABLE fin_vehicle_cost ( + id BIGINT NOT NULL COMMENT '公司费用主键', + subject_id BIGINT UNSIGNED NOT NULL COMMENT '平台财务主体', + cost_no VARCHAR(64) NOT NULL COMMENT '公司车辆费用编号', + source_type VARCHAR(32) NOT NULL COMMENT '费用来源类型', + source_no VARCHAR(64) NOT NULL COMMENT '来源业务编号', + source_version BIGINT NOT NULL COMMENT '来源快照版本', + source_hash VARCHAR(71) NOT NULL COMMENT '来源内容摘要', + source_json JSON NOT NULL COMMENT '原币及折算不可变来源快照', + supplier_id BIGINT NOT NULL COMMENT '收款供应商标识', + supplier_name VARCHAR(200) NOT NULL COMMENT '供应商名称快照', + amount DECIMAL(17,2) NOT NULL COMMENT 'CNY公司费用金额', + paid_amount DECIMAL(17,2) NOT NULL DEFAULT 0 COMMENT 'CNY累计实际付款', + status VARCHAR(32) NOT NULL COMMENT '入账与付款状态', + record_version BIGINT NOT NULL COMMENT '并发版本', + created_at DATETIME(3) NOT NULL COMMENT '创建时间UTC', + updated_at DATETIME(3) NOT NULL COMMENT '更新时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_vehicle_cost_no (cost_no), + UNIQUE KEY uk_fin_vehicle_cost_source (subject_id,source_type,source_no), + KEY idx_fin_vehicle_cost_page (subject_id,updated_at,id), + CONSTRAINT fk_fin_vehicle_cost_subject FOREIGN KEY (subject_id) REFERENCES fin_subject(id), + CONSTRAINT chk_fin_vehicle_cost_amount CHECK (amount>0 AND paid_amount>=0 AND paid_amount<=amount), + CONSTRAINT chk_fin_vehicle_cost_owner CHECK (subject_id>0 AND supplier_id>0), + CONSTRAINT chk_fin_vehicle_cost_status CHECK (status IN ('PENDING','APPROVED','PARTIALLY_PAID','PAID','VOIDED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公司车辆费用,不进入客户账务'; + +CREATE TABLE fin_vehicle_cost_revision ( + id BIGINT NOT NULL COMMENT '公司车辆费用历史主键', + cost_id BIGINT NOT NULL COMMENT '所属公司费用', + record_version BIGINT NOT NULL COMMENT '费用版本', + action VARCHAR(32) NOT NULL COMMENT '接收入账作废付款动作', + request_key VARCHAR(128) NOT NULL COMMENT '动作幂等键', + request_hash VARCHAR(71) NOT NULL COMMENT '请求摘要', + operator_id BIGINT NOT NULL COMMENT '操作人或系统标识', + operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称快照', + reason VARCHAR(1000) NULL COMMENT '更正或操作说明', + snapshot_json JSON NOT NULL COMMENT '不可变费用快照', + occurred_at DATETIME(3) NOT NULL COMMENT '操作时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_vehicle_cost_revision (cost_id,record_version), + UNIQUE KEY uk_fin_vehicle_cost_request (operator_id,action,request_key), + CONSTRAINT fk_fin_vehicle_revision_cost FOREIGN KEY (cost_id) REFERENCES fin_vehicle_cost(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公司车辆费用不可变审计'; + +CREATE TABLE fin_vehicle_cost_payment ( + id BIGINT NOT NULL COMMENT '公司车辆付款主键', + cost_id BIGINT NOT NULL COMMENT '所属公司费用', + payment_no VARCHAR(64) NOT NULL COMMENT '公司付款业务编号', + amount DECIMAL(17,2) NOT NULL COMMENT 'CNY实际付款金额', + payment_json JSON NOT NULL COMMENT '实际付款及凭证不可变快照', + created_at DATETIME(3) NOT NULL COMMENT '付款登记时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_vehicle_payment_no (payment_no), + KEY idx_fin_vehicle_payment_cost (cost_id,id), + CONSTRAINT fk_fin_vehicle_payment_cost FOREIGN KEY (cost_id) REFERENCES fin_vehicle_cost(id), + CONSTRAINT chk_fin_vehicle_payment_amount CHECK (amount>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公司车辆实际付款记录'; + CREATE TABLE fin_wallet_account ( id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', customer_id bigint unsigned NOT NULL COMMENT '客户ID和隔离键', @@ -5360,7 +5318,7 @@ CREATE TRIGGER trg_carrier_schedule_node_before_update BEFORE UPDATE ON fl_carri CREATE TRIGGER trg_carrier_schedule_query_before_insert BEFORE INSERT ON fl_carrier_schedule_query FOR EACH ROW BEGIN IF NOT EXISTS (SELECT 1 FROM fl_ocean_booking booking - WHERE booking.id=NEW.ocean_booking_id AND booking.customer_id=NEW.customer_id) THEN + WHERE booking.id=NEW.ocean_booking_id AND COALESCE(booking.customer_id,0)=NEW.customer_id) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船司查询客户与订舱不一致'; END IF; IF NEW.container_booking_id IS NOT NULL AND NOT EXISTS ( @@ -5813,11 +5771,17 @@ CREATE TRIGGER trg_loading_item_before_update BEFORE UPDATE ON fl_loading_plan_i SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细修改备注时不得变更其他业务字段'; END IF; ELSEIF NOT EXISTS ( - SELECT 1 FROM fl_loading_pick_item - WHERE loading_plan_item_id = OLD.id - AND item_status = 'PARTIAL' + SELECT 1 FROM fl_loading_pick_item actual_pick + JOIN fl_loading_pick_task actual_task ON actual_task.id=actual_pick.pick_task_id + WHERE actual_pick.loading_plan_item_id = OLD.id + AND actual_task.loading_plan_id = OLD.loading_plan_id + AND actual_pick.item_status = 'PARTIAL' + AND OLD.unit_type = 'PACKAGE' AND OLD.pallet_id IS NULL + AND actual_pick.picked_package_count > 0 + AND actual_pick.picked_package_count < actual_pick.planned_package_count + AND OLD.package_count = actual_pick.planned_package_count AND NEW.deleted = 0 - AND NEW.package_count = picked_package_count + AND NEW.package_count = actual_pick.picked_package_count ) OR NEW.deleted <> OLD.deleted OR NOT (NEW.customer_name <=> OLD.customer_name) OR NOT (NEW.inbound_id <=> OLD.inbound_id) @@ -5833,13 +5797,13 @@ CREATE TRIGGER trg_loading_item_before_update BEFORE UPDATE ON fl_loading_plan_i OR NOT (NEW.product_name_en <=> OLD.product_name_en) OR NOT (NEW.product_image_file_id <=> OLD.product_image_file_id) OR NOT (NEW.product_image_url <=> OLD.product_image_url) - OR NOT (NEW.product_quantity <=> OLD.product_quantity) + OR NOT (NEW.product_quantity <=> CASE WHEN OLD.units_per_carton IS NOT NULL THEN OLD.units_per_carton * NEW.package_count ELSE ROUND(CAST(OLD.product_quantity AS DECIMAL(30,6)) * NEW.package_count / NULLIF(OLD.package_count,0),0) END) OR NOT (NEW.units_per_carton <=> OLD.units_per_carton) - OR NOT (NEW.pallet_count <=> OLD.pallet_count) - OR NOT (NEW.gross_weight_kg <=> OLD.gross_weight_kg) - OR NOT (NEW.net_weight_kg <=> OLD.net_weight_kg) - OR NOT (NEW.dimensional_weight_kg <=> OLD.dimensional_weight_kg) - OR NOT (NEW.volume_cbm <=> OLD.volume_cbm) + OR NOT (NEW.pallet_count <=> CEIL(CAST(COALESCE(OLD.pallet_count,0) AS DECIMAL(30,6)) * NEW.package_count / NULLIF(OLD.package_count,0))) + OR NOT (NEW.gross_weight_kg <=> ROUND(OLD.gross_weight_kg * NEW.package_count / NULLIF(OLD.package_count,0),3)) + OR NOT (NEW.net_weight_kg <=> ROUND(OLD.net_weight_kg * NEW.package_count / NULLIF(OLD.package_count,0),3)) + OR NOT (NEW.dimensional_weight_kg <=> ROUND(OLD.dimensional_weight_kg * NEW.package_count / NULLIF(OLD.package_count,0),3)) + OR NOT (NEW.volume_cbm <=> ROUND(OLD.volume_cbm * NEW.package_count / NULLIF(OLD.package_count,0),4)) OR NOT (NEW.fba_tracking_no <=> OLD.fba_tracking_no) OR NOT (NEW.shipment_no <=> OLD.shipment_no) OR NOT (NEW.shipping_mark <=> OLD.shipping_mark) @@ -5851,7 +5815,7 @@ CREATE TRIGGER trg_loading_item_before_update BEFORE UPDATE ON fl_loading_plan_i OR NOT (NEW.removed_by <=> OLD.removed_by) OR NOT (NEW.removed_by_name <=> OLD.removed_by_name) OR NOT (NEW.removed_at <=> OLD.removed_at) THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细仅允许收敛部分拣货实际箱数'; + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已有PDA拣货记录的装柜明细仅允许按冻结分配比例收敛实际装载'; END IF; END IF; IF OLD.frozen_at IS NOT NULL THEN @@ -6076,7 +6040,7 @@ CREATE TRIGGER trg_ocean_requirement_before_insert BEFORE INSERT ON fl_ocean_boo CREATE TRIGGER trg_ocean_requirement_before_update BEFORE UPDATE ON fl_ocean_booking_container_requirement FOR EACH ROW BEGIN IF (SELECT status FROM fl_ocean_booking WHERE id=OLD.ocean_booking_id) <> 'DRAFT' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='仅草稿可维护箱型需求'; END IF; END$$ CREATE TRIGGER trg_port_schedule_before_insert BEFORE INSERT ON fl_port_schedule FOR EACH ROW BEGIN - IF (SELECT customer_id FROM fl_ocean_booking WHERE id=NEW.ocean_booking_id) <> NEW.customer_id THEN + IF COALESCE((SELECT customer_id FROM fl_ocean_booking WHERE id=NEW.ocean_booking_id),0) <> NEW.customer_id THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='船期客户与订舱客户不一致'; END IF; IF NEW.container_booking_id IS NOT NULL AND NOT EXISTS ( @@ -6122,7 +6086,7 @@ INSERT INTO iam_membership_role (membership_id, role_id, tenant_id, created_at) -- 系统基线 iam_number_sequence:1 行。 INSERT INTO iam_number_sequence (sequence_key, next_value, updated_at) VALUES ('CUSTOMER_CODE',1,CURRENT_TIMESTAMP(3)); --- 系统基线 iam_permission:367 行。 +-- 系统基线 iam_permission:392 行(含车次审核与外接预订独立操作权限)。 INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5100000,0,'PLATFORM','MENU',0,'platform:customer','客户管理','/customers',NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5100010,0,'PLATFORM','MENU',5100000,'platform:customer:list','企业客户','/customers/list',NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5100011,0,'PLATFORM','BUTTON',5100010,'platform:customer:create','新增企业客户',NULL,NULL,10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); @@ -6168,7 +6132,6 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102012,0,'PLATFORM','BUTTON',5102010,'platform:order:reject','订单驳回',NULL,NULL,20,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102013,0,'PLATFORM','BUTTON',5102010,'platform:order:accept','订单受理',NULL,NULL,30,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102014,0,'PLATFORM','BUTTON',5102010,'platform:order:process','开始处理',NULL,NULL,40,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); -INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102015,0,'PLATFORM','BUTTON',5102010,'platform:order:complete','完成订单',NULL,NULL,50,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102016,0,'PLATFORM','BUTTON',5102010,'platform:order:cancel','取消订单',NULL,NULL,60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102017,0,'PLATFORM','BUTTON',5102010,'platform:order:note','内部备注',NULL,NULL,70,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102018,0,'PLATFORM','BUTTON',5102010,'platform:order:fulfillment','登记履约快递',NULL,NULL,80,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); @@ -6364,6 +6327,10 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107001,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:edit','编辑海外尾程订单',NULL,'PUT',701,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107002,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:last-mile-order:export','导出海外尾程订单',NULL,'GET',702,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107003,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:warehouse-transfer:approve','审核仓储转运单',NULL,'POST',703,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107004,0,'PLATFORM','BUTTON',5107000,'lastleg:cargo-discard:approve','审核货物废弃申请',NULL,'POST',704,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107004,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107005,0,'PLATFORM','BUTTON',5107000,'lastleg:warehouse-transfer:manage','执行仓储转运',NULL,'POST',705,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107005,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107010,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:inbound-order:list','海外入仓单','/overseas-warehouse/inbound-orders','GET',710,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107011,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:create','创建海外入仓计划',NULL,'POST',711,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107012,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:edit','编辑海外入仓计划',NULL,'POST',712,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); @@ -6504,7 +6471,7 @@ INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, status, description, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (4000007,0,'PLATFORM','CN_FIRSTLEG_PDA_OPERATOR','国内头程 PDA 作业员','SYSTEM','ACTIVE','负责使用国内 PDA 执行柜次拣货、现场取证和装柜完成',NULL,CURRENT_TIMESTAMP(3),NULL,CURRENT_TIMESTAMP(3),0,0); INSERT INTO iam_role (id, tenant_id, app_code, role_code, role_name, role_type, status, description, created_by, created_at, updated_by, updated_at, deleted, version) VALUES (4000100,0,'MERCHANT','MERCHANT_OWNER','企业管理员','SYSTEM','ACTIVE','一期企业全部菜单和操作按钮模板',1000001,CURRENT_TIMESTAMP(3),1000001,CURRENT_TIMESTAMP(3),0,0); --- 系统基线 iam_role_permission:545 行。 +-- 系统基线 iam_role_permission:573 行(含平台管理员车次审核与外接预订授权)。 INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100000,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100010,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5100011,0,CURRENT_TIMESTAMP(3)); @@ -6550,7 +6517,6 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102012,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102013,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102014,0,CURRENT_TIMESTAMP(3)); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102015,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102016,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102017,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102018,0,CURRENT_TIMESTAMP(3)); @@ -6801,6 +6767,88 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107212,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107213,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107214,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107300,0,'PLATFORM','MENU',0,'platform:overseas-master-data:driver:list','司机管理','/overseas-master-data/drivers','GET',930,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107301,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:create','新增司机',NULL,'POST',931,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107302,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:edit','编辑与重提司机档案',NULL,'PUT',932,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107303,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:status','启停司机',NULL,'PATCH',933,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107304,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:review','审核司机证照',NULL,'POST',934,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107305,0,'PLATFORM','BUTTON',5107300,'platform:overseas-master-data:driver:upload','上传司机证照',NULL,'POST',935,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107310,0,'PLATFORM','MENU',0,'platform:fleet:trip:list','车次管理','/overseas-master-data/trips','GET',940,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107311,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:create','创建车次',NULL,'POST',941,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107312,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:edit','修改车次计划',NULL,'PUT',942,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107350,0,'PLATFORM','MENU',0,'platform:fleet:quote:list','车队报价','/overseas-master-data/fleet-quotes','GET',980,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107350,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107351,0,'PLATFORM','BUTTON',5107350,'platform:fleet:quote:create','新增车队报价',NULL,'POST',981,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107351,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107352,0,'PLATFORM','BUTTON',5107350,'platform:fleet:quote:edit','编辑车队报价',NULL,'PUT',982,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107352,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107353,0,'PLATFORM','BUTTON',5107350,'platform:fleet:quote:status','启停车队报价',NULL,'PATCH',983,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107353,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107354,0,'PLATFORM','BUTTON',5107350,'platform:fleet:quote:delete','删除车队报价',NULL,'DELETE',984,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107354,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107355,0,'PLATFORM','BUTTON',5107350,'platform:fleet:quote:import','导入车队报价',NULL,'POST',985,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107355,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107356,0,'PLATFORM','BUTTON',5107350,'platform:fleet:quote:export','导出车队报价',NULL,'GET',986,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107356,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107360,0,'PLATFORM','MENU',0,'platform:fleet:operations:list','出车记录','/overseas-master-data/trip-operations','GET',990,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107360,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107361,0,'PLATFORM','BUTTON',5107360,'platform:fleet:operations:export','导出出车记录',NULL,'GET',991,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107361,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107362,0,'PLATFORM','BUTTON',5107360,'platform:fleet:operations:edit','更正出车实测记录',NULL,'PUT',992,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107362,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107370,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:handover','在途车辆司机交接',NULL,'POST',1000,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107370,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107380,0,'PLATFORM','API',5105000,'platform:finance:vehicle-cost:list','查看公司车辆成本','/api/platform/finance/v1/vehicle-costs','GET',1010,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107380,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107381,0,'PLATFORM','BUTTON',5107380,'platform:finance:vehicle-cost:approve','公司车辆成本入账',NULL,'POST',1011,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107381,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107382,0,'PLATFORM','BUTTON',5107380,'platform:finance:vehicle-cost:void','作废未付公司车辆成本',NULL,'POST',1012,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107382,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107383,0,'PLATFORM','BUTTON',5107380,'platform:finance:vehicle-cost:pay','登记公司车辆付款',NULL,'POST',1013,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107383,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107384,0,'PLATFORM','BUTTON',5107380,'platform:finance:vehicle-cost:upload','上传公司车辆付款凭证',NULL,'POST',1014,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107384,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107385,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:record-finance-submit','提交维修救援公司成本',NULL,'POST',1015,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107385,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107340,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:cost-create','登记车次直接成本',NULL,'POST',970,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107340,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107341,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:cost-edit','更正车次直接成本',NULL,'PUT',971,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107341,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107313,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:assign','分配车辆和司机',NULL,'POST',943,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107314,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:cancel','取消车次',NULL,'POST',944,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107310,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107311,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107312,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107313,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107314,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107315,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:complete','车次货物结案',NULL,'POST',945,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107316,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:release','结束车辆司机任务',NULL,'POST',946,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107317,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:dispatch-review','审核车次客户排车',NULL,'POST',947,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107318,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:loading-review','审核整车理货',NULL,'POST',948,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107330,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:booking-create','新增外接预订',NULL,'POST',960,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107330,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107331,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:booking-edit','修改外接预订',NULL,'PUT',961,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107331,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107332,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:booking-confirm','确认外接承运',NULL,'POST',962,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107332,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107333,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:booking-cancel','取消外接预订',NULL,'POST',963,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107333,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107317,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107318,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107315,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107316,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107320,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:record-create','新增车辆运维记录',NULL,'POST',950,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107320,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107321,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:record-edit','更正车辆运维记录',NULL,'PUT',951,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107321,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107322,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:record-upload','上传车辆运维照片',NULL,'POST',952,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107322,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107300,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107301,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107302,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107303,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107304,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107305,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107220,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107221,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107222,0,CURRENT_TIMESTAMP(3)); @@ -6831,7 +6879,6 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102012,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102013,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102014,0,CURRENT_TIMESTAMP(3)); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102015,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102016,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102017,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000003,5102018,0,CURRENT_TIMESTAMP(3)); @@ -8142,4 +8189,78 @@ CREATE TABLE fl_sailing_booking_link ( CONSTRAINT chk_sailing_booking_released CHECK (booking_status_snapshot='RELEASED') ) ENGINE=InnoDB COMMENT='船期计划与已放舱订舱的最小关联及历史'; -SELECT 'PASS' AS initialization_status, 'CN' AS region, 223 AS initialized_tables; +USE baiyue_firstleg; +CREATE TABLE event_delivery_retry_audit ( + request_id VARCHAR(36) NOT NULL COMMENT '人工重试请求UUID和幂等键', + producer_table VARCHAR(64) NOT NULL COMMENT '本服务发件箱表名', + entry_id VARCHAR(64) NOT NULL COMMENT '发件箱记录主键', + event_id VARCHAR(128) NOT NULL COMMENT '原事件全局标识', + event_type VARCHAR(64) NOT NULL COMMENT '原事件类型', + payload_hash VARCHAR(71) NOT NULL COMMENT '原事件不可变负载摘要', + operator_name VARCHAR(128) NOT NULL COMMENT '持有运维凭据的操作人标识', + reason VARCHAR(500) NOT NULL COMMENT '人工重试原因', + previous_failures INT NOT NULL COMMENT '重试前累计失败次数', + previous_error VARCHAR(1000) NULL COMMENT '重试前最后一次脱敏错误', + requested_at DATETIME(3) NOT NULL COMMENT '重试申请提交时间', + PRIMARY KEY (request_id), + KEY idx_event_retry_entry (producer_table,entry_id,requested_at), + KEY idx_event_retry_event (event_id,requested_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务事件人工重试审计,与恢复待发送状态同事务提交'; + +USE baiyue_finance; +CREATE TABLE event_delivery_retry_audit ( + request_id VARCHAR(36) NOT NULL COMMENT '人工重试请求UUID和幂等键', + producer_table VARCHAR(64) NOT NULL COMMENT '本服务发件箱表名', + entry_id VARCHAR(64) NOT NULL COMMENT '发件箱记录主键', + event_id VARCHAR(128) NOT NULL COMMENT '原事件全局标识', + event_type VARCHAR(64) NOT NULL COMMENT '原事件类型', + payload_hash VARCHAR(71) NOT NULL COMMENT '原事件不可变负载摘要', + operator_name VARCHAR(128) NOT NULL COMMENT '持有运维凭据的操作人标识', + reason VARCHAR(500) NOT NULL COMMENT '人工重试原因', + previous_failures INT NOT NULL COMMENT '重试前累计失败次数', + previous_error VARCHAR(1000) NULL COMMENT '重试前最后一次脱敏错误', + requested_at DATETIME(3) NOT NULL COMMENT '重试申请提交时间', + PRIMARY KEY (request_id), + KEY idx_event_retry_entry (producer_table,entry_id,requested_at), + KEY idx_event_retry_event (event_id,requested_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务事件人工重试审计,与恢复待发送状态同事务提交'; + +USE baiyue_firstleg; +CREATE TABLE event_delivery_attempt ( + attempt_id VARCHAR(36) NOT NULL COMMENT '本次投递租约UUID和审计主键', + service_name VARCHAR(32) NOT NULL COMMENT '本库所属生产服务', + event_type VARCHAR(96) NOT NULL COMMENT '白名单业务事件类型', + entry_id VARCHAR(128) NOT NULL COMMENT '生产者发件箱主键', + event_id VARCHAR(128) NULL COMMENT '合法原事件UUID或ULID;格式错误时为空', + target_name VARCHAR(64) NOT NULL COMMENT '固定目标业务服务', + started_at DATETIME(3) NOT NULL COMMENT '领取及审计事务提交前的开始时间', + finished_at DATETIME(3) NULL COMMENT '尝试完成或租约过期恢复时间', + duration_ms BIGINT NULL COMMENT '尝试耗时毫秒', + outcome VARCHAR(24) NOT NULL COMMENT 'STARTED/APPLIED/DUPLICATE/STALE/RETRY/DEAD/LEASE_EXPIRED', + error_code VARCHAR(64) NULL COMMENT '有限错误类别或HTTP状态;不含载荷与令牌', + next_retry_at DATETIME(3) NULL COMMENT '本次失败后下次计划投递时间', + PRIMARY KEY (attempt_id), + KEY idx_delivery_attempt_entry (service_name,entry_id,started_at), + KEY idx_delivery_attempt_outcome (outcome,started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; + +USE baiyue_finance; +CREATE TABLE event_delivery_attempt ( + attempt_id VARCHAR(36) NOT NULL COMMENT '本次投递租约UUID和审计主键', + service_name VARCHAR(32) NOT NULL COMMENT '本库所属生产服务', + event_type VARCHAR(96) NOT NULL COMMENT '白名单业务事件类型', + entry_id VARCHAR(128) NOT NULL COMMENT '生产者发件箱主键', + event_id VARCHAR(128) NULL COMMENT '合法原事件UUID或ULID;格式错误时为空', + target_name VARCHAR(64) NOT NULL COMMENT '固定目标业务服务', + started_at DATETIME(3) NOT NULL COMMENT '领取及审计事务提交前的开始时间', + finished_at DATETIME(3) NULL COMMENT '尝试完成或租约过期恢复时间', + duration_ms BIGINT NULL COMMENT '尝试耗时毫秒', + outcome VARCHAR(24) NOT NULL COMMENT 'STARTED/APPLIED/DUPLICATE/STALE/RETRY/DEAD/LEASE_EXPIRED', + error_code VARCHAR(64) NULL COMMENT '有限错误类别或HTTP状态;不含载荷与令牌', + next_retry_at DATETIME(3) NULL COMMENT '本次失败后下次计划投递时间', + PRIMARY KEY (attempt_id), + KEY idx_delivery_attempt_entry (service_name,entry_id,started_at), + KEY idx_delivery_attempt_outcome (outcome,started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; + +SELECT 'PASS' AS initialization_status, 'CN' AS region, 226 AS initialized_tables; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 176fe0178..f43d529fc 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1,93 +1,11 @@ -- OVERSEAS 当前纯空库初始化入口(文件名沿用原入口,不提供增量升级语义)。 --- MySQL 8.0+;只允许目标区域的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。 +-- MySQL 8.0+;只允许目标测试环境的服务 schema 为空,已有表立即报错;禁止 --force/遇错继续。 -- 字段、索引、约束及注释直接定义在 CREATE TABLE 内;不回填、不清理、不改造存量。 -- 国内 IAM 管理两区域权限;海外不访问国内库。执行方法及系统种子清单见同目录 README.md。 -- DDL 隐式提交;失败后停止,不从中间续跑,也不把部分初始化当成成功。 SET NAMES utf8mb4 COLLATE utf8mb4_0900_ai_ci; SET SESSION sql_mode = 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION'; --- 服务 schema:baiyue_event_bridge_overseas -CREATE DATABASE IF NOT EXISTS baiyue_event_bridge_overseas DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -USE baiyue_event_bridge_overseas; - -CREATE TABLE bridge_delivery_attempt ( - id bigint NOT NULL AUTO_INCREMENT COMMENT '主键', - event_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件全局标识', - source_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源区域', - target_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目标区域', - attempt_no int NOT NULL COMMENT '投递序号', - status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '投递结果', - response_code varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '对端响应码', - error_message varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '错误信息', - started_at datetime(3) NOT NULL COMMENT '开始时间', - finished_at datetime(3) DEFAULT NULL COMMENT '完成时间', - PRIMARY KEY (id), - UNIQUE KEY uk_bridge_attempt (event_id,attempt_no), - KEY idx_bridge_attempt_event (event_id,started_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外跨区域事件投递尝试记录'; - -CREATE TABLE bridge_inbox ( - event_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件全局标识', - idempotency_key varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '接收幂等键', - event_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件类型', - event_version int NOT NULL COMMENT '事件契约版本', - source_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源区域', - target_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目标区域', - customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户标识', - aggregate_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合类型', - aggregate_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合标识', - aggregate_version bigint NOT NULL COMMENT '聚合版本', - handover_version bigint DEFAULT NULL COMMENT '跨区交接版本', - trace_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '链路标识', - correlation_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务关联标识', - causation_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '上游事件标识', - occurred_at datetime(3) NOT NULL COMMENT '业务事实发生时间', - payload_hash varchar(71) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化负载摘要', - payload_json json NOT NULL COMMENT '事件负载', - status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态', - lease_owner varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '消费租约持有者', - lease_until datetime(3) DEFAULT NULL COMMENT '消费租约截止时间', - retry_count int NOT NULL DEFAULT '0' COMMENT '消费重试次数', - next_retry_at datetime(3) DEFAULT NULL COMMENT '下次消费时间', - last_error varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最后错误', - received_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', - processed_at datetime(3) DEFAULT NULL COMMENT '消费完成时间', - PRIMARY KEY (event_id), - UNIQUE KEY uk_bridge_inbox_idempotency (idempotency_key), - KEY idx_bridge_inbox_consume (status,next_retry_at,lease_until) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外跨区域事件已接收箱'; - -CREATE TABLE bridge_outbox ( - event_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件全局标识', - idempotency_key varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '发送幂等键', - event_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件类型', - event_version int NOT NULL COMMENT '事件契约版本', - source_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源区域', - target_region varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目标区域', - customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户标识', - aggregate_type varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合类型', - aggregate_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合标识', - aggregate_version bigint NOT NULL COMMENT '聚合版本', - handover_version bigint DEFAULT NULL COMMENT '跨区交接版本', - trace_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '链路标识', - correlation_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务关联标识', - causation_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '上游事件标识', - occurred_at datetime(3) NOT NULL COMMENT '业务事实发生时间', - payload_hash varchar(71) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化负载摘要', - payload_json json NOT NULL COMMENT '事件负载', - status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT '投递状态', - retry_count int NOT NULL DEFAULT '0' COMMENT '已重试次数', - next_retry_at datetime(3) DEFAULT NULL COMMENT '下次重试时间', - lease_owner varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '租约持有者', - lease_until datetime(3) DEFAULT NULL COMMENT '租约截止时间', - last_error varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最后错误', - created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - delivered_at datetime(3) DEFAULT NULL COMMENT '投递成功时间', - PRIMARY KEY (event_id), - UNIQUE KEY uk_bridge_outbox_idempotency (idempotency_key), - KEY idx_bridge_outbox_schedule (target_region,status,next_retry_at,lease_until) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外跨区域事件待投递箱'; - -- 服务 schema:baiyue_lastleg CREATE DATABASE IF NOT EXISTS baiyue_lastleg DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE baiyue_lastleg; @@ -345,7 +263,7 @@ CREATE TABLE ll_delivery_order ( seller_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '卖家姓名', package_count int NOT NULL DEFAULT '0' COMMENT '派送箱数', pallet_count int NOT NULL DEFAULT '0' COMMENT '派送托盘数', - total_weight_kg decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '总重量(千克)', + total_weight_kg DECIMAL(18,4) NULL COMMENT '总重量(千克),任一来源未确认时为空', total_volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '总体积(立方米)', planned_delivery_at datetime(3) DEFAULT NULL COMMENT '预计派送时间', dispatched_at datetime(3) DEFAULT NULL COMMENT '实际派送时间', @@ -357,6 +275,9 @@ CREATE TABLE ll_delivery_order ( version bigint NOT NULL DEFAULT '0' 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 '更新时间', + signed_packages INT NULL COMMENT '保存签收资料时冻结的实际签收箱数,不含此前已退回量', + CONSTRAINT chk_delivery_signed_packages CHECK(signed_packages IS NULL OR (signed_packages>=0 AND signed_packages<=package_count)), + PRIMARY KEY (id), UNIQUE KEY uk_ll_delivery_no (customer_id,delivery_no), UNIQUE KEY uk_ll_delivery_idempotency (customer_id,idempotency_key), @@ -447,7 +368,7 @@ CREATE TABLE ll_dispatch_item ( contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系人快照', contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系电话快照', package_count int NOT NULL DEFAULT '0' COMMENT '箱数', - gross_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '毛重(千克)', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克),未知为NULL,不以材积重代替', volume_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '材积重(千克)', volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '体积(立方米)', source_stage varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '数据来源阶段', @@ -455,6 +376,8 @@ CREATE TABLE ll_dispatch_item ( source_business_type varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源业务类型', source_business_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源业务单号', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源', + PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,plan_pallet_id,warehouse_pallet_id), KEY idx_ll_dispatch_item_order (customer_id,order_id), @@ -512,6 +435,8 @@ CREATE TABLE ll_dispatch_pallet_lock ( released_at datetime(3) DEFAULT 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 '更新时间', + return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源', + PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_active_pallet (customer_id,active_key), KEY idx_ll_dispatch_pallet_lock_order (customer_id,dispatch_order_id,status), @@ -549,15 +474,44 @@ CREATE TABLE ll_dispatch_pda_pick ( KEY idx_ll_dispatch_pda_pick_item (customer_id,task_id,dispatch_item_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='派车PDA拣货记录'; +CREATE TABLE ll_return_stock_receipt ( + id BIGINT NOT NULL COMMENT '退回入仓实收凭据ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户数据归属', + receipt_no VARCHAR(64) NOT NULL COMMENT '退回实收业务编号', + inventory_ledger_id BIGINT NOT NULL COMMENT '唯一实际退回入账流水', + exception_order_id BIGINT NOT NULL COMMENT '退回异常订单', + order_id BIGINT NOT NULL COMMENT '原尾程订单', + inbound_order_id BIGINT NOT NULL COMMENT '原入仓单', + source_outbound_no VARCHAR(64) NOT NULL COMMENT '原发运出库业务编号', + source_outbound_pallet_item_id BIGINT NOT NULL COMMENT '原出库托盘明细', + source_inventory_position_id BIGINT NOT NULL COMMENT '原拣货库存位置', + inventory_balance_id BIGINT NOT NULL COMMENT '实际退回库存余额', + inventory_position_id BIGINT NOT NULL COMMENT '实际退回库存位置', + received_packages BIGINT NOT NULL COMMENT '实际退回箱数,不增加原订单箱数', + received_at DATETIME(3) NOT NULL COMMENT '实际退回入账时间', + created_at DATETIME(3) NOT NULL COMMENT '凭据记录时间', + PRIMARY KEY(id), + UNIQUE KEY uk_return_stock_receipt_no(customer_id,receipt_no), + UNIQUE KEY uk_return_stock_receipt_ledger(customer_id,inventory_ledger_id), + KEY idx_return_stock_receipt_order(customer_id,order_id), + KEY idx_return_stock_receipt_position(customer_id,inventory_balance_id,inventory_position_id), + CONSTRAINT chk_return_stock_receipt_packages CHECK(received_packages>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='真实退回入仓及再次发运来源凭据'; + CREATE TABLE ll_dispatch_pda_pick_order ( id bigint NOT NULL COMMENT '主键ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', pick_id bigint NOT NULL COMMENT 'PDA拣货记录ID', - receipt_id bigint NOT NULL COMMENT '入仓实收记录ID', + receipt_id BIGINT NULL COMMENT '入仓实收记录ID', order_id bigint NOT NULL COMMENT '订单ID', packages bigint NOT NULL COMMENT '本次操作箱数', product_quantity bigint NOT NULL DEFAULT '0' COMMENT '产品数量', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源', + KEY idx_dispatch_pick_return_receipt(customer_id,return_receipt_id), + CONSTRAINT chk_dispatch_pick_receipt_source CHECK((receipt_id IS NOT NULL AND return_receipt_id IS NULL) OR (receipt_id IS NULL AND return_receipt_id IS NOT NULL)), + + PRIMARY KEY (id), KEY idx_ll_dispatch_pick_order_pick (customer_id,pick_id), KEY idx_ll_dispatch_pick_order_receipt (customer_id,receipt_id) @@ -766,9 +720,11 @@ CREATE TABLE ll_exception_order ( version bigint NOT NULL DEFAULT '0' 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 '更新时间', + return_context_json JSON NULL COMMENT '退回异常创建前的派送及尾程状态快照', + PRIMARY KEY (id), UNIQUE KEY uk_ll_exception_no (customer_id,exception_no), - UNIQUE KEY uk_ll_exception_delivery_return (customer_id,delivery_order_id,exception_type), + KEY idx_ll_exception_delivery_return(customer_id,delivery_order_id,exception_type), KEY idx_ll_exception_list (customer_id,status,exception_at), KEY idx_ll_exception_tail (customer_id,last_mile_order_no), KEY idx_ll_exception_inbound (customer_id,inbound_code) @@ -799,6 +755,9 @@ CREATE TABLE ll_exception_return_item ( max_return_packages bigint NOT NULL COMMENT '原出库可退箱数上限', approved_return_packages bigint NOT NULL COMMENT '批准退回箱数', created_at datetime(3) NOT NULL COMMENT '创建时间', + received_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已实际退回入仓的累计箱数', + CONSTRAINT chk_exception_return_received CHECK(received_packages>=0 AND received_packages<=approved_return_packages), + PRIMARY KEY (id), UNIQUE KEY uk_ll_exception_return_source (customer_id,exception_order_id,outbound_pallet_item_id,inventory_position_id), KEY idx_ll_exception_return_order (customer_id,exception_order_id) @@ -1679,9 +1638,10 @@ CREATE TABLE ll_outbound_order ( status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'WAIT_OUTBOUND' COMMENT '业务状态', package_count int NOT NULL DEFAULT '0' COMMENT '箱数', pallet_count int NOT NULL DEFAULT '0' COMMENT '托盘数', - gross_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '毛重(千克)', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克),来源未确认时为空', volume_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '材积重(千克)', volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '体积(立方米)', + fee_coverage_json JSON NULL COMMENT '本次发运引用的原费用版本、实际货物及明确服务覆盖确认快照', idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键', created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', @@ -1689,7 +1649,7 @@ CREATE TABLE ll_outbound_order ( outbound_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'DISPATCH_DELIVERY' COMMENT '出库类型:派送转出库、一件代发', lx_codes varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '关联一件代发领星入库单代码汇总', recipient_info varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '一件代发收件信息', - request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '一件代发创建请求摘要,其他出库类型为空', + request_hash char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建或转派送业务请求摘要,用于幂等内容校验', PRIMARY KEY (id), UNIQUE KEY uk_ll_outbound_no (outbound_no), UNIQUE KEY uk_ll_outbound_idempotency (customer_id,idempotency_key), @@ -1707,7 +1667,7 @@ CREATE TABLE ll_outbound_pallet_item ( destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码快照', destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称快照', package_count int NOT NULL DEFAULT '0' COMMENT '箱数', - gross_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '毛重(千克)', + gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克),来源未确认时为空', volume_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '材积重(千克)', volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '体积(立方米)', picked_package_count int NOT NULL DEFAULT '0' COMMENT '已拣箱数', @@ -2340,6 +2300,34 @@ CREATE TABLE ll_relabel_status_history ( KEY idx_ll_relabel_status_history_application (customer_id,application_id,created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='换标状态变更历史'; +-- 退货出库按SKU固化扣减前后快照,与PDA拣货同事务提交;不修改历史箱内容。 +CREATE TABLE ll_return_inventory_outbound_movement ( + id BIGINT NOT NULL COMMENT '退货出库SKU流水ID', + outbound_pick_id BIGINT NOT NULL COMMENT '同事务PDA出库拣货记录ID', + customer_id VARCHAR(128) NOT NULL COMMENT '库存所属客户ID和隔离键', + overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '实际出库海外仓ID', + carton_id BIGINT NOT NULL COMMENT '真实退货库存箱ID', + carton_mark VARCHAR(128) NOT NULL COMMENT '冻结的真实退货箱业务码', + outbound_no VARCHAR(64) NOT NULL COMMENT '本次退货出库业务单号', + sku_code VARCHAR(128) NOT NULL COMMENT '本次出库SKU编码快照', + product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照', + quantity BIGINT NOT NULL COMMENT '本箱该SKU实际出库件数,始终为正', + total_before BIGINT NOT NULL COMMENT '客户仓SKU出库前实物总数量', + total_after BIGINT NOT NULL COMMENT '客户仓SKU出库后实物总数量', + available_before BIGINT NOT NULL COMMENT '客户仓SKU出库前可用数量', + available_after BIGINT NOT NULL COMMENT '客户仓SKU出库后可用数量', + cartons_before BIGINT NOT NULL COMMENT '客户仓包含该SKU的出库前可用箱数', + cartons_after BIGINT NOT NULL COMMENT '客户仓包含该SKU的出库后可用箱数', + occurred_at DATETIME(3) NOT NULL COMMENT '出库记账时间', + PRIMARY KEY(id), + UNIQUE KEY uk_ll_return_outbound_pick_sku(outbound_pick_id,sku_code), + UNIQUE KEY uk_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code), + KEY idx_ll_return_outbound_movement_query(customer_id,overseas_warehouse_id,sku_code,occurred_at), + CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 + AND total_before-total_after=quantity AND available_before-available_after=quantity + AND cartons_after>=0 AND cartons_before-cartons_after=1 AND total_after>=available_after) +) ENGINE=InnoDB COMMENT='不可变退货出库SKU库存流水'; + CREATE TABLE ll_return_inventory_balance ( id bigint NOT NULL COMMENT '退货库存余额ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', @@ -2523,36 +2511,7 @@ CREATE TABLE ll_return_receipt_item ( CONSTRAINT chk_ll_return_receipt_item_quantity CHECK ((received_quantity > 0)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PDA退货签收SKU实际数量明细'; -CREATE TABLE ll_security_event_inbox ( - event_id varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '跨区安全事件ID', - event_type varchar(96) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'IAM安全事件类型', - payload_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化载荷SHA-256哈希', - payload_json json NOT NULL COMMENT '冻结事件载荷JSON', - status varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '消费状态:RECEIVED或PROCESSED', - result varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '处理结果:APPLIED、STALE、DUPLICATE或DUPLICATE_VERSION', - received_at datetime(3) NOT NULL COMMENT '接收时间', - processed_at datetime(3) DEFAULT NULL COMMENT '处理完成时间', - PRIMARY KEY (event_id), - KEY idx_security_inbox_type_time (event_type,received_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外IAM安全事件消费者收件箱'; -CREATE TABLE ll_security_projection ( - aggregate_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合类型:USER、TENANT或AUTHORIZATION', - aggregate_id varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户或客户聚合ID', - tenant_id bigint NOT NULL COMMENT '平台0或客户ID', - security_status varchar(24) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '当前安全状态:ACTIVE或DISABLED', - permission_version bigint DEFAULT NULL COMMENT '当前单调权限版本', - roles_json json NOT NULL COMMENT 'IAM投影的当前角色快照', - authorities_json json NOT NULL COMMENT 'IAM投影的当前海外权限快照', - aggregate_version bigint NOT NULL COMMENT '事件聚合单调版本', - payload_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '当前版本载荷哈希', - last_event_id varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '最后应用的事件ID', - changed_at datetime(3) NOT NULL COMMENT '国内IAM事实发生时间', - updated_at datetime(3) NOT NULL COMMENT '投影更新时间', - PRIMARY KEY (aggregate_type,aggregate_id), - KEY idx_security_projection_tenant (tenant_id), - KEY idx_security_projection_permission (aggregate_id,permission_version) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外IAM安全状态与权限版本投影'; CREATE TABLE ll_us_truck_order ( id bigint NOT NULL COMMENT '主键ID', @@ -2639,7 +2598,65 @@ CREATE TABLE ll_us_truck_order_status_history ( KEY idx_ll_us_truck_history (customer_id,order_id,created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='美国卡车订单状态历史'; +CREATE TABLE ll_fleet_driver ( + id BIGINT NOT NULL COMMENT '内部主键', + driver_no VARCHAR(32) NOT NULL COMMENT '司机业务编号', + warehouse_id BIGINT NULL COMMENT '所属公共仓库引用', + warehouse_code VARCHAR(64) NULL COMMENT '所属仓库编码快照', + warehouse_name VARCHAR(256) NULL COMMENT '所属仓库名称快照', + vehicle_models VARCHAR(128) NULL COMMENT '可驾驶车型描述', + languages_and_area VARCHAR(256) NULL COMMENT '语言及服务区域', + medical_expires_on DATE NULL COMMENT '体检到期日,当前不作为必需资质', + training_expires_on DATE NULL COMMENT '培训安全到期日,当前不作为必需资质', + insurance_expires_on DATE NULL COMMENT '保险到期日,当前不作为必需资质', + name VARCHAR(128) NOT NULL COMMENT '姓名', + phone VARCHAR(64) NOT NULL COMMENT '联系电话', + ownership VARCHAR(16) NOT NULL COMMENT '归属 OWNED自有 DISPATCHED外派', + region VARCHAR(16) NOT NULL COMMENT '区域 DOMESTIC国内 OVERSEAS海外', + active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + review_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '证照审核 PENDING待审 APPROVED通过 REJECTED打回', + license_number VARCHAR(128) NULL COMMENT '驾驶证编号', + license_type VARCHAR(64) NULL COMMENT '驾驶证类型', + license_issued_on DATE NULL COMMENT '驾驶证签发日期', + license_expires_on DATE NULL COMMENT '驾驶证有效截止日', + license_zone VARCHAR(64) NOT NULL COMMENT '证照有效日期的IANA时区', + license_file_id BIGINT NULL COMMENT '平台驾驶证文件引用', + remark VARCHAR(1000) NULL COMMENT '备注', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '并发版本', + created_by VARCHAR(64) NOT NULL COMMENT '创建人', + created_at DATETIME(3) NOT NULL COMMENT '创建时间UTC', + updated_by VARCHAR(64) NOT NULL COMMENT '修改人', + updated_at DATETIME(3) NOT NULL COMMENT '修改时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_driver_no (driver_no), + KEY idx_fleet_driver_page (active,review_status,updated_at,id), + KEY idx_fleet_driver_expiry (license_expires_on), + CONSTRAINT chk_fleet_driver_ownership CHECK (ownership IN ('OWNED','DISPATCHED')), + CONSTRAINT chk_fleet_driver_region CHECK (region IN ('DOMESTIC','OVERSEAS')), + CONSTRAINT chk_fleet_driver_review CHECK (review_status IN ('PENDING','APPROVED','REJECTED')), + CONSTRAINT chk_fleet_driver_dates CHECK (license_issued_on IS NULL OR license_expires_on IS NULL OR license_issued_on <= license_expires_on) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台司机档案'; + +CREATE TABLE ll_fleet_driver_history ( + id BIGINT NOT NULL COMMENT '内部主键', + driver_id BIGINT NOT NULL COMMENT '司机内部引用', + record_version BIGINT NOT NULL COMMENT '操作后的档案版本', + action VARCHAR(32) NOT NULL COMMENT '创建编辑启停证照上传及审核动作', + reason VARCHAR(1000) NULL COMMENT '操作原因或审核意见', + license_file_id BIGINT NULL COMMENT '当时驾驶证文件引用', + snapshot_json JSON NOT NULL COMMENT '操作后不可变档案快照', + operator_id VARCHAR(64) NOT NULL COMMENT '操作人', + occurred_at DATETIME(3) NOT NULL COMMENT '操作时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_driver_history_version (driver_id,record_version), + KEY idx_fleet_driver_history_file (driver_id,license_file_id), + CONSTRAINT fk_fleet_driver_history_driver FOREIGN KEY (driver_id) REFERENCES ll_fleet_driver(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='司机档案及审核历史'; + CREATE TABLE ll_vehicle ( + max_pallet_spaces INT NULL COMMENT '最大标准托位,未知时为空', + max_load_kg DECIMAL(12,3) NULL COMMENT '最大载重,千克', + max_volume_cbm DECIMAL(12,3) NULL COMMENT '可用容积,立方米', id bigint NOT NULL COMMENT '车辆档案ID', plate_number varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '车牌号', model varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '车型', @@ -2658,9 +2675,314 @@ CREATE TABLE ll_vehicle ( updated_at datetime(3) NOT NULL COMMENT '更新时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_vehicle_plate (plate_number), - KEY idx_ll_vehicle_page (ownership,region,status,updated_at,id) + KEY idx_ll_vehicle_page (ownership,region,status,updated_at,id), + CONSTRAINT chk_ll_vehicle_capacity CHECK ((max_pallet_spaces IS NULL AND max_load_kg IS NULL AND max_volume_cbm IS NULL) + OR (max_pallet_spaces IS NOT NULL AND max_load_kg IS NOT NULL AND max_volume_cbm IS NOT NULL + AND max_pallet_spaces BETWEEN 1 AND 10000 AND max_load_kg > 0 AND max_volume_cbm > 0)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台车辆主数据'; +CREATE TABLE ll_fleet_vehicle_record ( + id BIGINT NOT NULL COMMENT '记录内部主键', + record_no VARCHAR(32) NOT NULL COMMENT '车辆运维业务编号', + vehicle_id BIGINT NOT NULL COMMENT '所属车辆主键', + vehicle_plate VARCHAR(64) NOT NULL COMMENT '建档时车牌快照', + record_type VARCHAR(32) NOT NULL COMMENT '运维类型:保养、加油、维修、故障、救援、异常', + occurred_at DATETIME(3) NOT NULL COMMENT '记录发生时间(UTC)', + details_json JSON NOT NULL COMMENT '带明确单位的业务字段快照', + files_json JSON NOT NULL COMMENT '所属记录照片与上传幂等摘要', + idempotency_key VARCHAR(128) NOT NULL COMMENT '创建请求幂等键', + request_hash CHAR(64) NOT NULL COMMENT '原创建业务内容摘要', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', + created_by VARCHAR(64) NOT NULL COMMENT '创建操作人', + updated_by VARCHAR(64) NOT NULL COMMENT '最后操作人', + created_at DATETIME(3) NOT NULL COMMENT '创建时间(UTC)', + updated_at DATETIME(3) NOT NULL COMMENT '更新时间(UTC)', + PRIMARY KEY (id), + UNIQUE KEY uk_vehicle_record_no (record_no), + UNIQUE KEY uk_vehicle_record_creation (vehicle_id, created_by, idempotency_key), + KEY idx_vehicle_record_page (vehicle_id, record_type, occurred_at, id), + CONSTRAINT fk_vehicle_record_vehicle FOREIGN KEY (vehicle_id) REFERENCES ll_vehicle(id), + CONSTRAINT ck_vehicle_record_type CHECK (record_type IN ('MAINTENANCE','FUEL','REPAIR','BREAKDOWN','RESCUE','EXCEPTION')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆运维记录'; + +CREATE TABLE ll_fleet_vehicle_record_history ( + id BIGINT NOT NULL COMMENT '历史内部主键', + record_id BIGINT NOT NULL COMMENT '运维记录主键', + record_version BIGINT NOT NULL COMMENT '对应业务版本', + action VARCHAR(32) NOT NULL COMMENT '操作类型', + reason VARCHAR(1000) DEFAULT NULL COMMENT '更正原因或附件名称', + operator_id VARCHAR(64) NOT NULL COMMENT '操作人', + occurred_at DATETIME(3) NOT NULL COMMENT '操作时间(UTC)', + snapshot_json JSON NOT NULL COMMENT '当时完整业务快照', + PRIMARY KEY (id), + UNIQUE KEY uk_vehicle_record_revision (record_id, record_version), + CONSTRAINT fk_vehicle_record_history FOREIGN KEY (record_id) REFERENCES ll_fleet_vehicle_record(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆运维记录不可变历史'; + +CREATE TABLE ll_fleet_trip ( + id BIGINT NOT NULL COMMENT '内部主键', + trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号', + idempotency_key VARCHAR(128) NOT NULL COMMENT '创建请求幂等键', + request_hash VARCHAR(64) NOT NULL COMMENT '创建请求SHA256', + status VARCHAR(16) NOT NULL COMMENT '车次状态', + warehouse_id BIGINT NOT NULL COMMENT '起运公共仓库引用', + warehouse_code VARCHAR(64) NOT NULL COMMENT '起运仓编码快照', + warehouse_name VARCHAR(256) NOT NULL COMMENT '起运仓名称快照', + vehicle_id BIGINT NULL COMMENT '车辆档案引用', + vehicle_plate VARCHAR(64) NULL COMMENT '分配时车牌快照', + driver_id BIGINT NULL COMMENT '司机档案引用', + driver_name VARCHAR(128) NULL COMMENT '分配时司机姓名', + driver_phone VARCHAR(64) NULL COMMENT '分配时司机电话', + max_pallet_spaces INT NULL COMMENT '分配时托位容量快照', + max_load_kg DECIMAL(12,3) NULL COMMENT '分配时载重千克', + max_volume_cbm DECIMAL(12,3) NULL COMMENT '分配时可用容积立方米', + planned_departure_at DATETIME(3) NOT NULL COMMENT '计划发车UTC', + expected_driver_end_at DATETIME(3) NOT NULL COMMENT '司机预计任务结束UTC,含承担的返程转场', + departed_at DATETIME(3) NULL COMMENT '实际发车UTC', + completed_at DATETIME(3) NULL COMMENT '实际运输完成UTC', + stops_json JSON NOT NULL COMMENT '按顺序保存的路线站点', + planned_load_json JSON NULL COMMENT '拣货前自有货计划载荷与逐段剩余容量', + load_json JSON NULL COMMENT '已确认实物装载及客户货物来源快照', + loading_review_json LONGTEXT NULL COMMENT '整车理货审核快照,空值为待审核', + departure_key VARCHAR(128) NULL COMMENT '整车发车幂等标识', + departure_hash CHAR(64) NULL COMMENT '整车发车请求摘要', + departure_result_json JSON NULL COMMENT '整车发车结果及各客户出库结果', + completion_json JSON NULL COMMENT '货物结案来源及幂等结果快照', + vehicle_release_json JSON NULL COMMENT '车辆任务结束地点状态与幂等记录', + driver_release_json JSON NULL COMMENT '司机任务结束地点与幂等记录', + remark VARCHAR(1000) NULL COMMENT '备注', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '并发版本', + created_by VARCHAR(64) NOT NULL COMMENT '创建人', + created_at DATETIME(3) NOT NULL COMMENT '创建UTC时间', + updated_by VARCHAR(64) NOT NULL COMMENT '更新人', + updated_at DATETIME(3) NOT NULL COMMENT '更新UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_trip_no (trip_no), + UNIQUE KEY uk_fleet_trip_create (created_by,idempotency_key), + KEY idx_fleet_trip_page (warehouse_id,status,updated_at,id), + CONSTRAINT fk_fleet_trip_vehicle FOREIGN KEY (vehicle_id) REFERENCES ll_vehicle(id), + CONSTRAINT fk_fleet_trip_driver FOREIGN KEY (driver_id) REFERENCES ll_fleet_driver(id), + CONSTRAINT chk_fleet_trip_status CHECK (status IN ('PLANNED','DEPARTING','IN_TRANSIT','COMPLETED','CANCELLED')), + CONSTRAINT chk_fleet_trip_time CHECK (expected_driver_end_at > planned_departure_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台运输车次'; + +CREATE TABLE ll_fleet_external_booking ( + id BIGINT NOT NULL COMMENT '内部主键', + booking_no VARCHAR(32) NOT NULL COMMENT '外接预订业务编号', + trip_id BIGINT NOT NULL COMMENT '所属车次', + trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号', + customer_id VARCHAR(128) NOT NULL COMMENT '委托客户及付款归属', + customer_code VARCHAR(64) NOT NULL COMMENT '客户编号快照', + customer_name VARCHAR(255) NOT NULL COMMENT '客户名称快照', + status VARCHAR(16) NOT NULL COMMENT 'DRAFT询价/CONFIRMED占位/RECEIVED确认实收/BOUND关联发运/CANCELLED取消', + cargo_json JSON NOT NULL COMMENT '货物、计划载荷、联系人及约定报价快照', + quote_source_json JSON NULL COMMENT '选用销售报价的可信版本快照,与实际成交金额分开保存', + destination_key VARCHAR(160) NOT NULL COMMENT '已有路线卸货站匹配键', + destination_label VARCHAR(255) NOT NULL COMMENT '约定卸货站名称', + agreed_amount DECIMAL(16,2) NULL COMMENT '约定金额,非已入账应收或实收', + idempotency_key VARCHAR(128) NOT NULL COMMENT '登记请求键', + request_hash CHAR(64) NOT NULL COMMENT '登记内容摘要', + confirm_key VARCHAR(128) NULL COMMENT '承运确认请求键', + confirm_hash CHAR(64) NULL COMMENT '承运确认摘要', + cancel_key VARCHAR(128) NULL COMMENT '取消请求键', + cancel_hash CHAR(64) NULL COMMENT '取消摘要', + incoming_no VARCHAR(64) NULL COMMENT '正式来货单业务编号', + dispatch_no VARCHAR(64) NULL COMMENT '已关联客户排车业务编号', + receipt_json JSON NULL COMMENT '实测来源及运营确认的托位和运费依据', + incoming_key VARCHAR(128) NULL COMMENT '来货登记请求键', + incoming_hash CHAR(64) NULL COMMENT '来货登记摘要', + receipt_key VARCHAR(128) NULL COMMENT '实收确认请求键', + receipt_hash CHAR(64) NULL COMMENT '实收确认摘要', + binding_key VARCHAR(128) NULL COMMENT '关联发运请求键', + binding_hash CHAR(64) NULL COMMENT '关联发运摘要', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '记录版本', + created_by VARCHAR(64) NOT NULL COMMENT '登记人', + created_at DATETIME(3) NOT NULL COMMENT '登记UTC时间', + updated_by VARCHAR(64) NOT NULL COMMENT '修改人', + updated_at DATETIME(3) NOT NULL COMMENT '修改UTC时间', + PRIMARY KEY(id), + UNIQUE KEY uk_fleet_booking_no(booking_no), + UNIQUE KEY uk_fleet_booking_create(trip_id,created_by,idempotency_key), + KEY idx_fleet_booking_trip(trip_id,status,id), + UNIQUE KEY uk_fleet_booking_incoming(incoming_no), + KEY idx_fleet_booking_dispatch(dispatch_no), + CONSTRAINT fk_fleet_booking_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id), + CONSTRAINT chk_fleet_booking_status CHECK(status IN ('DRAFT','CONFIRMED','RECEIVED','BOUND','CANCELLED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='外接询价及确认承运占位;货物收发和费用入账分别引用正式事实'; + +CREATE TABLE ll_fleet_resource_assignment ( + resource_kind VARCHAR(16) NOT NULL COMMENT '资源种类 VEHICLE或DRIVER', + resource_id BIGINT NOT NULL COMMENT '对应车辆或司机档案引用', + trip_id BIGINT NOT NULL COMMENT '占用车次', + trip_no VARCHAR(32) NOT NULL COMMENT '占用车次业务编号', + PRIMARY KEY (resource_kind,resource_id), + KEY idx_fleet_resource_trip (trip_id), + CONSTRAINT fk_fleet_resource_trip FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id), + CONSTRAINT chk_fleet_resource_kind CHECK (resource_kind IN ('VEHICLE','DRIVER')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机当前车次占用,释放后删除锁行,历史保留在车次'; + +CREATE TABLE ll_fleet_trip_dispatch ( + dispatch_id BIGINT NOT NULL COMMENT '客户排车单内部引用,唯一有效车次关联', + dispatch_no VARCHAR(64) NOT NULL COMMENT '客户排车业务单号', + customer_id VARCHAR(128) NOT NULL COMMENT '排车单自身客户归属', + trip_id BIGINT NOT NULL COMMENT '共享运输车次内部引用', + trip_no VARCHAR(32) NOT NULL COMMENT '共享运输车次业务编号', + package_count INT NOT NULL COMMENT '关联时待发运箱数快照', + bound_by VARCHAR(64) NOT NULL COMMENT '关联操作人', + bound_at DATETIME(3) NOT NULL COMMENT '关联UTC时间', + review_json LONGTEXT NULL COMMENT '客户排车调度审核快照,空值为待审核', + PRIMARY KEY (dispatch_id), + UNIQUE KEY uk_fleet_trip_dispatch_no (dispatch_no), + KEY idx_fleet_trip_dispatch_trip (trip_id,dispatch_id), + CONSTRAINT fk_fleet_trip_dispatch_trip FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次与各客户排车单的有效关联,解除前快照保留在车次历史'; + +CREATE TABLE ll_fleet_trip_operations_record ( + id BIGINT NOT NULL COMMENT '出车实测历史主键', + trip_no VARCHAR(64) NOT NULL COMMENT '车次业务编号', + record_version BIGINT NOT NULL COMMENT '实测记录版本从一递增', + details_json JSON NOT NULL COMMENT '实际里程英里油耗升与备注快照', + reason VARCHAR(1000) NOT NULL COMMENT '实测更正说明', + operator_id VARCHAR(64) NOT NULL COMMENT '实测操作人标识', + request_key VARCHAR(128) NOT NULL COMMENT '实测保存幂等键', + request_hash CHAR(64) NOT NULL COMMENT '实测请求内容摘要', + occurred_at DATETIME(3) NOT NULL COMMENT '实测保存时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_operations_version (trip_no,record_version), + UNIQUE KEY uk_fleet_operations_request (trip_no,operator_id,request_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次里程油耗不可变更正记录'; + +CREATE TABLE ll_fleet_trip_handover ( + id BIGINT NOT NULL COMMENT '内部主键', + trip_id BIGINT NOT NULL COMMENT '所属车次', + trip_version BIGINT NOT NULL COMMENT '交接对应车次历史版本', + trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号', + handover_no VARCHAR(32) NOT NULL COMMENT '交接业务编号', + request_key VARCHAR(128) NOT NULL COMMENT '交接请求幂等键', + request_hash CHAR(64) NOT NULL COMMENT '交接请求摘要', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '资源结束并发版本', + handed_over_at DATETIME(3) NOT NULL COMMENT '实际交接UTC时间', + snapshot_json JSON NOT NULL COMMENT '不可变资源及剩余货物交接快照', + vehicle_release_json JSON NULL COMMENT '原车辆任务结束及幂等结果', + driver_release_json JSON NULL COMMENT '原司机任务结束及幂等结果', + created_by VARCHAR(64) NOT NULL COMMENT '记录操作人', + created_at DATETIME(3) NOT NULL COMMENT '记录UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_handover_no (handover_no), + UNIQUE KEY uk_fleet_handover_request (trip_id, request_key), + UNIQUE KEY uk_fleet_handover_version (trip_id, trip_version), + KEY idx_fleet_handover_page (trip_id, id), + CONSTRAINT fk_fleet_handover_trip FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='在途车辆司机交接'; + +CREATE TABLE ll_fleet_finance_source ( + id BIGINT UNSIGNED NOT NULL COMMENT '来源提交主键', + record_id BIGINT UNSIGNED NOT NULL COMMENT '车辆运维记录主键', + source_version BIGINT UNSIGNED NOT NULL COMMENT '独立财务来源版本,从1递增', + record_version BIGINT UNSIGNED NOT NULL COMMENT '提交时运维记录版本', + operator_id VARCHAR(64) NOT NULL COMMENT '提交操作人标识', + request_key VARCHAR(128) NOT NULL COMMENT '原请求幂等键', + request_hash VARCHAR(71) NOT NULL COMMENT '规范请求摘要,含sha256前缀', + event_id VARCHAR(128) NOT NULL COMMENT '关联公共Outbox事件ID', + payload_json JSON NOT NULL COMMENT '不可变公司成本来源快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '提交时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_finance_source_version (record_id,source_version), + UNIQUE KEY uk_fleet_finance_source_request (record_id,operator_id,request_key), + UNIQUE KEY uk_fleet_finance_source_event (event_id), + CONSTRAINT chk_fleet_finance_source_version CHECK (source_version>0) +) ENGINE=InnoDB COMMENT='维修救援公司成本提交来源,不保存付款推定'; + +CREATE TABLE ll_fleet_quote ( + id BIGINT PRIMARY KEY COMMENT '报价记录主键', + quote_no VARCHAR(64) NOT NULL COMMENT '报价业务编号', + kind VARCHAR(16) NOT NULL COMMENT '报价用途采购或销售', + party_id BIGINT NOT NULL COMMENT '供应商或客户主档标识', + party_name VARCHAR(160) NOT NULL COMMENT '往来单位名称快照', + origin_city VARCHAR(128) NOT NULL COMMENT '发货城市', + destination_code VARCHAR(64) NOT NULL COMMENT '目的仓业务编码', + valid_from DATE NOT NULL COMMENT '报价生效日期', + valid_to DATE NOT NULL COMMENT '报价失效日期含当天', + status VARCHAR(16) NOT NULL COMMENT '报价启停或删除状态', + natural_key CHAR(64) NOT NULL COMMENT '报价组合去重摘要', + details_json JSON NOT NULL COMMENT '完整报价明细快照', + record_version BIGINT NOT NULL COMMENT '报价记录版本', + create_key VARCHAR(128) NOT NULL COMMENT '创建幂等键', + create_hash CHAR(64) NOT NULL COMMENT '创建请求含导入批次内容摘要', + created_by VARCHAR(64) NOT NULL COMMENT '创建操作人标识', + created_at DATETIME(3) NOT NULL COMMENT '创建时间UTC', + updated_at DATETIME(3) NOT NULL COMMENT '更新时间UTC', + UNIQUE KEY uk_fleet_quote_no (quote_no), + UNIQUE KEY uk_fleet_quote_natural (natural_key), + UNIQUE KEY uk_fleet_quote_create (created_by,create_key), + KEY idx_fleet_quote_search (kind,status,valid_from,valid_to) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='车辆采购与外接销售报价'; + +CREATE TABLE ll_fleet_quote_history ( + id BIGINT PRIMARY KEY COMMENT '报价历史主键', + quote_id BIGINT NOT NULL COMMENT '报价记录标识', + record_version BIGINT NOT NULL COMMENT '报价记录版本', + action VARCHAR(24) NOT NULL COMMENT '报价操作类型', + operator_id VARCHAR(64) NOT NULL COMMENT '操作人标识', + occurred_at DATETIME(3) NOT NULL COMMENT '操作时间UTC', + snapshot_json JSON NOT NULL COMMENT '报价完整版本快照', + UNIQUE KEY uk_fleet_quote_history_version (quote_id,record_version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='车辆报价不可变版本历史'; + +CREATE TABLE ll_fleet_trip_cost ( + id BIGINT NOT NULL COMMENT '内部主键', + trip_id BIGINT NOT NULL COMMENT '车次内部引用', + cost_no VARCHAR(32) NOT NULL COMMENT '车次成本业务编号', + category VARCHAR(16) NOT NULL COMMENT '直接成本类别FUEL/DRIVER/TOLL/PARKING/OTHER', + currency VARCHAR(3) NOT NULL COMMENT '原始成本币种CNY/USD,不混币汇总', + amount DECIMAL(14,2) NOT NULL COMMENT '运营登记金额,不代表付款', + incurred_at DATETIME(3) NOT NULL COMMENT '实际费用发生UTC时间', + remark VARCHAR(1000) NULL COMMENT '费用备注', + record_version BIGINT NOT NULL COMMENT '成本记录版本', + request_key VARCHAR(128) NOT NULL COMMENT '创建幂等键', + request_hash CHAR(64) NOT NULL COMMENT '创建请求摘要', + created_by VARCHAR(64) NOT NULL COMMENT '登记人', + updated_by VARCHAR(64) NOT NULL COMMENT '最近更正人', + created_at DATETIME(3) NOT NULL COMMENT '登记UTC时间', + updated_at DATETIME(3) NOT NULL COMMENT '更正UTC时间', + PRIMARY KEY(id), + UNIQUE KEY uk_fleet_trip_cost_no(cost_no), + UNIQUE KEY uk_fleet_trip_cost_request(trip_id,created_by,request_key), + KEY idx_fleet_trip_cost_page(trip_id,incurred_at,id), + CONSTRAINT fk_fleet_trip_cost_trip FOREIGN KEY(trip_id) REFERENCES ll_fleet_trip(id), + CONSTRAINT ck_fleet_trip_cost_amount CHECK(amount>=0), + CONSTRAINT ck_fleet_trip_cost_currency CHECK(currency IN ('CNY','USD')), + CONSTRAINT ck_fleet_trip_cost_category CHECK(category IN ('FUEL','DRIVER','TOLL','PARKING','OTHER')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次直接运营成本,不生成客户账单或付款'; + +CREATE TABLE ll_fleet_trip_cost_history ( + id BIGINT NOT NULL COMMENT '内部主键', + cost_id BIGINT NOT NULL COMMENT '成本记录内部引用', + record_version BIGINT NOT NULL COMMENT '成本更正后版本', + action VARCHAR(16) NOT NULL COMMENT '成本操作CREATE/UPDATE', + reason VARCHAR(1000) NULL COMMENT '登记或更正说明', + operator_id VARCHAR(64) NOT NULL COMMENT '操作人', + occurred_at DATETIME(3) NOT NULL COMMENT '操作UTC时间', + snapshot_json JSON NOT NULL COMMENT '成本与当时整趟汇总及分摊快照', + PRIMARY KEY(id), + UNIQUE KEY uk_fleet_trip_cost_history(cost_id,record_version), + CONSTRAINT fk_fleet_trip_cost_history FOREIGN KEY(cost_id) REFERENCES ll_fleet_trip_cost(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次成本不可变更正历史'; + +CREATE TABLE ll_fleet_trip_history ( + id BIGINT NOT NULL COMMENT '内部主键', + trip_id BIGINT NOT NULL COMMENT '车次内部引用', + record_version BIGINT NOT NULL COMMENT '操作后版本', + action VARCHAR(32) NOT NULL COMMENT '车次操作', + reason VARCHAR(1000) NULL COMMENT '操作原因', + operator_id VARCHAR(64) NOT NULL COMMENT '操作人', + occurred_at DATETIME(3) NOT NULL COMMENT '操作UTC时间', + snapshot_json JSON NOT NULL COMMENT '操作后不可变车次快照', + PRIMARY KEY (id), + UNIQUE KEY uk_fleet_trip_history_version (trip_id,record_version), + CONSTRAINT fk_fleet_trip_history FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运输车次操作历史'; + CREATE TABLE ll_wallet_event_inbox ( event_id varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '国内Finance资金结果事件ID', command_event_id varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原海外资金指令事件ID', @@ -2703,6 +3025,7 @@ CREATE TABLE ll_warehouse_transfer_inventory_allocation ( pallet_id bigint NOT NULL COMMENT '来源托盘ID', pallet_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源托盘号快照', package_count int NOT NULL COMMENT '分配箱数', + sku_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本次来源分配的实际SKU快照', status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'RESERVED' COMMENT '分配状态', active_position_key varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '生效中的仓位排他键', allocated_at datetime(3) NOT NULL COMMENT '分配时间', @@ -2711,7 +3034,7 @@ CREATE TABLE ll_warehouse_transfer_inventory_allocation ( 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 (id), - UNIQUE KEY uk_ll_wh_transfer_allocation_position (transfer_order_id,inventory_position_id), + KEY idx_ll_wh_transfer_allocation_position (transfer_order_id,inventory_position_id), UNIQUE KEY uk_ll_wh_transfer_active_position (customer_id,active_position_key), KEY idx_ll_wh_transfer_allocation_list (customer_id,transfer_order_id,status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转仓单来源库存分配记录'; @@ -2880,6 +3203,31 @@ CREATE TABLE ll_warehouse_transfer_receipt ( KEY idx_ll_wh_transfer_receipt_list (customer_id,transfer_order_id,received_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转仓来源仓位的目标库存收货记录'; +CREATE TABLE ll_warehouse_transfer_receipt_command ( + id BIGINT NOT NULL COMMENT '收货命令主键', + customer_id VARCHAR(128) NOT NULL COMMENT '客户标识', + transfer_order_id BIGINT NOT NULL COMMENT '转运单标识', + source_inventory_position_id BIGINT NOT NULL COMMENT '本次收货来源库存位置', + idempotency_key VARCHAR(255) NOT NULL COMMENT '原始收货幂等键', + request_hash CHAR(64) NOT NULL COMMENT '完整原始收货请求摘要', + request_json JSON NOT NULL COMMENT '完整原始收货请求快照', + target_warehouse_id BIGINT NOT NULL COMMENT '实际目标海外收货仓', + target_pallet_id BIGINT NOT NULL COMMENT '目标实物托盘', + target_location_id BIGINT NOT NULL COMMENT '目标实际库位', + target_hash CHAR(64) NOT NULL COMMENT '共享目标资源绑定摘要', + target_json JSON NOT NULL COMMENT '权威目标仓托盘库位及目的仓快照', + reservation_key VARCHAR(128) NOT NULL COMMENT '同转运同目标托盘共享资源占用键', + resource_lock_id BIGINT NULL COMMENT '已确认有效的海外资源锁标识', + status VARCHAR(32) NOT NULL COMMENT '命令状态PREPARED准备完成RESERVED资源占用COMPLETED收货完成', + created_at DATETIME(3) NOT NULL COMMENT '命令创建时间', + updated_at DATETIME(3) NOT NULL COMMENT '命令最后更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_wh_receipt_cmd_source (transfer_order_id, source_inventory_position_id), + UNIQUE KEY uk_ll_wh_receipt_cmd_key (customer_id, idempotency_key), + KEY idx_ll_wh_receipt_cmd_target (customer_id, transfer_order_id, target_pallet_id), + CONSTRAINT ck_ll_wh_receipt_cmd_status CHECK (status IN ('PREPARED','RESERVED','COMPLETED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转运逐来源收货原请求与共享目标资源占用命令'; + CREATE TABLE ll_warehouse_transfer_relabel_binding ( id bigint NOT NULL COMMENT '主键ID', transfer_order_id bigint NOT NULL COMMENT '转运单ID', @@ -2892,6 +3240,8 @@ CREATE TABLE ll_warehouse_transfer_relabel_binding ( fba_carton_mark varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '新FBA箱唛', destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA目的仓', remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', + source_allocation_id bigint DEFAULT NULL COMMENT '已有库存转运的原始来源分配ID', + relabel_item_id bigint DEFAULT NULL COMMENT '本票关联换标明细ID', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_transfer_relabel_mark (transfer_order_id,customer_carton_mark), @@ -3064,9 +3414,43 @@ CREATE TABLE wh_resource_lock ( USE baiyue_lastleg; --- 系统基线 ll_security_projection:2 行。 -INSERT INTO ll_security_projection (aggregate_type, aggregate_id, tenant_id, security_status, permission_version, roles_json, authorities_json, aggregate_version, payload_hash, last_event_id, changed_at, updated_at) VALUES ('AUTHORIZATION','1000001',0,NULL,0,'[\"PLATFORM_ADMIN\"]','[\"platform:overseas-warehouse:warehouse-transfer:approve\", \"platform:finance:bill:adjust\", \"platform:finance:bill:update\", \"platform:finance:bill:void\", \"platform:finance:cost:create\", \"platform:finance:cost:view\", \"platform:finance:customer-account:adjust\", \"platform:finance:customer-account:export\", \"platform:finance:customer-account:list\", \"platform:finance:customer-arap:export\", \"platform:finance:customer-arap:list\", \"platform:finance:customer-statement:export\", \"platform:finance:customer-statement:list\", \"platform:finance:customer-terms:update\", \"platform:finance:customer-terms:view\", \"platform:finance:fee-source:edit\", \"platform:finance:fee-source:review\", \"platform:finance:payment:create\", \"platform:finance:receipt:create\", \"platform:finance:receipt:view\", \"platform:finance:receivable-statistics:export\", \"platform:finance:receivable-statistics:list\", \"platform:finance:recharge:review\", \"platform:finance:reconciliation:adjust\", \"platform:finance:reconciliation:create\", \"platform:finance:reconciliation:issue\", \"platform:finance:reconciliation:review\", \"platform:finance:reconciliation:void\", \"platform:finance:settlement-ap:create\", \"platform:finance:settlement-ap:export\", \"platform:finance:settlement-ap:list\", \"platform:finance:settlement-ar:create\", \"platform:finance:settlement-ar:export\", \"platform:finance:settlement-ar:list\", \"platform:finance:settlement:void\", \"platform:overseas-business:looseboard:create\", \"platform:overseas-business:looseboard:edit\", \"platform:overseas-business:looseboard:list\", \"platform:overseas-business:looseboard:status\", \"platform:overseas-business:purchase:edit\", \"platform:overseas-business:purchase:list\", \"platform:overseas-business:purchase:review\", \"platform:overseas-business:truck:create\", \"platform:overseas-business:truck:edit\", \"platform:overseas-business:truck:list\", \"platform:overseas-business:truck:status\", \"platform:overseas-master-data:exchange-rate:create\", \"platform:overseas-master-data:exchange-rate:edit\", \"platform:overseas-master-data:exchange-rate:export\", \"platform:overseas-master-data:exchange-rate:list\", \"platform:overseas-master-data:exchange-rate:status\", \"platform:overseas-master-data:quote:create\", \"platform:overseas-master-data:quote:edit\", \"platform:overseas-master-data:quote:export\", \"platform:overseas-master-data:quote:list\", \"platform:overseas-master-data:quote:status\", \"platform:overseas-master-data:vehicle:create\", \"platform:overseas-master-data:vehicle:edit\", \"platform:overseas-master-data:vehicle:export\", \"platform:overseas-master-data:vehicle:list\", \"platform:overseas-master-data:vehicle:status\", \"platform:overseas-warehouse:customer-visit:edit\", \"platform:overseas-warehouse:customer-visit:list\", \"platform:overseas-warehouse:delivery:documents\", \"platform:overseas-warehouse:delivery:list\", \"platform:overseas-warehouse:delivery:return\", \"platform:overseas-warehouse:delivery:status\", \"platform:overseas-warehouse:dispatch:cancel\", \"platform:overseas-warehouse:dispatch:edit\", \"platform:overseas-warehouse:dispatch:list\", \"platform:overseas-warehouse:dispatch:transfer-delivery\", \"platform:overseas-warehouse:exception-order:create\", \"platform:overseas-warehouse:exception-order:list\", \"platform:overseas-warehouse:exception-order:process\", \"platform:overseas-warehouse:inbound-order:create\", \"platform:overseas-warehouse:inbound-order:edit\", \"platform:overseas-warehouse:inbound-order:fba-appointment\", \"platform:overseas-warehouse:inbound-order:list\", \"platform:overseas-warehouse:inbound-order:smart-dispatch\", \"platform:overseas-warehouse:inventory:adjust\", \"platform:overseas-warehouse:inventory:edit\", \"platform:overseas-warehouse:inventory:export\", \"platform:overseas-warehouse:inventory:list\", \"platform:overseas-warehouse:last-mile-order:edit\", \"platform:overseas-warehouse:last-mile-order:export\", \"platform:overseas-warehouse:last-mile-order:list\", \"platform:overseas-warehouse:location:create\", \"platform:overseas-warehouse:location:delete\", \"platform:overseas-warehouse:location:edit\", \"platform:overseas-warehouse:location:list\", \"platform:overseas-warehouse:outbound:cancel\", \"platform:overseas-warehouse:outbound:create\", \"platform:overseas-warehouse:outbound:list\", \"platform:overseas-warehouse:pallet:create\", \"platform:overseas-warehouse:pallet:delete\", \"platform:overseas-warehouse:pallet:edit\", \"platform:overseas-warehouse:pallet:list\", \"platform:overseas-warehouse:relabel-order:create\", \"platform:overseas-warehouse:relabel-order:list\", \"platform:overseas-warehouse:relabel-order:status\", \"platform:overseas-warehouse:return-cartons:list\", \"platform:overseas-warehouse:return-inventory:list\", \"platform:overseas-warehouse:warehouse:create\", \"platform:overseas-warehouse:warehouse:edit\", \"platform:overseas-warehouse:warehouse:list\", \"platform:overseas-warehouse:warehouse:status\"]',0,'f45d2c4b22e46dd866045caf4c2050dafae456a58065367b06c180032df65c22','baseline-platform-admin-authorization-v0','1970-01-01 00:00:00.000',CURRENT_TIMESTAMP(3)); -INSERT INTO ll_security_projection (aggregate_type, aggregate_id, tenant_id, security_status, permission_version, roles_json, authorities_json, aggregate_version, payload_hash, last_event_id, changed_at, updated_at) VALUES ('USER','1000001',0,'ACTIVE',0,'[]','[]',0,'c7522473b3b940c94397b23fa08bd8f3f56ca646e395314393853bd599c300c7','baseline-platform-admin-user-v0','1970-01-01 00:00:00.000','1970-01-01 00:00:00.000'); -- assert_relabel_transfer_latest_schema_ready: 贴标申请与仓储转运最新表结构已完整部署。 -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 138 AS initialized_tables; +USE baiyue_lastleg; +CREATE TABLE event_delivery_retry_audit ( + request_id VARCHAR(36) NOT NULL COMMENT '人工重试请求UUID和幂等键', + producer_table VARCHAR(64) NOT NULL COMMENT '本服务发件箱表名', + entry_id VARCHAR(64) NOT NULL COMMENT '发件箱记录主键', + event_id VARCHAR(128) NOT NULL COMMENT '原事件全局标识', + event_type VARCHAR(64) NOT NULL COMMENT '原事件类型', + payload_hash VARCHAR(71) NOT NULL COMMENT '原事件不可变负载摘要', + operator_name VARCHAR(128) NOT NULL COMMENT '持有运维凭据的操作人标识', + reason VARCHAR(500) NOT NULL COMMENT '人工重试原因', + previous_failures INT NOT NULL COMMENT '重试前累计失败次数', + previous_error VARCHAR(1000) NULL COMMENT '重试前最后一次脱敏错误', + requested_at DATETIME(3) NOT NULL COMMENT '重试申请提交时间', + PRIMARY KEY (request_id), + KEY idx_event_retry_entry (producer_table,entry_id,requested_at), + KEY idx_event_retry_event (event_id,requested_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务事件人工重试审计,与恢复待发送状态同事务提交'; + +USE baiyue_lastleg; +CREATE TABLE event_delivery_attempt ( + attempt_id VARCHAR(36) NOT NULL COMMENT '本次投递租约UUID和审计主键', + service_name VARCHAR(32) NOT NULL COMMENT '本库所属生产服务', + event_type VARCHAR(96) NOT NULL COMMENT '白名单业务事件类型', + entry_id VARCHAR(128) NOT NULL COMMENT '生产者发件箱主键', + event_id VARCHAR(128) NULL COMMENT '合法原事件UUID或ULID;格式错误时为空', + target_name VARCHAR(64) NOT NULL COMMENT '固定目标业务服务', + started_at DATETIME(3) NOT NULL COMMENT '领取及审计事务提交前的开始时间', + finished_at DATETIME(3) NULL COMMENT '尝试完成或租约过期恢复时间', + duration_ms BIGINT NULL COMMENT '尝试耗时毫秒', + outcome VARCHAR(24) NOT NULL COMMENT 'STARTED/APPLIED/DUPLICATE/STALE/RETRY/DEAD/LEASE_EXPIRED', + error_code VARCHAR(64) NULL COMMENT '有限错误类别或HTTP状态;不含载荷与令牌', + next_retry_at DATETIME(3) NULL COMMENT '本次失败后下次计划投递时间', + PRIMARY KEY (attempt_id), + KEY idx_delivery_attempt_entry (service_name,entry_id,started_at), + KEY idx_delivery_attempt_outcome (outcome,started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; + +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 154 AS initialized_tables; diff --git a/docs/头程项目目录结构与服务说明.md b/docs/头程项目目录结构与服务说明.md index 522ab5e45..88c858837 100644 --- a/docs/头程项目目录结构与服务说明.md +++ b/docs/头程项目目录结构与服务说明.md @@ -10,7 +10,7 @@ ## 1. 项目定位 -`baiyue-firstleg` 是国内头程与海外仓后端仓库,采用 Maven 多模块单仓结构。根 POM 聚合 22 个 Maven 模块,其中 7 个是公共/依赖模块,15 个是可独立启动、构建、制作镜像的微服务。 +`baiyue-firstleg` 是国内头程与海外仓后端仓库,采用 Maven 多模块单仓结构。根 POM 聚合 20 个 Maven 模块,其中 7 个是公共/依赖模块,13 个是可独立启动、构建、制作镜像的微服务。 当前技术基线: @@ -24,7 +24,7 @@ - SpringDoc OpenAPI / Swagger - GitLab、Jenkins、Docker Compose -国内与海外服务共享源码仓库和公共制品,但保持独立数据库、Nacos 命名空间与部署边界;跨区域业务协作使用版本化 Outbox/Inbox 事件,不共享业务数据源。 +当前源码与发布清单统一为 TEST 单机、一个 Gateway/Auth 会话和 Nacos 命名空间;业务服务保留独立 Schema,可靠事件使用 Outbox 直接投递及消费幂等。真实环境切换仍待验收,不能把本地配置视为已部署。 ## 2. 根目录结构 @@ -39,7 +39,7 @@ baiyue-firstleg/ ├─ common-event-contract/ # 国内外跨区域事件统一契约 │ ├─ gateway/ # 统一 API 网关 -├─ gateway-overseas/ # 海外区域 API 网关 +├─ gateway-overseas/ # 退役待清理,不参与构建/发布 ├─ auth/ # 统一登录与账号凭据 ├─ iam/ # 租户、成员、角色、权限、企业审核 ├─ order/ # 头程订单中心 @@ -52,7 +52,7 @@ baiyue-firstleg/ ├─ print/ # 打印中心 ├─ audit-service/ # 审计日志中心 ├─ integration-service/ # 第三方平台集成适配 -├─ event-bridge-service/ # 国内外跨区域事件接收桥 +├─ event-bridge-service/ # 退役待清理,不参与构建/发布 │ ├─ config/ # Nacos 配置模板 ├─ deploy/ # Jenkins、Docker 使用的部署文件 @@ -90,7 +90,6 @@ baiyue-firstleg/ | 文件夹 | 注册服务名 | 端口 | 数据库 | 当前职责与实现情况 | | --- | --- | ---: | --- | --- | | `gateway` | `gateway` | 9000 | 无 | 统一路由、CORS、JWT/Redis 会话校验、商户端与平台端访问域隔离、Swagger 聚合 | -| `gateway-overseas` | `gateway-overseas` | 9300 | 无 | 海外区域 API 入口、海外 Audience/JWKS 校验及海外服务路由 | | `auth` | `auth-service` | 9100 | `baiyue_auth` | 登录、刷新 Token、退出、商户注册、账号密码和账号内部管理 | | `iam` | `iam-service` | 9201 | `baiyue_iam` | 租户、企业资料、企业审核、成员、角色、权限、按钮权限、个人资料和授权快照 | | `firstleg` | `firstleg-service` | 9202 | `baiyue_firstleg` | 履约任务与分配;入库单、测量审核、FBA 计费、费用发布和完成入库编排;不是订单主数据服务 | @@ -99,9 +98,8 @@ baiyue-firstleg/ | `file` | `file-service` | 9206 | `baiyue_file` | 企业资质文件、公共异步导出下载、入库测量图片及重量 OCR 调用协作 | | `print` | `print-service` | 9207 | 无 | 无状态 PDF 渲染服务;调用方传入完整标签数据,服务在内存中生成一次性多页 PDF | | `integration-service` | `integration-service` | 9210 | 无 | 百度 OCR、Amazon SP-API 等第三方适配器和启停状态;默认关闭外部调用 | -| `lastleg-service` | `lastleg-service` | 9303 | `baiyue_lastleg` | 海外尾程订单、接柜入仓、库存、海外本土业务及 IAM 安全事件投影/实时校验;不含海外出库和派送 | +| `lastleg-service` | `lastleg-service` | 9303 | `baiyue_lastleg` | 海外尾程订单、接柜入仓、库存、出库、派送和本土业务;复用统一会话鉴权 | | `warehouse-overseas-service` | `warehouse-overseas-service` | 9305 | 海外仓资源库 | 海外仓库、库位、托盘和资源锁 | -| `event-bridge-service` | `event-bridge-service` | 9312 | `baiyue_event_bridge` | 按 CN/OVERSEAS 双实例部署的跨区事件 Inbox、重试和目标服务投递 | | `audit-service` | `audit-service` | 9700 | `baiyue_audit` | 接收公共审计事件并持久化;平台端可按权限查询已脱敏日志,业务审计发送默认开启 | MySQL、Redis、Nacos 是外部基础设施,不属于上述业务服务,也不应打包进业务镜像。 @@ -158,9 +156,9 @@ Print 是无数据库的无状态 PDF 渲染服务。业务归属服务完成权 Integration 负责百度 OCR、重量识别和 Amazon Logistics/SP-API 等第三方协议适配及能力状态。入库测量与计费重量 OCR 由 File 调用,业务图片和入库状态不归 Integration;服务不使用业务数据库,真实外部调用默认关闭,密钥必须通过 Jenkins Credentials、环境变量或 Nacos 密文注入。 -### 5.11 `event-bridge-service/`:跨区域事件桥 +### 5.11 业务事件直接投递 -Event Bridge 负责接收国内外服务器之间的必要业务事件,并通过 Inbox 实现幂等。事件载荷遵循 `common-event-contract`,包含事件 ID、来源和目标区域、业务键、版本、时间及 Payload 哈希;头程与尾程不共享 MySQL、Redis、Nacos 或内部接口。 +Firstleg、Lastleg 与 Finance 通过内部服务身份直接投递业务事件,保留事务 Outbox、消费幂等、结构化 ACK 与失败重试;精确事件表只维护在外层 `docs/统一认证与服务通信契约.md`。旧 Event Bridge 和海外 Gateway 源码退役待清理,不作为当前实现入口。 头程装柜完成事件以实际集装箱为顶层批次,`orders[]` 中每项是一张实际发运的来源入仓单;一个柜可包含多张入仓单。每个货物明细携带来源明细 ID、实际装运箱数和完整的国内箱唛池。国内不要求在装柜时逐箱扫描冻结,完整箱唛池可以大于本柜实际装运箱数;海外按实际首次扫描的箱唛确认归属和实收,未扫描箱唛保留给后续子订单。海外将同柜各来源入仓单分别生成入库计划,但以同一集装箱号关联,并且只允许同一目的仓的箱子合托。 @@ -170,11 +168,10 @@ Event Bridge 负责接收国内外服务器之间的必要业务事件,并通 ```text config/nacos/ -├─ local/ # 本地联调配置 -└─ cn/ # 国内服务器配置模板 +└─ test/ # 公共配置 + 13 服务,统一实际 Namespace ID ``` -`local/`、`cn/`、`overseas/` 按部署区域保存公共配置和该区域需要的服务配置。配置模板可以提交,真实密码和第三方密钥不能提交。 +`test/` 保存公共配置和全部保留服务的 14 个 Data ID;旧区域目录已退役。配置模板可以提交,真实密码和第三方密钥不能提交。 ### `deploy/` @@ -183,17 +180,16 @@ deploy/ ├─ docker/Dockerfile # 通用 Java 17 镜像 ├─ env/.env.test.example # 环境变量示例 ├─ scripts/deploy-services.sh # Jenkins 增量部署脚本 -├─ docker-compose.yml # 国内区域服务编排 -├─ docker-compose.overseas.yml # 海外区域服务编排 +├─ docker-compose.yml # 13 服务统一 TEST 编排 ├─ docker-compose.rocketmq.yml # 公共导出 RocketMQ 基础设施 -├─ sql/ # 测试/存量环境统一迁移入口与说明 +├─ sql/ # TEST 空库基线入口与执行说明 └─ service-map.yml # 模块、JAR、端口映射 ``` ### `scripts/` - `local-env.ps1`:本地服务环境变量 -- `publish-local-nacos-config.ps1`:发布本地 Nacos 配置 +- `publish-nacos-config.ps1`:发布统一 TEST Nacos 配置;DryRun 不联网,正式发布须授权 - `phase1-acceptance.ps1`:订单与客户验收入口 - `phase1-business-maturity-regression.ps1`:业务成熟度回归 - `phase1-onboarding-order-e2e.ps1`:企业入驻到订单端到端验证 @@ -281,6 +277,8 @@ HTTP 401 / `A0401`,不得再按 HTTP 200 + 空权限处理;只有使用新 - 平台端和商户端权限分别管理,接口与按钮权限都必须由后端校验。 - Controller 负责协议转换,Service 负责业务编排,Repository/Mapper 负责持久化。 - 服务之间只传 DTO 或事件契约,不能依赖其他服务的 Entity、Mapper。 -- 公共模块变化会影响多个服务,Jenkins 会按 `service-map.yml` 覆盖的全部 15 个服务执行受影响构建。 +- 公共模块变化会影响多个服务,Jenkins 会按 `service-map.yml` 覆盖的13 个保留服务执行受影响构建。 - 单一 Git 仓库不等于单一部署单元;每个服务仍生成独立 JAR、镜像和容器。 -- 国内头程与海外仓共享 Git 仓库,但保持两套数据库、Nacos 命名空间和部署环境。 +- 国内头程与海外仓共用 TEST 单机基础设施与 Nacos 命名空间,保留各服务数据归属和 Schema。 + +前端 phase4 定向离线检查入口为 `apps/admin-web/scripts/phase4-test-frontend-acceptance.mjs`(工作区相对路径),由 Admin 的 `pnpm test:phase4:test` 使用 `vitest.phase4-test.config.ts` 执行;不替代真实HTTP、页面或PDA验收。 diff --git a/event-bridge-service/AGENTS.md b/event-bridge-service/AGENTS.md index 890bd77e2..85e616a0d 100644 --- a/event-bridge-service/AGENTS.md +++ b/event-bridge-service/AGENTS.md @@ -1,5 +1,7 @@ # Event Bridge Service 模块指引 +> 本目录为退役源码,已退出根 POM 和部署映射。下文仅记录旧实现,不是现行规则。事件任务先按 `../../docs/统一认证与服务通信契约.md` 的逐事件表定位生产者/直接消费者与业务 Relay;不得恢复区域分派或重新加入构建。源码清理按 `../../docs/测试环境单机部署与配置契约.md` 核对引用及运行证据。 + ## 职责边界 - Mapper 只由 `EventBridgeApplication.java` 对 `com.baiyue.eventbridge` 根包按 `@Mapper` 过滤扫描;新增或移动 Mapper 后从后端根运行 `node scripts/mapper-scan-contract.mjs`,不得增加子域扫描配置。 @@ -36,5 +38,5 @@ ## 修改与验证 -- 定向验证:`mvn -pl event-bridge-service -am test`。 +- 本模块已退出聚合,不能从根使用 `mvn -pl event-bridge-service`;现行投递验证直接生产者/消费者及对应 Outbox/Inbox、ACK、重试和审计。 - 重试相关修改至少覆盖 `RECEIVED`、`RETRY`、成功、失败、租约、最大次数和人工补投。 diff --git a/file/AGENTS.md b/file/AGENTS.md index c4a01d206..33f23e85c 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -16,7 +16,11 @@ | 平台订单商品图片读取 | `controller/PlatformProductImageController.java` 的 `/api/platform/v1/files/product-images/{tenantId}/{fileId}`、`service/ProductImageService.java`;仅平台 `platform:order:list` 可按明确客户与文件 ID 内联读取,响应 `no-store` | | 装柜与业务文件证据 | `controller/PlatformLoadingEvidenceController.java`、`InternalLoadingEvidenceController.java`、`service/LoadingEvidenceService.java`、`src/main/resources/openapi/loading-evidence-v1.yaml`;五类装柜证据固定为平台所有、`tenantId=0`、`businessId=containerBookingId`,不得绑定客户或入仓单。其他业务文件进入 `InternalBusinessFileController.java`、`service/InternalBusinessFileService.java` | | 财务付款凭证、海外卸柜照片 | 通用业务文件入口 `InternalBusinessFileController.java`、`InternalBusinessFileService.java`;`FINANCE_VOUCHER` 按客户与业务 ID 精确校验;`OVERSEAS_UNLOAD_PHOTO` 是允许同柜多客户的柜级物理证据,固定平台范围 `tenantId=0` 并按接柜会话 ID 精确校验 | +| 公司车辆付款凭证 | `src/main/resources/openapi/finance-vehicle-voucher-v1.yaml`;类型`FINANCE_VEHICLE_VOUCHER`,固定平台0和Finance费用ID,仅非空JPG/PNG、10MiB及签名校验;不复用客户凭证或维修照片 | | 海外异常订单证据 | 通用内部业务文件入口;固定 `businessType=OVERSEAS_EXCEPTION_EVIDENCE`、`tenantId=异常订单所属客户ID`、`businessId=异常订单ID`,File 只校验文件复合归属,异常状态、完成门禁和证据引用归 Lastleg | +| 平台司机驾驶证 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-driver-license-v1.yaml`;`FLEET_DRIVER_LICENSE` 固定平台 `tenantId=0`、`businessId=driverId`,仅非空 PDF/JPG/PNG、最大 10MiB并校验签名;Lastleg维护审核、当前/历史附件关联及读取权限 | +| 车辆保养加油照片 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-vehicle-record-photo-v1.yaml`;`FLEET_VEHICLE_RECORD_PHOTO` 固定平台0及运维记录ID,仅非空JPG/PNG、最大10MiB并校验签名;Lastleg校验车辆/记录/附件关联与权限,不使用车牌作为文件归属键 | +| 出车实测凭证 | 通用内部业务文件入口,契约 `src/main/resources/openapi/fleet-trip-operation-evidence-v1.yaml`;`FLEET_TRIP_OPERATION_EVIDENCE`固定平台0及车次ID,PDF/JPG/PNG、10MiB与签名校验;Lastleg管理实测版本引用及读写权限,附件选填 | | 海外换标 PDF 资料 | 通用内部业务文件入口,契约 `src/main/resources/openapi/overseas-relabel-document-v1.yaml`;`OVERSEAS_RELABEL_TOTAL_DOCUMENT` 按 `application.id`、`OVERSEAS_RELABEL_ITEM_DOCUMENT` 按 `item.id` 归属,必须指定明确客户与正数业务 ID;仅非空 PDF、最大 10MiB,校验 MIME 和文件签名。File 按客户、类型、业务 ID、文件 ID 复合校验,Lastleg 校验申请/明细关系、客户归属、权限和业务状态 | | 入库扣货凭证 | 通用内部业务文件入口;固定 `businessType=INBOUND_HOLD_EVIDENCE`、`tenantId=客户ID`、`businessId=入库单ID`,客户作用域单文件最多 10MiB;可见性与历史引用由 Firstleg 校验 | | 报关源文件、报关行回执、完成文件与买单明细导出 | `src/main/resources/openapi/customs-file-and-export-v1.yaml`;文件类别 `LOADING_LIST_CUSTOMS_SOURCE`、`LOADING_LIST_CUSTOMS_RECEIPT`、`LOADING_LIST_CUSTOMS_RESULT` 均为客户作用域且最多 50MB,异步导出类型 `LOADING_LIST_BUY_DECLARATION_ITEMS` 由 Firstleg Provider 按固定十三列供数 | diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java index 42add2447..7bc39d2e8 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -7,6 +7,9 @@ import java.util.Arrays; /** File-owned storage policy. Business visibility and lifecycle remain in the calling service. */ enum BusinessFileCategory { + FLEET_DRIVER_LICENSE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + FLEET_VEHICLE_RECORD_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), DELIVERY_LOADING_PHOTO(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), DELIVERY_BOL(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), DELIVERY_RECEIPT(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), @@ -15,6 +18,7 @@ enum BusinessFileCategory { CUSTOMS_DOCUMENT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), CUSTOMS_RECEIPT(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), FINANCE_VOUCHER(false, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + FINANCE_VEHICLE_VOUCHER(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), INBOUND_HOLD_EVIDENCE(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_PROCUREMENT_RECEIPT(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_PROCUREMENT_VOUCHER(true, FormatPolicy.STANDARD, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), @@ -59,6 +63,18 @@ enum BusinessFileCategory { } void validateOwner(long tenantId) { + if (this == FLEET_TRIP_OPERATION_EVIDENCE && tenantId != 0) { + throw new BizException(ResultCode.BAD_REQUEST, "出车实测凭证必须归属平台"); + } + if (this == FINANCE_VEHICLE_VOUCHER && tenantId != 0) { + throw new BizException(ResultCode.BAD_REQUEST, "公司车辆付款凭证必须归属平台"); + } + if (this == FLEET_VEHICLE_RECORD_PHOTO && tenantId != 0) { + throw new BizException(ResultCode.BAD_REQUEST, "车辆运维照片必须归属平台"); + } + if (this == FLEET_DRIVER_LICENSE && tenantId != 0) { + throw new BizException(ResultCode.BAD_REQUEST, "驾驶证附件必须归属平台"); + } if ((this == OCEAN_BOOKING_DOCUMENT || this == OVERSEAS_UNLOAD_PHOTO) && tenantId != 0) { throw new BizException(ResultCode.BAD_REQUEST, this == OCEAN_BOOKING_DOCUMENT ? "订舱附件必须归属平台" : "海外卸柜照片必须归属平台"); @@ -93,7 +109,7 @@ enum BusinessFileCategory { return Long.toString(value); } - enum FormatPolicy { STANDARD, ARBITRARY, XLSX, PDF } + enum FormatPolicy { STANDARD, ARBITRARY, XLSX, PDF, PDF_IMAGE, IMAGE } private enum BusinessIdPolicy { POSITIVE_LONG, ORDER_BATCH_NO } private static final class Limits { private static final long STANDARD = 10L * 1024 * 1024; diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java b/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java index 764b21e53..2b78410e0 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java @@ -64,6 +64,10 @@ public class BusinessFileStorage { verifyXlsx(file); extension = ".xlsx"; } else { + if (formatPolicy == BusinessFileCategory.FormatPolicy.IMAGE && !java.util.Set.of("image/jpeg", "image/png").contains(type)) + throw bad("图片附件仅支持JPG、PNG"); + if (formatPolicy == BusinessFileCategory.FormatPolicy.PDF_IMAGE && !java.util.Set.of("application/pdf", "image/jpeg", "image/png").contains(type)) + throw bad("附件仅支持PDF、JPG、PNG"); extension = TYPES.get(type); if (extension == null) throw bad("附件仅支持TXT、PDF、JPG、PNG"); verifyContent(file, type); diff --git a/file/src/main/resources/application-dev.yml b/file/src/main/resources/application-dev.yml deleted file mode 100644 index 334396130..000000000 --- a/file/src/main/resources/application-dev.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${FILE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_file?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${FILE_DB_USERNAME:root} - password: ${FILE_DB_PASSWORD:123456} diff --git a/file/src/main/resources/application-test.yml b/file/src/main/resources/application-test.yml deleted file mode 100644 index fec0b48d0..000000000 --- a/file/src/main/resources/application-test.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${FILE_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_file?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true} - username: ${FILE_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}} - password: ${FILE_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}} diff --git a/file/src/main/resources/application.yml b/file/src/main/resources/application.yml index be91f53d6..4c5595c8c 100644 --- a/file/src/main/resources/application.yml +++ b/file/src/main/resources/application.yml @@ -2,24 +2,24 @@ server: port: ${FILE_PORT:9206} spring: profiles: - active: ${SPRING_PROFILES_ACTIVE:dev} + active: ${SPRING_PROFILES_ACTIVE:test} application: name: file-service config: import: - - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + - nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true + - nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} + server-addr: ${NACOS_SERVER_ADDR} + username: ${NACOS_USERNAME} + password: ${NACOS_PASSWORD} discovery: enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:public} - config.namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} + config.namespace: ${NACOS_NAMESPACE} openfeign: client: config: @@ -49,8 +49,7 @@ rocketmq: send-message-timeout: 3000 baiyue: security: - jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} - internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} + internal-service-token: ${INTERNAL_SERVICE_TOKEN} clients: iam: url: ${IAM_SERVICE_URL:} diff --git a/file/src/main/resources/openapi/finance-vehicle-voucher-v1.yaml b/file/src/main/resources/openapi/finance-vehicle-voucher-v1.yaml new file mode 100644 index 000000000..8f6989db4 --- /dev/null +++ b/file/src/main/resources/openapi/finance-vehicle-voucher-v1.yaml @@ -0,0 +1,10 @@ +openapi: 3.0.3 +info: + title: 公司车辆付款凭证归属 + version: 1.0.0 + description: >- + 复用InternalBusinessFileController内部上传、元数据及内容接口,不增加公网路径。 + businessType为FINANCE_VEHICLE_VOUCHER,tenantId必须为0,businessId为Finance公司费用内部主键。 + 仅非空JPG/PNG,最多10MiB,同时校验MIME及文件签名;File按平台、业务类型、费用ID及文件ID复合校验。 + Finance校验list/upload/pay权限及费用归属;上传不是付款,不复用客户FINANCE_VOUCHER或车辆维修照片。 +paths: {} diff --git a/file/src/main/resources/openapi/fleet-driver-license-v1.yaml b/file/src/main/resources/openapi/fleet-driver-license-v1.yaml new file mode 100644 index 000000000..2d88200e9 --- /dev/null +++ b/file/src/main/resources/openapi/fleet-driver-license-v1.yaml @@ -0,0 +1,89 @@ +openapi: 3.0.3 +info: + title: 平台司机证照内部文件契约 + version: 1.0.0 + description: >- + 复用InternalBusinessFileController,固定平台tenantId=0、businessType=FLEET_DRIVER_LICENSE、 + businessId=司机内部ID。Lastleg负责司机存在性、列表/上传权限、关联和历史可见性; + File校验内部服务令牌、平台复合归属、真实非空文件、MIME与签名。最大10MiB,仅PDF/JPG/PNG。 + 上传只生成不可变对象,不自动通过资质审核,不生成公开URL,旧附件通过Lastleg历史引用读取。 +security: + - InternalServiceToken: [] +paths: + /internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}: + parameters: + - {$ref: '#/components/parameters/TenantId'} + - {$ref: '#/components/parameters/BusinessType'} + - {$ref: '#/components/parameters/BusinessId'} + post: + summary: 上传平台司机驾驶证 + parameters: + - {name: X-Operator-Id, in: header, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: {type: string, format: binary} + responses: + '200': {$ref: '#/components/responses/Metadata'} + '403': {description: 内部服务令牌无效} + /internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}: + parameters: + - {$ref: '#/components/parameters/TenantId'} + - {$ref: '#/components/parameters/BusinessType'} + - {$ref: '#/components/parameters/BusinessId'} + - {$ref: '#/components/parameters/FileId'} + get: + summary: 校验归属并读取元数据 + responses: + '200': {$ref: '#/components/responses/Metadata'} + '403': {description: 内部服务令牌无效} + /internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content: + parameters: + - {$ref: '#/components/parameters/TenantId'} + - {$ref: '#/components/parameters/BusinessType'} + - {$ref: '#/components/parameters/BusinessId'} + - {$ref: '#/components/parameters/FileId'} + get: + summary: 校验归属并下载真实文件 + responses: + '200': + description: 文件内容,业务错误按JSON code识别 + content: + application/pdf: {schema: {type: string, format: binary}} + image/jpeg: {schema: {type: string, format: binary}} + image/png: {schema: {type: string, format: binary}} + '403': {description: 内部服务令牌无效} +components: + securitySchemes: + InternalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token} + parameters: + TenantId: {name: tenantId, in: path, required: true, schema: {type: string, enum: ['0']}} + BusinessType: {name: businessType, in: path, required: true, schema: {type: string, enum: [FLEET_DRIVER_LICENSE]}} + BusinessId: {name: businessId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + FileId: {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: + Metadata: + description: 统一响应;业务成功code为200,复合归属不匹配按业务错误返回 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string, nullable: true} + data: + type: object + properties: + fileId: {type: string} + tenantId: {type: string, enum: ['0']} + businessType: {type: string, enum: [FLEET_DRIVER_LICENSE]} + businessId: {type: string} + fileName: {type: string} + sha256: {type: string, pattern: '^[0-9a-f]{64}$'} + contentType: {type: string, enum: [application/pdf, image/jpeg, image/png]} + sizeBytes: {type: integer, format: int64, minimum: 1, maximum: 10485760} diff --git a/file/src/main/resources/openapi/fleet-trip-operation-evidence-v1.yaml b/file/src/main/resources/openapi/fleet-trip-operation-evidence-v1.yaml new file mode 100644 index 000000000..1f96cda87 --- /dev/null +++ b/file/src/main/resources/openapi/fleet-trip-operation-evidence-v1.yaml @@ -0,0 +1,12 @@ +openapi: 3.0.3 +info: + title: 出车实测凭证归属契约 + version: 1.0.0 + description: >- + 复用InternalBusinessFileController的内部业务文件上传、元数据和内容接口,不新增公网File路径。 + businessType固定FLEET_TRIP_OPERATION_EVIDENCE,tenantId固定0,businessId固定Lastleg车次内部主键。 + 凭证为选填;上传时只接受非空PDF/JPG/PNG,最大10MiB,校验MIME与内容签名。 + 文件按平台、业务类型、车次及文件ID隔离;Lastleg持有当前/历史实测版本的附件引用及读写权限。 + 上传不自动保存实测记录,也不变更出车、库存、费用或签收事实;历史对象不硬删除。 + Lastleg读取时先确认附件属于可见实测版本,再读取内容;不返回内部存储路径或匿名文件URL。 +paths: {} diff --git a/file/src/main/resources/openapi/fleet-vehicle-record-photo-v1.yaml b/file/src/main/resources/openapi/fleet-vehicle-record-photo-v1.yaml new file mode 100644 index 000000000..b5ea28f80 --- /dev/null +++ b/file/src/main/resources/openapi/fleet-vehicle-record-photo-v1.yaml @@ -0,0 +1,11 @@ +openapi: 3.0.3 +info: + title: 车辆运维照片归属契约 + version: 1.0.0 + description: >- + 复用InternalBusinessFileController的内部业务文件上传、元数据和内容接口,不新增公网File路径。 + businessType固定FLEET_VEHICLE_RECORD_PHOTO,tenantId固定0,businessId固定Lastleg运维记录内部主键。 + 只接受非空JPG/PNG,最大10MiB,同时校验MIME及真实内容签名。拒绝其他客户、记录或类别引用。 + Lastleg负责车辆与记录归属、当前附件关联、权限、版本、上传幂等和不可变历史;File不读取Lastleg业务表。 + 上传不会代表已核验、付款或车辆恢复可派;不返回对象存储路径或匿名图片URL。 +paths: {} diff --git a/file/src/test/java/com/baiyue/file/config/CustomsFileExportContractTest.java b/file/src/test/java/com/baiyue/file/config/CustomsFileExportContractTest.java index 29fef5e9e..72945f2c6 100644 --- a/file/src/test/java/com/baiyue/file/config/CustomsFileExportContractTest.java +++ b/file/src/test/java/com/baiyue/file/config/CustomsFileExportContractTest.java @@ -11,10 +11,9 @@ class CustomsFileExportContractTest { private static final String TYPE = "LOADING_LIST_BUY_DECLARATION_ITEMS"; @Test void applicationAndNacosRegisterTheSameFirstlegProviderContract() throws Exception { - for (String file : new String[]{ - "file/src/main/resources/application.yml", - "config/nacos/local/file-service.yml", - "config/nacos/cn/file-service.yml"}) { + assertThat(Files.readString(repositoryRoot().resolve("file/src/main/resources/application.yml"))) + .contains("nacos:${spring.application.name}.yml").doesNotContain("optional:nacos:"); + for (String file : new String[]{"config/nacos/test/file-service.yml"}) { String source = Files.readString(repositoryRoot().resolve(file)); assertThat(source).contains(TYPE + ":") .contains("provider-service: firstleg-service") diff --git a/file/src/test/java/com/baiyue/file/config/DomesticWarehouseOrderExportContractTest.java b/file/src/test/java/com/baiyue/file/config/DomesticWarehouseOrderExportContractTest.java index 97bcb5f18..f676b361d 100644 --- a/file/src/test/java/com/baiyue/file/config/DomesticWarehouseOrderExportContractTest.java +++ b/file/src/test/java/com/baiyue/file/config/DomesticWarehouseOrderExportContractTest.java @@ -14,8 +14,9 @@ class DomesticWarehouseOrderExportContractTest { private static final String TYPE = "DOMESTIC_WAREHOUSE_ORDER_LIST"; @Test void applicationAndNacosRegisterTheSameOrderProvider() throws Exception { - for (String file : List.of("file/src/main/resources/application.yml", - "config/nacos/local/file-service.yml", "config/nacos/cn/file-service.yml")) { + assertThat(Files.readString(repositoryRoot().resolve("file/src/main/resources/application.yml"))) + .contains("nacos:${spring.application.name}.yml").doesNotContain("optional:nacos:"); + for (String file : List.of("config/nacos/test/file-service.yml")) { String source = Files.readString(repositoryRoot().resolve(file)); assertThat(source).contains(TYPE + ":") .contains("provider-service: order-service") diff --git a/file/src/test/java/com/baiyue/file/config/ExportInfrastructureContractTest.java b/file/src/test/java/com/baiyue/file/config/ExportInfrastructureContractTest.java index 41bcb25fa..4cd57f188 100644 --- a/file/src/test/java/com/baiyue/file/config/ExportInfrastructureContractTest.java +++ b/file/src/test/java/com/baiyue/file/config/ExportInfrastructureContractTest.java @@ -16,12 +16,14 @@ class ExportInfrastructureContractTest { assertTrue(compose.contains("FILE_EXPORT_PUBLISHER_ENABLED: ${FILE_EXPORT_PUBLISHER_ENABLED:-false}")); assertTrue(compose.contains("FILE_EXPORT_RECONCILER_ENABLED: ${FILE_EXPORT_RECONCILER_ENABLED:-false}")); assertTrue(compose.contains("-Xms1g -Xmx2g")); - assertTrue(compose.contains("MQ_TOPIC_PREFIX: ${MQ_TOPIC_PREFIX-}")); - assertTrue(compose.contains("ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER-}")); - String deployScript=read("..","deploy","scripts","deploy-services.sh"); - assertTrue(deployScript.contains("部署 file 服务时,环境文件必须显式包含 MQ_TOPIC_PREFIX")); - assertTrue(deployScript.contains("测试环境部署 file 服务时 MQ_TOPIC_PREFIX 必须为 test-")); - assertTrue(deployScript.contains("环境文件必须包含非空 ROCKETMQ_NAME_SERVER")); + assertTrue(compose.contains("MQ_TOPIC_PREFIX: test-")); + assertTrue(compose.contains("ROCKETMQ_NAME_SERVER: ${ROCKETMQ_NAME_SERVER}")); + String entry=read("..","deploy","scripts","deploy-services.sh"); + assertTrue(entry.contains("sh deploy/scripts/deploy-built-service.sh")); + String deployScript=read("..","deploy","scripts","deploy-built-service.sh"); + assertTrue(deployScript.contains("MQ_TOPIC_PREFIX must be test-")); + assertTrue(deployScript.contains("ROCKETMQ_NAME_SERVER")); + assertTrue(deployScript.contains("env_file_value")); } @Test void rocketMqBootstrapCreatesBothGroupsAndAReadableWorkerDlq() throws Exception { diff --git a/file/src/test/java/com/baiyue/file/service/DriverLicenseFileTest.java b/file/src/test/java/com/baiyue/file/service/DriverLicenseFileTest.java new file mode 100644 index 000000000..5664cf19f --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/DriverLicenseFileTest.java @@ -0,0 +1,52 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class DriverLicenseFileTest { + @TempDir Path root; + + @Test void licensesArePlatformOwnedWithPositiveDriverId() { + var category = BusinessFileCategory.require("FLEET_DRIVER_LICENSE"); + category.validateOwner(0); + assertThatThrownBy(() -> category.validateOwner(1)).isInstanceOf(BizException.class).hasMessageContaining("平台"); + assertThatThrownBy(() -> category.validateOwner(-1)).isInstanceOf(BizException.class); + assertThat(category.storageBusinessId("71")).isEqualTo("71"); + assertThatThrownBy(() -> category.storageBusinessId("0")).isInstanceOf(BizException.class); + } + + @Test void storesRealPdfAndRejectsWrongMimeAndSpoofedPdf() throws Exception { + var storage = storage(); + var pdf = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.7\nlicense".getBytes()); + var result = storage.store(0, "71", pdf, BusinessFileCategory.FLEET_DRIVER_LICENSE); + assertThat(storage.read(result.objectKey())).isEqualTo(pdf.getBytes()); + assertThatThrownBy(() -> storage.store(0, "71", new MockMultipartFile("file", "license.txt", "text/plain", "text".getBytes()), BusinessFileCategory.FLEET_DRIVER_LICENSE)) + .isInstanceOf(BizException.class).hasMessageContaining("仅支持"); + assertThatThrownBy(() -> storage.store(0, "71", new MockMultipartFile("file", "license.pdf", "application/pdf", "not pdf".getBytes()), BusinessFileCategory.FLEET_DRIVER_LICENSE)) + .isInstanceOf(BizException.class).hasMessageContaining("不是有效PDF"); + } + + @Test void rejectsMoreThanTenMibBeforeReadingBytes() { + var file = mock(MultipartFile.class); + when(file.isEmpty()).thenReturn(false); + when(file.getSize()).thenReturn(10485761L); + assertThatThrownBy(() -> storage().store(0, "71", file, BusinessFileCategory.FLEET_DRIVER_LICENSE)) + .isInstanceOf(BizException.class).hasMessageContaining("10MB"); + } + + private BusinessFileStorage storage() { + var storage = new BusinessFileStorage(); + ReflectionTestUtils.setField(storage, "configuredRoot", root); + storage.initializeRoot(); + return storage; + } +} diff --git a/file/src/test/java/com/baiyue/file/service/TripOperationEvidenceFileTest.java b/file/src/test/java/com/baiyue/file/service/TripOperationEvidenceFileTest.java new file mode 100644 index 000000000..0cac6cc4c --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/TripOperationEvidenceFileTest.java @@ -0,0 +1,79 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.entity.FileObjectEntity; +import com.baiyue.file.mapper.FileObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.multipart.MultipartFile; +import java.nio.file.Path; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class TripOperationEvidenceFileTest { + @TempDir Path root; + private static final String TYPE = "FLEET_TRIP_OPERATION_EVIDENCE"; + + @Test void platformTripOwnershipIsRequiredAndDistinctFromOtherFleetFiles() { + var category = BusinessFileCategory.require(TYPE); + category.validateOwner(0); + for (long customer : new long[]{-1, 1, 900}) + assertThatThrownBy(() -> category.validateOwner(customer)).isInstanceOf(BizException.class).hasMessageContaining("平台"); + assertThat(category.storageBusinessId("71")).isEqualTo("71"); + for (String id : new String[]{"0", "-1", "TRIP-71", "../71"}) + assertThatThrownBy(() -> category.storageBusinessId(id)).isInstanceOf(BizException.class); + assertThat(category).isNotEqualTo(BusinessFileCategory.FLEET_DRIVER_LICENSE) + .isNotEqualTo(BusinessFileCategory.FLEET_VEHICLE_RECORD_PHOTO); + } + + @Test void storesPdfAndImagesAndRejectsEmptySpoofedOrOversizeContent() throws Exception { + var storage = storage(); + var pdf = new MockMultipartFile("file", "里程凭证.pdf", "application/pdf", "%PDF-1.7\nmeasured".getBytes()); + var png = new MockMultipartFile("file", "油表.png", "image/png", image("png")); + var jpeg = new MockMultipartFile("file", "里程.jpg", "image/jpeg", image("jpeg")); + for (var file : new MockMultipartFile[]{pdf,png,jpeg}) { + var saved = storage.store(0,"71",file,BusinessFileCategory.FLEET_TRIP_OPERATION_EVIDENCE); + assertThat(storage.read(saved.objectKey())).isEqualTo(file.getBytes()); + } + for (var bad : new MockMultipartFile[]{ + new MockMultipartFile("file","empty.pdf","application/pdf",new byte[0]), + new MockMultipartFile("file","fake.pdf","application/pdf","text".getBytes()), + new MockMultipartFile("file","fake.png","image/png","text".getBytes()), + new MockMultipartFile("file","notes.txt","text/plain","text".getBytes())}) + assertThatThrownBy(() -> storage.store(0,"71",bad,BusinessFileCategory.FLEET_TRIP_OPERATION_EVIDENCE)).isInstanceOf(BizException.class); + MultipartFile oversized = mock(MultipartFile.class); + when(oversized.isEmpty()).thenReturn(false); when(oversized.getSize()).thenReturn(10L*1024*1024+1); + assertThatThrownBy(() -> storage.store(0,"71",oversized,BusinessFileCategory.FLEET_TRIP_OPERATION_EVIDENCE)) + .isInstanceOf(BizException.class).hasMessageContaining("10MB"); + verify(oversized,never()).getInputStream(); + } + + @Test void uploadPreservesTripScopeAndWrongOwnerOrMissingBindingNeverReadsBytes() { + var mapper = mock(FileObjectMapper.class); var storage = storage(); + var service = new InternalBusinessFileService(); + ReflectionTestUtils.setField(service,"files",mapper); ReflectionTestUtils.setField(service,"storage",storage); + when(mapper.insert(any(FileObjectEntity.class))).thenAnswer(call->{((FileObjectEntity)call.getArgument(0)).setId(501L);return 1;}); + var upload = new MockMultipartFile("file","record.pdf","application/pdf","%PDF-1.7\nrecord".getBytes()); + var metadata = service.upload(0,TYPE,"71",7,upload); + assertThat(metadata.ownerScope()).isEqualTo("PLATFORM"); assertThat(metadata.businessType()).isEqualTo(TYPE); + assertThat(metadata.businessId()).isEqualTo("71"); assertThat(metadata.fileId()).isEqualTo(501L); + assertThatThrownBy(()->service.metadata(1,TYPE,"71",501)).isInstanceOf(BizException.class).hasMessageContaining("平台"); + when(mapper.selectOne(any())).thenReturn(null); + assertThatThrownBy(()->service.download(0,TYPE,"72",501)).isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + } + + private BusinessFileStorage storage() { + var storage = new BusinessFileStorage(); ReflectionTestUtils.setField(storage,"configuredRoot",root); + storage.initializeRoot(); return storage; + } + + private byte[] image(String format) throws java.io.IOException { + var bytes = new java.io.ByteArrayOutputStream(); + var image = new java.awt.image.BufferedImage(2,2,java.awt.image.BufferedImage.TYPE_INT_RGB); + assertThat(javax.imageio.ImageIO.write(image,format,bytes)).isTrue(); + return bytes.toByteArray(); + } +} diff --git a/file/src/test/java/com/baiyue/file/service/VehicleCarePhotoTest.java b/file/src/test/java/com/baiyue/file/service/VehicleCarePhotoTest.java new file mode 100644 index 000000000..cafa7fd4d --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/VehicleCarePhotoTest.java @@ -0,0 +1,27 @@ +package com.baiyue.file.service; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import java.nio.file.Path; +import static org.assertj.core.api.Assertions.*; + +class VehicleCarePhotoTest { + @TempDir Path root; + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"FLEET_VEHICLE_RECORD_PHOTO", "FINANCE_VEHICLE_VOUCHER"}) + void recordPhotosAndCompanyVouchersArePlatformOwnedAndRequireRealImageBytes(String type) throws Exception { + var category = BusinessFileCategory.require(type); + category.validateOwner(0); + assertThatThrownBy(() -> category.validateOwner(7)).hasMessageContaining("平台"); + assertThatThrownBy(() -> category.validateOwner(-1)).hasMessageContaining("平台"); + assertThatThrownBy(() -> category.storageBusinessId("0")).hasMessageContaining("有效"); + var storage = new BusinessFileStorage(); ReflectionTestUtils.setField(storage, "configuredRoot", root); storage.initializeRoot(); + byte[] bytes = {(byte)0x89, 0x50, 0x4e, 0x47, 13, 10, 26, 10}; + var result = storage.store(0, "71", new MockMultipartFile("file", "meter.png", "image/png", bytes), category); + assertThat(storage.read(result.objectKey())).isEqualTo(bytes); + assertThatThrownBy(() -> storage.store(0, "71", new MockMultipartFile("file", "fake.png", "image/png", "not-an-image".getBytes()), category)).hasMessageContaining("不是有效图片"); + assertThatThrownBy(() -> storage.store(0, "71", new MockMultipartFile("file", "doc.pdf", "application/pdf", "%PDF-test".getBytes()), category)).hasMessageContaining("仅支持JPG"); + } +} diff --git a/finance/AGENTS.md b/finance/AGENTS.md index b4b5d848d..c59e2fdb1 100644 --- a/finance/AGENTS.md +++ b/finance/AGENTS.md @@ -3,15 +3,18 @@ ## 模块定位 - 本模块是独立财务服务,拥有客户结算条款、财务费用来源、CNY 账单与明细、客户资金账户及充值申请、应收款月度统计、客户对账确认、收款及分配、成本及分摊、账单修订、单据编号、幂等记录和财务审计状态。 -- 产品范围先读 `../../docs/财务管理/README.md`、`../../docs/财务管理/财务领域模型与跨服务契约.md` 和 `Firstleg-Finance跨服务契约.md`;产品确认表、五页验收契约和证据矩阵只在状态核对或验收任务中读取。只有海外仓费用阶段 0A—0E 的设计或实现任务再读取阶段技术契约,并明确区分其目标 V2 协议与当前 V1 代码事实。 +- 普通代码任务从本文件快速查询和目标 Controller/Service 进入;产品范围不明时查 `../../docs/财务管理/README.md`,领域归属变化时查 `../../docs/财务管理/财务领域模型与跨服务契约.md`,Firstleg 费用对接变化时查 `../../docs/财务管理/Firstleg-Finance跨服务契约.md` 对应章节。产品确认表、五页验收契约和证据矩阵仅按具体规则或验收问题读取,不默认同时加载。海外仓费用阶段 0A—0E 任务才读取阶段技术契约,目标 V2 与当前实现沿代码分别核对。 - 海外仓费用阶段 0 按需契约:`../../docs/财务管理/海外仓费用统一接入阶段0技术契约.md`;仅在该阶段的实现、接口或验收任务中读取,不作为普通 Finance 任务首读。 - Firstleg 拥有入库/柜次业务费用和履约事实,File 拥有凭证文件;Finance 通过内部接口读取或消费快照,不跨库查询,也不复制上游业务状态机。 ## 快速查询 +事件人工重试:`controller/EventOperationsController.java` 提供内部 `/internal/finance/event-operations/{entryId}` 查询及 `/retry` 写入,只允许资金结果事件;独立 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可修改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务提交,脚本见根 `scripts/event-outbox-operations.mjs`。 + | 目标 | 首选入口 | |---|---| | 平台账户、费用池、账单、收款与成本 API | `controller/PlatformFinanceController.java`,统一前缀 `/api/platform/finance/v1` | +| 公司车辆成本 | `fleet/`及`src/main/resources/openapi/finance-vehicle-costs-v1.yaml`;独立平台主体费用、入账和付款,不进入客户账务。编号VC/VP,凭证FINANCE_VEHICLE_VOUCHER;维修/救援由Lastleg完整快照事件接入 | | 平台财务凭证上传 | `PlatformFinanceController.java` 的 `POST /vouchers`、`client/FinanceFileClient.java`;文件类型固定 `FINANCE_VOUCHER`,业务 ID/客户范围使用当前客户 ID | | 客户只读账单、收款与应收 API | `controller/ClientFinanceController.java`,统一前缀 `/api/client/finance/v1` | | 订单与客户账户、费用来源审核与结算纳入 | `controller/FinancePhase1OperationController.java`、`service/FinancePhase1OperationService.java` | @@ -26,13 +29,14 @@ | 财务 OpenAPI | 五页 `../../docs/财务管理/openapi/finance-core-five-pages-v1.yaml`;人工财务 `finance-f1-manual-v1.yaml`;资金账户 `finance-wallet-v1.yaml`;柜级聚合 `src/main/resources/openapi/finance-container-settlement-v1.yaml` | | 业务费用来源事件入口 | Firstleg:`controller/InternalFinanceFeeSourceController.java`、`service/FinanceFeeSourceEventService.java`;海外完整快照:`controller/InternalBusinessFeeSourceController.java` 的 `/internal/finance/v1/business-fee-source-events`、`service/BusinessFeeSourceEventService.java`;事件模型 `../common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java` | | 查询模型、客户可见性与账单行入仓码 | `service/FinanceQueryService.java`、`dto/FinanceReadModels.java`;`StatementLine.inboundCode` 从对应费用来源不可变快照读取,供 Client 跳转客户隔离的入仓关系详情 | +| 尾程引用原头程费用 | `controller/InternalDeliveryFeeCoverageController.java`、`service/DeliveryFeeCoverageQueryService.java`、`dto/DeliveryFeeCoverageModels.java`;内部`delivery-fee-coverage/sources`按客户及实际原入仓ID批量读取FIRSTLEG已审核有效应收最新版本,仅查询本库,不调用下游、不写费用或资金。OpenAPI `src/main/resources/openapi/finance-delivery-fee-coverage-v1.yaml`;覆盖何种服务由Lastleg运营针对本次货物明确确认,不能由金额或费用名称自动推断 | | 账期、账单、收款、成本及幂等写模型 | `service/FinanceService.java` | | Firstleg 费用来源与柜次成本快照 | `client/FirstlegFeeSourceClient.java`、`client/FirstlegCostSnapshotClient.java`;生产端见 `../firstleg/AGENTS.md` | | Warehouse 供应商校验与名称快照 | `client/WarehouseSupplierClient.java`、`service/FinanceSupplierValidator.java`;供应商事实来源及类别契约见 `../warehouse/AGENTS.md`、`../warehouse/src/main/resources/openapi/supplier-v1.yaml` | | 收款/成本凭证 | `client/FinanceFileClient.java`;文件归属与下载规则见 `../file/AGENTS.md` | | 异常与响应包装 | `controller/FinanceExceptionHandler.java`、`controller/FinanceResponses.java` | | 数据库 | `src/main/resources/db/finance.sql`、`src/main/resources/db/fee-source-event-migration.sql`;存量/测试环境统一入口为 `../deploy/sql/头程数据库统一增量-MySQL8.sql` | -| 配置与网关 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/finance-service.yml`、`../gateway/src/main/resources/application.yml` | +| 配置与网关 | `src/main/resources/application.yml`、`../config/nacos/test/finance-service.yml`、`../gateway/src/main/resources/application.yml` | | 独立服务部署链 | `../Jenkinsfile`、`../Jenkinsfile.service`、`../deploy/service-map.yml`、`../deploy/docker-compose.yml`、`../deploy/scripts/deploy-services.sh`;契约检查见 `../scripts/finance-deployment-contract.test.mjs`,部署授权边界见 `../../docs/测试环境Jenkins部署指南.md` | | 测试环境全链路验收模块 | `../scripts/admin-test-env-modules/finance-full-fixtures.mjs`、`finance-reconciliation-full.mjs`、`finance-settlement-full.mjs` 及同名 `*-contract.test.mjs`;仅表示验收入口,不代表环境已部署或验收通过 | | 测试 | `src/test/java/com/baiyue/finance/`;执行 `mvn -pl finance -am test` | diff --git a/finance/pom.xml b/finance/pom.xml index 8da2c4701..8fce50d06 100644 --- a/finance/pom.xml +++ b/finance/pom.xml @@ -25,6 +25,7 @@ com.alibaba.cloudspring-cloud-starter-alibaba-nacos-config org.springframework.bootspring-boot-starter-actuator org.springframework.bootspring-boot-starter-testtest + com.h2databaseh2test diff --git a/finance/src/main/java/com/baiyue/finance/client/WalletResultEventBridgeClient.java b/finance/src/main/java/com/baiyue/finance/client/WalletResultEventBridgeClient.java deleted file mode 100644 index 1aab9c099..000000000 --- a/finance/src/main/java/com/baiyue/finance/client/WalletResultEventBridgeClient.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.baiyue.finance.client; - -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.event.EventEnvelope; -import org.springframework.cloud.openfeign.FeignClient; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; - -@FeignClient(name = "event-bridge-service", contextId = "financeWalletResultEventBridgeClient", - url = "${OVERSEAS_EVENT_BRIDGE_URL:${baiyue.wallet-event.event-bridge-url:}}") -public interface WalletResultEventBridgeClient { - @PostMapping("/internal/event-bridge/events") - ApiResponse receive(@RequestHeader("X-Internal-Service-Token") String token, - @RequestBody EventEnvelope envelope); - - record Acknowledgement(String eventId, String result, String receivedAt) { } -} diff --git a/finance/src/main/java/com/baiyue/finance/client/WalletResultLastlegClient.java b/finance/src/main/java/com/baiyue/finance/client/WalletResultLastlegClient.java new file mode 100644 index 000000000..793e75493 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/client/WalletResultLastlegClient.java @@ -0,0 +1,19 @@ +package com.baiyue.finance.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.web.EventDeliveryFeignConfiguration; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.DeliveryAck; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +@FeignClient(configuration = EventDeliveryFeignConfiguration.class, name = "lastleg-service", contextId = "financeWalletResultLastlegClient", + url = "${LASTLEG_SERVICE_URL:${baiyue.clients.lastleg.url:}}") +public interface WalletResultLastlegClient { + @PostMapping("/internal/lastleg/events") + ApiResponse receive(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody EventEnvelope envelope); + +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/EventOperationsController.java b/finance/src/main/java/com/baiyue/finance/controller/EventOperationsController.java new file mode 100644 index 000000000..d24be19e6 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/EventOperationsController.java @@ -0,0 +1,56 @@ +package com.baiyue.finance.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.outbox.OutboxOperations; +import com.baiyue.common.core.outbox.EventOperationsTokenGuard; +import com.baiyue.common.event.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.bind.annotation.*; +import java.util.Set; + +/** Internal-only operator recovery. It cannot mutate the original event or call a consumer directly. */ +@RestController +@RequestMapping("/internal/finance/event-operations") +public class EventOperationsController { + private final EventOperationsTokenGuard guard; + private final OutboxOperations operations; + + public EventOperationsController(JdbcTemplate jdbc, PlatformTransactionManager transactions, + ObjectMapper mapper, @Value("${baiyue.event-delivery.operations-token:}") String token) { + guard = new EventOperationsTokenGuard(token); + operations = new OutboxOperations(jdbc, transactions, + new OutboxOperations.Layout("fin_wallet_event_outbox", "event_id", "status", Set.of(EventTypes.WALLET_COMMAND_COMPLETED)), + json -> { + try { + EventEnvelope envelope = mapper.readValue(json, EventEnvelope.class).requireValid(); + if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) { + throw new IllegalArgumentException("Hash mismatch"); + } + return new OutboxOperations.Identity(envelope.eventId(), envelope.eventType(), envelope.payloadHash()); + } catch (Exception invalid) { + throw new BizException(ResultCode.CONFLICT, "原事件格式或摘要无效,禁止人工重试"); + } + }); + } + + @GetMapping("/{entryId}") + public ApiResponse inspect( + @RequestHeader(value = "X-Event-Operations-Token", required = false) String token, + @PathVariable String entryId) { + guard.require(token); + return ApiResponse.success(operations.inspect(entryId)); + } + + @PostMapping("/{entryId}/retry") + public ApiResponse retry( + @RequestHeader(value = "X-Event-Operations-Token", required = false) String token, + @PathVariable String entryId, @RequestBody OutboxOperations.RetryRequest request) { + guard.require(token); + return ApiResponse.success(operations.retry(entryId, request)); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/InternalBusinessFeeSourceController.java b/finance/src/main/java/com/baiyue/finance/controller/InternalBusinessFeeSourceController.java index 88f9dc71e..cc21914d0 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/InternalBusinessFeeSourceController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/InternalBusinessFeeSourceController.java @@ -25,7 +25,7 @@ public class InternalBusinessFeeSourceController { @Value("${baiyue.security.internal-service-token}") private String expectedToken; @PostMapping - public ApiResponse> consume( + public ApiResponse consume( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @RequestBody EventEnvelope event) { if (token == null || expectedToken == null || expectedToken.isBlank() @@ -33,6 +33,6 @@ public class InternalBusinessFeeSourceController { expectedToken.getBytes(StandardCharsets.UTF_8))) { throw new FinanceEventException("FIN-EVT-401", "内部服务令牌无效"); } - return FinanceResponses.success(Map.of("feeSourceId", service.consume(event))); + return FinanceResponses.success(service.consume(event)); } } diff --git a/finance/src/main/java/com/baiyue/finance/controller/InternalDeliveryFeeCoverageController.java b/finance/src/main/java/com/baiyue/finance/controller/InternalDeliveryFeeCoverageController.java new file mode 100644 index 000000000..2cc93aee6 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/InternalDeliveryFeeCoverageController.java @@ -0,0 +1,31 @@ +package com.baiyue.finance.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.finance.dto.DeliveryFeeCoverageModels; +import com.baiyue.finance.service.DeliveryFeeCoverageQueryService; +import com.baiyue.finance.service.FinanceEventException; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.*; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/internal/finance/v1/delivery-fee-coverage") +public class InternalDeliveryFeeCoverageController { + private final DeliveryFeeCoverageQueryService service; + @Value("${baiyue.security.internal-service-token}") private String expectedToken; + + @PostMapping("/sources") + public ApiResponse> sources( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody DeliveryFeeCoverageModels.Query request) { + if (token == null || expectedToken == null || expectedToken.isBlank() + || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8), expectedToken.getBytes(StandardCharsets.UTF_8))) + throw new FinanceEventException("FIN-EVT-401", "内部服务令牌无效"); + return FinanceResponses.success(service.query(request)); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/InternalTripFinanceController.java b/finance/src/main/java/com/baiyue/finance/controller/InternalTripFinanceController.java new file mode 100644 index 000000000..8666ed8d7 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/controller/InternalTripFinanceController.java @@ -0,0 +1,31 @@ +package com.baiyue.finance.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.finance.dto.TripFinanceModels; +import com.baiyue.finance.service.TripFinanceQueryService; +import com.baiyue.finance.service.FinanceEventException; +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.*; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@RestController +@RequestMapping("/internal/finance/v1/trip-receivables") +public class InternalTripFinanceController { + private final TripFinanceQueryService service; + private final String expectedToken; + public InternalTripFinanceController(TripFinanceQueryService service, + @Value("${baiyue.security.internal-service-token}") String expectedToken) { + this.service=service;this.expectedToken=expectedToken; + } + @PostMapping("/query") + public ApiResponse query( + @RequestHeader(value="X-Internal-Service-Token",required=false) String token, + @Valid @RequestBody TripFinanceModels.Query query) { + if(token==null || expectedToken==null || expectedToken.isBlank() + || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8),expectedToken.getBytes(StandardCharsets.UTF_8))) + throw new FinanceEventException("FIN-EVT-401","内部服务令牌无效"); + return FinanceResponses.success(service.query(query)); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/controller/InternalWalletCommandEventController.java b/finance/src/main/java/com/baiyue/finance/controller/InternalWalletCommandEventController.java index 51a94c358..ea45923b9 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/InternalWalletCommandEventController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/InternalWalletCommandEventController.java @@ -24,7 +24,7 @@ public class InternalWalletCommandEventController { @Value("${baiyue.security.internal-service-token}") private String expectedToken; @PostMapping - public ApiResponse> consume( + public ApiResponse consume( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @RequestBody EventEnvelope event) { if (token == null || expectedToken == null || expectedToken.isBlank() @@ -32,6 +32,6 @@ public class InternalWalletCommandEventController { expectedToken.getBytes(StandardCharsets.UTF_8))) { throw new FinanceEventException("FIN-EVT-401", "内部服务令牌无效"); } - return FinanceResponses.success(Map.of("eventId", service.consume(event))); + return FinanceResponses.success(service.consume(event)); } } diff --git a/finance/src/main/java/com/baiyue/finance/dto/DeliveryFeeCoverageModels.java b/finance/src/main/java/com/baiyue/finance/dto/DeliveryFeeCoverageModels.java new file mode 100644 index 000000000..323d8fef4 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/dto/DeliveryFeeCoverageModels.java @@ -0,0 +1,14 @@ +package com.baiyue.finance.dto; + +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.util.List; + +public final class DeliveryFeeCoverageModels { + private DeliveryFeeCoverageModels() { } + public record Query(@NotNull @Positive Long customerId, + @NotEmpty @Size(max = 2000) List<@NotNull @Positive Long> inboundIds) { } + public record Fee(long financeFeeId, long sourceFeeId, String sourceType, int sourceVersion, + long customerId, long sourceInboundId, String inboundCode, String feeItemCode, String feeItemName, + String currency, BigDecimal amount, String sourceHash, String status) { } +} diff --git a/finance/src/main/java/com/baiyue/finance/dto/TripFinanceModels.java b/finance/src/main/java/com/baiyue/finance/dto/TripFinanceModels.java new file mode 100644 index 000000000..79e1becb2 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/dto/TripFinanceModels.java @@ -0,0 +1,17 @@ +package com.baiyue.finance.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.util.List; + +public final class TripFinanceModels { + private TripFinanceModels() { } + public record Business(@Positive long customerId, @NotBlank @Size(max=64) String deliveryNo) { } + public record Query(@NotNull @Size(max=200) List<@Valid Business> businesses, boolean receiptsVisible) { } + public record Money(String currency, BigDecimal receivableAmount, BigDecimal receivedAmount) { } + public record Source(long customerId, String businessNo, String sourceNo, String feeItemName, int sourceVersion, + String currency, BigDecimal amount, String status) { } + public record Document(String billNo, String currency, BigDecimal receivedAmount, String allocationStatus, String reason) { } + public record Result(String status, List totals, List sources, List documents, String reason) { } +} diff --git a/finance/src/main/java/com/baiyue/finance/fleet/InternalVehicleCostController.java b/finance/src/main/java/com/baiyue/finance/fleet/InternalVehicleCostController.java new file mode 100644 index 000000000..4e93d58ea --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/fleet/InternalVehicleCostController.java @@ -0,0 +1,33 @@ +package com.baiyue.finance.fleet; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.DeliveryAck; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.finance.service.FinanceEventException; +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.*; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.List; + +@RestController +@RequestMapping("/internal/finance/v1/vehicle-costs") +public class InternalVehicleCostController { + private final VehicleCostService service; + private final String token; + public InternalVehicleCostController(VehicleCostService service, @Value("${baiyue.security.internal-service-token:}") String token) { + this.service = service; this.token = token; + } + @PostMapping("/events") + public ApiResponse receive(@RequestHeader(value="X-Internal-Service-Token", required=false) String supplied, + @RequestBody EventEnvelope event) { authorize(supplied); return ApiResponse.success(service.receive(event)); } + @PostMapping("/sources/query") + public ApiResponse> sources(@RequestHeader(value="X-Internal-Service-Token", required=false) String supplied, + @Valid @RequestBody VehicleCostModels.SourceQuery request) { authorize(supplied); return ApiResponse.success(service.sources(request)); } + private void authorize(String supplied) { + if (token == null || token.isBlank() || supplied == null + || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8), supplied.getBytes(StandardCharsets.UTF_8))) + throw new FinanceEventException("FIN-EVT-401", "内部服务令牌无效"); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/fleet/PlatformVehicleCostController.java b/finance/src/main/java/com/baiyue/finance/fleet/PlatformVehicleCostController.java new file mode 100644 index 000000000..00c2aaa2f --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/fleet/PlatformVehicleCostController.java @@ -0,0 +1,64 @@ +package com.baiyue.finance.fleet; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequestMapping("/api/platform/finance/v1/vehicle-costs") +public class PlatformVehicleCostController { + private final VehicleCostService costs; + private final VehicleCostVouchers vouchers; + public PlatformVehicleCostController(VehicleCostService costs, VehicleCostVouchers vouchers) { this.costs = costs; this.vouchers = vouchers; } + @GetMapping + @PreAuthorize("hasAuthority('platform:finance:vehicle-cost:list')") + public ApiResponse> list(@Valid VehicleCostModels.Query query) { return ApiResponse.success(costs.list(query)); } + @GetMapping("/{costNo}") + @PreAuthorize("hasAuthority('platform:finance:vehicle-cost:list')") + public ApiResponse detail(@PathVariable String costNo) { return ApiResponse.success(costs.detail(costNo)); } + @GetMapping("/{costNo}/history") + @PreAuthorize("hasAuthority('platform:finance:vehicle-cost:list')") + public ApiResponse> history(@PathVariable String costNo, @Valid VehicleCostModels.Query query) { return ApiResponse.success(costs.history(costNo, query)); } + @GetMapping("/{costNo}/payments") + @PreAuthorize("hasAuthority('platform:finance:vehicle-cost:list')") + public ApiResponse> payments(@PathVariable String costNo, @Valid VehicleCostModels.Query query) { return ApiResponse.success(costs.payments(costNo, query)); } + @PostMapping("/{costNo}/approve") + @PreAuthorize("hasAuthority('platform:finance:vehicle-cost:approve')") + public ApiResponse approve(@PathVariable String costNo, @AuthenticationPrincipal AuthenticatedUser user, + @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody VehicleCostModels.Command request) { + return ApiResponse.success(costs.approve(costNo, user.userId(), name(user), key, request)); + } + @PostMapping("/{costNo}/void") + @PreAuthorize("hasAuthority('platform:finance:vehicle-cost:void')") + public ApiResponse voidCost(@PathVariable String costNo, @AuthenticationPrincipal AuthenticatedUser user, + @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody VehicleCostModels.Command request) { + return ApiResponse.success(costs.voidCost(costNo, user.userId(), name(user), key, request)); + } + @PostMapping("/{costNo}/payments") + @PreAuthorize("hasAuthority('platform:finance:vehicle-cost:pay')") + public ApiResponse pay(@PathVariable String costNo, @AuthenticationPrincipal AuthenticatedUser user, + @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody VehicleCostModels.Payment request) { + return ApiResponse.success(costs.pay(costNo, user.userId(), name(user), key, request)); + } + @PostMapping(value="/{costNo}/vouchers", consumes="multipart/form-data") + @PreAuthorize("hasAuthority('platform:finance:vehicle-cost:upload')") + public ApiResponse upload(@PathVariable String costNo, @AuthenticationPrincipal AuthenticatedUser user, + @RequestPart("file") MultipartFile file) { + return ApiResponse.success(vouchers.upload(costs.voucherOwner(costNo), user.userId(), file)); + } + @GetMapping("/{costNo}/vouchers/{fileId}/content") + @PreAuthorize("hasAuthority('platform:finance:vehicle-cost:list')") + public ResponseEntity download(@PathVariable String costNo, @PathVariable long fileId) { + return vouchers.download(costs.voucherOwner(costNo), fileId); + } + private static String name(AuthenticatedUser user) { + if (user.displayName() != null && !user.displayName().isBlank()) return user.displayName(); + return user.username() == null || user.username().isBlank() ? "—" : user.username(); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostClientConfiguration.java b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostClientConfiguration.java new file mode 100644 index 000000000..5efb62c2e --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostClientConfiguration.java @@ -0,0 +1,8 @@ +package com.baiyue.finance.fleet; + +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods=false) +@EnableFeignClients(clients=VehicleCostFileClient.class) +public class VehicleCostClientConfiguration { } diff --git a/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostExceptionHandler.java b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostExceptionHandler.java new file mode 100644 index 000000000..a1ea497b6 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostExceptionHandler.java @@ -0,0 +1,11 @@ +package com.baiyue.finance.fleet; + +import com.baiyue.finance.controller.FinanceExceptionHandler; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** Keep Finance business errors and diagnostic identifiers on the dedicated fleet endpoints. */ +@Order(Ordered.HIGHEST_PRECEDENCE) +@RestControllerAdvice(basePackages="com.baiyue.finance.fleet") +public class VehicleCostExceptionHandler extends FinanceExceptionHandler { } diff --git a/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostFileClient.java b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostFileClient.java new file mode 100644 index 000000000..341b47120 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostFileClient.java @@ -0,0 +1,23 @@ +package com.baiyue.finance.fleet; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.http.ResponseEntity; + +@FeignClient(name="file-service", contextId="financeVehicleCostFiles", url="${baiyue.clients.file.url:}", + path="/internal/file/v1/business-files/0/FINANCE_VEHICLE_VOUCHER") +public interface VehicleCostFileClient { + @PostMapping(value="/{costId}", consumes="multipart/form-data") + ApiResponse upload(@RequestHeader("X-Internal-Service-Token") String token, + @RequestHeader("X-Operator-Id") long operatorId, @PathVariable long costId, @RequestPart("file") MultipartFile file); + @GetMapping("/{costId}/{fileId}") + ApiResponse metadata(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long costId, @PathVariable long fileId); + @GetMapping("/{costId}/{fileId}/content") + ResponseEntity download(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long costId, @PathVariable long fileId); + record Metadata(Long fileId, String ownerScope, Long tenantId, String businessType, String businessId, + String fileName, String contentType, Long sizeBytes) { } +} diff --git a/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostModels.java b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostModels.java new file mode 100644 index 000000000..226ae849c --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostModels.java @@ -0,0 +1,45 @@ +package com.baiyue.finance.fleet; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + +/** Company-owned costs are deliberately separate from customer fee sources and settlements. */ +public final class VehicleCostModels { + private VehicleCostModels() { } + public static final String EVENT_TYPE = "CompanyVehicleCostSubmitted"; + public record Source(@NotBlank @Pattern(regexp="REPAIR|RESCUE|TRIP_DIRECT") String sourceType, + @NotBlank @Size(max=64) String sourceNo, @Positive long sourceVersion, + @NotBlank @Pattern(regexp="[1-9][0-9]*") String vehicleId, + @NotBlank @Size(max=64) String vehiclePlate, @Size(max=64) String tripNo, + @Positive long supplierId, @NotBlank @Size(max=200) String feeName, + @NotNull LocalDate businessDate, @NotBlank @Pattern(regexp="CNY|USD") String originalCurrency, + @NotNull @DecimalMin(value="0", inclusive=false) @Digits(integer=12,fraction=2) BigDecimal originalAmount, + @NotNull @DecimalMin(value="0", inclusive=false) @Digits(integer=9,fraction=6) BigDecimal exchangeRate, + @NotBlank @Pattern(regexp="[0-9]{4}-[0-9]{2}") String exchangeRateMonth, + @NotNull @DecimalMin(value="0", inclusive=false) @Digits(integer=15,fraction=2) BigDecimal amount, + @Size(max=1000) String remark) { } + public record Command(@NotNull @PositiveOrZero Long expectedVersion, @Size(max=1000) String reason) { } + public record Payment(@NotNull @PositiveOrZero Long expectedVersion, + @NotNull @DecimalMin(value="0", inclusive=false) @Digits(integer=15,fraction=2) BigDecimal amount, + @NotNull Instant paidAt, @NotBlank @Pattern(regexp="ONLINE|BANK_TRANSFER|CASH|OTHER") String method, + @Size(max=128) String referenceNo, + @NotEmpty @Size(max=20) List<@NotBlank @Pattern(regexp="[1-9][0-9]*") String> voucherFileIds, + @Size(max=1000) String remark) { } + public record Query(@Min(1) Integer page, @Min(1) @Max(100) Integer size) { + public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; } + public long offset() { return ((long) page - 1) * size; } + } + public record SourceKey(@NotBlank @Pattern(regexp="REPAIR|RESCUE|TRIP_DIRECT") String sourceType, + @NotBlank @Size(max=64) String sourceNo) { } + public record SourceQuery(@NotEmpty @Size(max=100) List<@Valid SourceKey> sources) { } + public record Cost(String costNo, String subjectCode, Source source, String supplierName, String currency, + String status, BigDecimal paidAmount, BigDecimal outstandingAmount, long recordVersion) { } + public record History(long recordVersion, String action, String reason, String operatorName, + Instant occurredAt, Cost snapshot) { } + public record Paid(String paymentNo, String currency, BigDecimal amount, Instant paidAt, String method, String referenceNo, + List voucherFileIds, String operatorName, Instant recordedAt, String remark) { } +} diff --git a/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostService.java b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostService.java new file mode 100644 index 000000000..5e6ecaa6e --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostService.java @@ -0,0 +1,230 @@ +package com.baiyue.finance.fleet; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.event.*; +import com.baiyue.finance.service.FinanceDocumentNumberGenerator; +import com.baiyue.finance.service.FinanceEventException; +import com.baiyue.finance.service.FinanceSupplierValidator; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.validation.Validator; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.*; +import java.util.*; + +/** Only this company-cost ledger is written. Customer costs, settlements and wallets are untouched. */ +@Service +public class VehicleCostService { + private final JdbcTemplate jdbc; + private final ObjectMapper json; + private final Validator validator; + private final FinanceSupplierValidator suppliers; + private final FinanceDocumentNumberGenerator numbers; + private final VehicleCostVouchers vouchers; + private record Row(long id, long subjectId, VehicleCostModels.Cost cost) { } + private final RowMapper rowMapper = (r, ignored) -> { + var source = read(r.getString("source_json"), VehicleCostModels.Source.class); + var paid = r.getBigDecimal("paid_amount"); + var status = r.getString("status"); + var outstanding = Set.of("APPROVED", "PARTIALLY_PAID", "PAID").contains(status) ? source.amount().subtract(paid) : null; + return new Row(r.getLong("id"), r.getLong("subject_id"), new VehicleCostModels.Cost(r.getString("cost_no"), + "PLATFORM_DEFAULT", source, r.getString("supplier_name"), "CNY", status, paid, outstanding, r.getLong("record_version"))); + }; + public VehicleCostService(JdbcTemplate jdbc, ObjectMapper json, Validator validator, + FinanceSupplierValidator suppliers, FinanceDocumentNumberGenerator numbers, VehicleCostVouchers vouchers) { + this.jdbc = jdbc; this.json = json; this.validator = validator; + this.suppliers = suppliers; this.numbers = numbers; this.vouchers = vouchers; + } + + @Transactional(isolation=Isolation.READ_COMMITTED) + public DeliveryAck receive(EventEnvelope event) { + try { event.requireValid(); } catch (RuntimeException error) { throw bad("公司车辆费用事件信封无效"); } + if (!VehicleCostModels.EVENT_TYPE.equals(event.eventType()) || event.eventVersion() != 1 + || !"0".equals(event.tenantId()) || !"COMPANY_VEHICLE_COST".equals(event.aggregateType()) + || !EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(event.payload()), event.payloadHash())) + throw bad("公司车辆费用事件类型、归属或摘要无效"); + if (event.payload().has("customerId")) throw bad("公司车辆费用不得携带客户归属"); + var source = read(event.payload().toString(), VehicleCostModels.Source.class); + validate(source); + if (!event.aggregateId().equals(source.sourceType() + ":" + source.sourceNo()) || event.aggregateVersion() != source.sourceVersion()) + throw bad("费用来源与事件聚合版本不一致"); + long subject = subject(true); + var priorEvent = jdbc.queryForList("SELECT payload_hash,status FROM fin_inbox_event WHERE source_system='LASTLEG' AND event_id=? FOR UPDATE", event.eventId()); + if (!priorEvent.isEmpty()) { + if (!event.payloadHash().equals(priorEvent.get(0).get("payload_hash"))) throw conflict("同一事件对应不同公司费用内容"); + if (!"PROCESSED".equals(priorEvent.get(0).get("status"))) throw conflict("费用事件尚未处理完成"); + return DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE); + } + String sourceHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(json.valueToTree(source))); + var current = jdbc.query("SELECT * FROM fin_vehicle_cost WHERE subject_id=? AND source_type=? AND source_no=? FOR UPDATE", + rowMapper, subject, source.sourceType(), source.sourceNo()); + Row row; + if (!current.isEmpty()) { + row = current.get(0); + long version = row.cost().source().sourceVersion(); + if (source.sourceVersion() < version) throw conflict("费用来源版本已过期"); + if (source.sourceVersion() == version) { + var storedHash = jdbc.queryForObject("SELECT source_hash FROM fin_vehicle_cost WHERE id=?", String.class, row.id()); + if (!sourceHash.equals(storedHash)) throw conflict("同一费用来源版本对应不同内容"); + inbox(event, row.id()); + return DeliveryAck.of(event, DeliveryAck.Outcome.DUPLICATE); + } + if (!Set.of("PENDING", "VOIDED").contains(row.cost().status()) || row.cost().paidAmount().signum() != 0) + throw conflict("已入账费用须先作废;已付款费用不能替换"); + String supplier = suppliers.requireActive(source.supplierId(), null).name(); + jdbc.update("UPDATE fin_vehicle_cost SET source_version=?,source_hash=?,source_json=?,supplier_id=?,supplier_name=?,amount=?,status='PENDING',record_version=record_version+1,updated_at=? WHERE id=?", + source.sourceVersion(), sourceHash, write(source), source.supplierId(), supplier, source.amount(), now(), row.id()); + row = required(row.cost().costNo(), false); + } else { + String supplier = suppliers.requireActive(source.supplierId(), null).name(); + long id = IdWorker.getId(); + String no = numbers.next("VC"); + jdbc.update("INSERT INTO fin_vehicle_cost(id,subject_id,cost_no,source_type,source_no,source_version,source_hash,source_json,supplier_id,supplier_name,amount,paid_amount,status,record_version,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,0,'PENDING',0,?,?)", + id, subject, no, source.sourceType(), source.sourceNo(), source.sourceVersion(), sourceHash, write(source), source.supplierId(), supplier, source.amount(), now(), now()); + row = required(no, false); + } + audit(row, "RECEIVE", event.eventId(), event.payloadHash(), 0, "系统", "接收公司车辆费用来源"); + inbox(event, row.id()); + return DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED); + } + + @Transactional(readOnly=true, isolation=Isolation.REPEATABLE_READ) + public PageResult list(VehicleCostModels.Query query) { + validate(query); long subject = subject(false); + long count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_vehicle_cost WHERE subject_id=?", Long.class, subject); + var rows = jdbc.query("SELECT * FROM fin_vehicle_cost WHERE subject_id=? ORDER BY updated_at DESC,id DESC LIMIT ? OFFSET ?", + rowMapper, subject, query.size(), query.offset()); + return new PageResult<>(rows.stream().map(Row::cost).toList(), query.page(), query.size(), count, count / query.size() + (count % query.size() == 0 ? 0 : 1)); + } + @Transactional(readOnly=true) + public VehicleCostModels.Cost detail(String no) { return required(no, false).cost(); } + @Transactional(readOnly=true) + public List sources(VehicleCostModels.SourceQuery query) { + validate(query); long subject = subject(false); + String predicates = String.join(" OR ", Collections.nCopies(query.sources().size(), "(source_type=? AND source_no=?)")); + var args = new ArrayList(); args.add(subject); + for (var key : query.sources()) { args.add(key.sourceType()); args.add(key.sourceNo()); } + return jdbc.query("SELECT * FROM fin_vehicle_cost WHERE subject_id=? AND (" + predicates + ")", rowMapper, args.toArray()).stream().map(Row::cost).toList(); + } + @Transactional(readOnly=true, isolation=Isolation.REPEATABLE_READ) + public PageResult history(String no, VehicleCostModels.Query query) { + validate(query); long id = required(no, false).id(); + long count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_vehicle_cost_revision WHERE cost_id=?", Long.class, id); + var rows = jdbc.query("SELECT * FROM fin_vehicle_cost_revision WHERE cost_id=? ORDER BY record_version DESC LIMIT ? OFFSET ?", + (r, ignored) -> new VehicleCostModels.History(r.getLong("record_version"), r.getString("action"), r.getString("reason"), + r.getString("operator_name"), r.getTimestamp("occurred_at").toLocalDateTime().toInstant(ZoneOffset.UTC), + read(r.getString("snapshot_json"), VehicleCostModels.Cost.class)), id, query.size(), query.offset()); + return new PageResult<>(rows, query.page(), query.size(), count, count / query.size() + (count % query.size() == 0 ? 0 : 1)); + } + @Transactional(readOnly=true, isolation=Isolation.REPEATABLE_READ) + public PageResult payments(String no, VehicleCostModels.Query query) { + validate(query); long id = required(no, false).id(); + long count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_vehicle_cost_payment WHERE cost_id=?", Long.class, id); + var rows = jdbc.query("SELECT payment_json FROM fin_vehicle_cost_payment WHERE cost_id=? ORDER BY id DESC LIMIT ? OFFSET ?", + (r, ignored) -> read(r.getString("payment_json"), VehicleCostModels.Paid.class), id, query.size(), query.offset()); + return new PageResult<>(rows, query.page(), query.size(), count, count / query.size() + (count % query.size() == 0 ? 0 : 1)); + } + + @Transactional(isolation=Isolation.READ_COMMITTED) + public VehicleCostModels.Cost approve(String no, long operator, String operatorName, String key, VehicleCostModels.Command command) { + return transition(no, operator, operatorName, key, command, "APPROVE"); + } + @Transactional(isolation=Isolation.READ_COMMITTED) + public VehicleCostModels.Cost voidCost(String no, long operator, String operatorName, String key, VehicleCostModels.Command command) { + return transition(no, operator, operatorName, key, command, "VOID"); + } + private VehicleCostModels.Cost transition(String no, long operator, String name, String key, VehicleCostModels.Command command, String action) { + validate(command); subject(true); var row = required(no, true); + String hash = commandHash(no, command); if (replay(row, operator, action, key, hash)) return row.cost(); + requireVersion(row, command.expectedVersion()); + if (action.equals("APPROVE")) { + if (!"PENDING".equals(row.cost().status())) throw conflict("仅待入账费用可以入账"); + suppliers.requireActive(row.cost().source().supplierId(), null); + } else if (!Set.of("PENDING", "APPROVED").contains(row.cost().status()) || row.cost().paidAmount().signum() != 0) { + throw conflict("已付款或已作废费用不能直接作废"); + } + jdbc.update("UPDATE fin_vehicle_cost SET status=?,record_version=record_version+1,updated_at=? WHERE id=?", + action.equals("APPROVE") ? "APPROVED" : "VOIDED", now(), row.id()); + var changed = required(no, false); + audit(changed, action, key, hash, operator, name, text(command.reason(), action.equals("APPROVE") ? "确认公司车辆费用入账" : "作废公司车辆费用")); + return changed.cost(); + } + @Transactional(isolation=Isolation.READ_COMMITTED) + public VehicleCostModels.Cost pay(String no, long operator, String name, String key, VehicleCostModels.Payment request) { + validate(request); subject(true); var row = required(no, true); + String hash = commandHash(no, request); if (replay(row, operator, "PAY", key, hash)) return row.cost(); + requireVersion(row, request.expectedVersion()); + if (!Set.of("APPROVED", "PARTIALLY_PAID").contains(row.cost().status())) throw conflict("仅已入账且未付清费用可以登记付款"); + if (request.paidAt().isAfter(Instant.now())) throw bad("实际付款时间不能晚于当前时间"); + if (request.amount().compareTo(row.cost().outstandingAmount()) > 0) throw conflict("付款金额不得超过未付余额"); + suppliers.requireActive(row.cost().source().supplierId(), null); + vouchers.validate(row.id(), request.voucherFileIds()); + var payment = new VehicleCostModels.Paid(numbers.next("VP"), "CNY", request.amount(), request.paidAt(), request.method(), + request.referenceNo(), List.copyOf(request.voucherFileIds()), text(name, "—"), Instant.now(), request.remark()); + jdbc.update("INSERT INTO fin_vehicle_cost_payment(id,cost_id,payment_no,amount,payment_json,created_at) VALUES(?,?,?,?,?,?)", + IdWorker.getId(), row.id(), payment.paymentNo(), request.amount(), write(payment), now()); + var paid = row.cost().paidAmount().add(request.amount()); + jdbc.update("UPDATE fin_vehicle_cost SET paid_amount=?,status=?,record_version=record_version+1,updated_at=? WHERE id=?", + paid, paid.compareTo(row.cost().source().amount()) == 0 ? "PAID" : "PARTIALLY_PAID", now(), row.id()); + var changed = required(no, false); + audit(changed, "PAY", key, hash, operator, name, text(request.remark(), "登记公司车辆费用实际付款")); + return changed.cost(); + } + public long voucherOwner(String no) { return required(no, false).id(); } + + private void validate(Object value) { + if (value == null || !validator.validate(value).isEmpty()) throw bad("公司车辆费用参数不完整或格式无效"); + if (value instanceof VehicleCostModels.Source source) { + if ("TRIP_DIRECT".equals(source.sourceType()) && (source.tripNo() == null || source.tripNo().isBlank())) + throw bad("车次直接成本须保留真实车次号"); + if (!source.exchangeRateMonth().equals(source.businessDate().toString().substring(0, 7))) + throw bad("费用业务日期或汇率月份无效"); + if ("CNY".equals(source.originalCurrency()) && source.exchangeRate().compareTo(BigDecimal.ONE) != 0) + throw bad("CNY费用汇率必须为1"); + if (source.originalAmount().multiply(source.exchangeRate()).setScale(2, RoundingMode.HALF_UP).compareTo(source.amount()) != 0) + throw bad("公司车辆费用原币及折算金额不守恒"); + } + } + private long subject(boolean lock) { + var ids = jdbc.queryForList("SELECT id FROM fin_subject WHERE subject_code='PLATFORM_DEFAULT' AND status='ACTIVE'" + (lock ? " FOR UPDATE" : ""), Long.class); + if (ids.size() != 1) throw conflict("平台财务主体不存在或未启用"); + return ids.get(0); + } + private Row required(String no, boolean lock) { + var rows = jdbc.query("SELECT * FROM fin_vehicle_cost WHERE subject_id=? AND cost_no=?" + (lock ? " FOR UPDATE" : ""), rowMapper, subject(false), no); + if (rows.isEmpty()) throw new FinanceEventException("FIN_404", "公司车辆费用不存在"); + return rows.get(0); + } + private boolean replay(Row row, long operator, String action, String key, String hash) { + if (operator <= 0 || key == null || key.isBlank() || key.length() > 128) throw bad("缺少有效操作人或幂等键"); + var rows = jdbc.queryForList("SELECT cost_id,request_hash FROM fin_vehicle_cost_revision WHERE operator_id=? AND action=? AND request_key=?", + operator, action, key); + if (rows.isEmpty()) return false; + if (((Number) rows.get(0).get("cost_id")).longValue() != row.id() || !hash.equals(rows.get(0).get("request_hash"))) + throw conflict("幂等键已用于其他财务操作内容"); + return true; + } + private void audit(Row row, String action, String key, String hash, long operator, String name, String reason) { + jdbc.update("INSERT INTO fin_vehicle_cost_revision(id,cost_id,record_version,action,request_key,request_hash,operator_id,operator_name,reason,snapshot_json,occurred_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)", + IdWorker.getId(), row.id(), row.cost().recordVersion(), action, key, hash, operator, text(name, "—"), reason, write(row.cost()), now()); + } + private void inbox(EventEnvelope event, long id) { + jdbc.update("INSERT INTO fin_inbox_event(event_id,source_system,event_type,payload,payload_hash,status,result_resource_id,processed_at) VALUES(?,'LASTLEG',?,?,?,'PROCESSED',?,?)", + event.eventId(), event.eventType(), write(event), event.payloadHash(), id, now()); + } + private static void requireVersion(Row row, long version) { if (row.cost().recordVersion() != version) throw conflict("公司车辆费用版本已变化"); } + private String commandHash(String no, Object request) { return EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(json.valueToTree(Map.of("costNo", no, "request", request)))); } + private String write(Object value) { try { return json.writeValueAsString(value); } catch (JsonProcessingException error) { throw new IllegalStateException("公司费用快照无法保存", error); } } + private T read(String value, Class type) { try { return json.readerFor(type).with(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).readValue(value); } catch (JsonProcessingException error) { throw bad("公司费用快照格式无效"); } } + private static LocalDateTime now() { return LocalDateTime.now(ZoneOffset.UTC); } + private static String text(String value, String fallback) { return value == null || value.isBlank() ? fallback : value.trim(); } + private static FinanceEventException bad(String message) { return new FinanceEventException("FIN_400", message); } + private static FinanceEventException conflict(String message) { return new FinanceEventException("FIN_409", message); } +} diff --git a/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostVouchers.java b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostVouchers.java new file mode 100644 index 000000000..e447e45a8 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/fleet/VehicleCostVouchers.java @@ -0,0 +1,61 @@ +package com.baiyue.finance.fleet; + +import com.baiyue.finance.service.FinanceEventException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Service +public class VehicleCostVouchers { + private final VehicleCostFileClient files; + private final String token; + public VehicleCostVouchers(VehicleCostFileClient files, @Value("${baiyue.security.internal-service-token:}") String token) { + this.files = files; this.token = token; + } + public void validate(long costId, List ids) { + if (ids == null || ids.isEmpty() || ids.size() > 20 || new HashSet<>(ids).size() != ids.size()) throw bad("付款凭证不能为空或重复"); + for (var id : ids) metadata(costId, fileId(id)); + } + public VehicleCostFileClient.Metadata upload(long costId, long operator, MultipartFile file) { + requireToken(); + var response = call(() -> files.upload(token, operator, costId, file)); + if (response == null || !response.successful()) throw unavailable(); + return requireOwner(costId, response.data(), null); + } + public VehicleCostFileClient.Metadata metadata(long costId, long fileId) { + requireToken(); + var response = call(() -> files.metadata(token, costId, fileId)); + if (response == null || !response.successful()) throw unavailable(); + return requireOwner(costId, response.data(), fileId); + } + public ResponseEntity download(long costId, long fileId) { + var metadata = metadata(costId, fileId); + var response = call(() -> files.download(token, costId, fileId)); + if (response == null || !response.getStatusCode().is2xxSuccessful() || response.getBody() == null + || response.getBody().length != metadata.sizeBytes() || response.getHeaders().getContentType() == null + || !metadata.contentType().equalsIgnoreCase(response.getHeaders().getContentType().toString())) throw unavailable(); + return response; + } + private static VehicleCostFileClient.Metadata requireOwner(long costId, VehicleCostFileClient.Metadata value, Long expected) { + if (value == null || value.fileId() == null || value.fileId() <= 0 || expected != null && !expected.equals(value.fileId()) + || !"PLATFORM".equals(value.ownerScope()) || !Long.valueOf(0).equals(value.tenantId()) + || !"FINANCE_VEHICLE_VOUCHER".equals(value.businessType()) || !Long.toString(costId).equals(value.businessId()) + || value.contentType() == null || !Set.of("image/jpeg", "image/png").contains(value.contentType()) || value.sizeBytes() == null || value.sizeBytes() <= 0) + throw bad("公司车辆付款凭证不存在或归属不匹配"); + return value; + } + private static long fileId(String value) { + try { long id = Long.parseLong(value); if (id > 0) return id; } catch (RuntimeException ignored) { } + throw bad("付款凭证编号无效"); + } + private void requireToken() { if (token == null || token.isBlank()) throw unavailable(); } + private static T call(java.util.function.Supplier request) { + try { return request.get(); } catch (feign.FeignException unavailable) { throw unavailable(); } + } + private static FinanceEventException bad(String message) { return new FinanceEventException("FIN_400", message); } + private static FinanceEventException unavailable() { return new FinanceEventException("FIN_503", "公司车辆付款凭证服务不可用"); } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java index 2089ae768..9049e00da 100644 --- a/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java +++ b/finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java @@ -2,10 +2,10 @@ package com.baiyue.finance.service; import com.baiyue.common.event.BusinessFeeSourceChangedV1; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; -import com.baiyue.common.event.RegionCode; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; @@ -28,16 +28,18 @@ public class BusinessFeeSourceEventService { private final FinanceSupplierValidator supplierValidator; @Transactional - public long consume(EventEnvelope envelope) { + public DeliveryAck consume(EventEnvelope envelope) { try { envelope.requireValid(); } catch (RuntimeException exception) { throw error("FIN-EVT-001", "跨区费用事件信封无效"); } if (!EventTypes.BUSINESS_FEE_SOURCE_CHANGED.equals(envelope.eventType()) - || envelope.eventVersion() != 1 || envelope.sourceRegion() != RegionCode.OVERSEAS - || envelope.targetRegion() != RegionCode.CN) { - throw error("FIN-EVT-001", "跨区费用事件类型、版本或区域无效"); + || envelope.eventVersion() != 1) { + throw error("FIN-EVT-001", "跨区费用事件类型或版本无效"); + } + if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) { + throw new FinanceEventException("FIN-EVT-002", "事件信封摘要与实际载荷不一致"); } BusinessFeeSourceChangedV1 source = payload(envelope); validate(envelope, source); @@ -51,7 +53,7 @@ public class BusinessFeeSourceEventService { if (!envelope.payloadHash().equals(row.get("payload_hash"))) throw error("FIN-EVT-002", "同一事件ID对应不同费用快照"); if ("PROCESSED".equals(row.get("status")) && row.get("result_resource_id") instanceof Number id) - return id.longValue(); + return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE); throw error("FIN-EVT-003", "相同事件正在处理或上次处理失败"); } jdbc.update("INSERT INTO fin_inbox_event(event_id,source_system,event_type,payload,payload_hash,status) VALUES (?,?,?,?,?,'RECEIVED')", @@ -59,7 +61,7 @@ public class BusinessFeeSourceEventService { long result = apply(sourceSystem, source); jdbc.update("UPDATE fin_inbox_event SET status='PROCESSED',result_resource_id=?,processed_at=CURRENT_TIMESTAMP(3),error_code=NULL WHERE source_system=? AND event_id=?", result, sourceSystem, envelope.eventId()); - return result; + return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED); } private long apply(String system, BusinessFeeSourceChangedV1 source) { diff --git a/finance/src/main/java/com/baiyue/finance/service/DeliveryFeeCoverageQueryService.java b/finance/src/main/java/com/baiyue/finance/service/DeliveryFeeCoverageQueryService.java new file mode 100644 index 000000000..e94da1f4d --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/DeliveryFeeCoverageQueryService.java @@ -0,0 +1,40 @@ +package com.baiyue.finance.service; + +import com.baiyue.finance.dto.DeliveryFeeCoverageModels; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.Map; + +/** Finance eligibility only. The business operator must explicitly confirm which services are included. */ +@Service +@RequiredArgsConstructor +public class DeliveryFeeCoverageQueryService { + private final JdbcTemplate jdbc; + + @Transactional(readOnly = true) + public List query(DeliveryFeeCoverageModels.Query request) { + return new NamedParameterJdbcTemplate(jdbc).query(""" + SELECT f.id,f.source_fee_id,f.source_type,f.source_fee_version,f.customer_id,f.inbound_id, + CASE WHEN NULLIF(TRIM(f.inbound_code),'') IS NOT NULL THEN f.inbound_code + WHEN f.source_type='INBOUND_FEE' THEN f.source_no ELSE NULL END AS coverage_inbound_code, + f.fee_item_code,f.fee_item_name,f.currency_code,f.amount,f.source_hash,f.status + FROM fin_fee_source f + WHERE f.customer_id=:customerId AND f.inbound_id IN (:inboundIds) + AND f.source_system='FIRSTLEG' AND f.source_type IN ('INBOUND_FEE','CONTAINER_FEE') + AND f.direction='RECEIVABLE' AND f.review_status='APPROVED' + AND f.status IN ('AVAILABLE','SETTLED') + AND NOT EXISTS (SELECT 1 FROM fin_fee_source newer + WHERE newer.source_system=f.source_system AND newer.source_type=f.source_type + AND newer.source_fee_id=f.source_fee_id AND newer.source_fee_version>f.source_fee_version) + ORDER BY f.inbound_id,f.fee_item_code,f.source_fee_id + """, Map.of("customerId", request.customerId(), "inboundIds", request.inboundIds().stream().distinct().toList()), + (row, index) -> new DeliveryFeeCoverageModels.Fee(row.getLong("id"), row.getLong("source_fee_id"), + row.getString("source_type"), row.getInt("source_fee_version"), row.getLong("customer_id"), row.getLong("inbound_id"), + row.getString("coverage_inbound_code"), row.getString("fee_item_code"), row.getString("fee_item_name"), + row.getString("currency_code"), row.getBigDecimal("amount"), row.getString("source_hash"), row.getString("status"))); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceDocumentNumberGenerator.java b/finance/src/main/java/com/baiyue/finance/service/FinanceDocumentNumberGenerator.java index d9c02ac8a..d9557200c 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceDocumentNumberGenerator.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceDocumentNumberGenerator.java @@ -17,7 +17,7 @@ import java.util.Set; public class FinanceDocumentNumberGenerator { private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd", Locale.ROOT); - private static final Set SUPPORTED_PREFIXES = Set.of("REC", "AR", "AP", "RC", "AC"); + private static final Set SUPPORTED_PREFIXES = Set.of("REC", "AR", "AP", "RC", "AC", "VC", "VP"); private static final long MAX_DAILY_SEQUENCE = 9_999L; private final JdbcTemplate jdbc; diff --git a/finance/src/main/java/com/baiyue/finance/service/TripFinanceQueryService.java b/finance/src/main/java/com/baiyue/finance/service/TripFinanceQueryService.java new file mode 100644 index 000000000..4ebf610ea --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/service/TripFinanceQueryService.java @@ -0,0 +1,85 @@ +package com.baiyue.finance.service; + +import com.baiyue.finance.dto.TripFinanceModels; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; +import java.util.*; + +/** Only whole documents whose complete source set belongs to this trip are attributable. No ratios. */ +@Service +public class TripFinanceQueryService { + private final JdbcTemplate jdbc; + public TripFinanceQueryService(JdbcTemplate jdbc) {this.jdbc=jdbc;} + record Fee(long id,long customerId,String businessNo,String sourceNo,String name,int version,String currency,BigDecimal amount,String status) { } + record Bill(long id,long customerId,String no,String currency) { } + @Transactional(readOnly=true, isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) + public TripFinanceModels.Result query(TripFinanceModels.Query request) { + if(request.businesses().isEmpty())return new TripFinanceModels.Result("PENDING_ALLOCATION",List.of(),List.of(),List.of(),"尚无可明确归属车次的派送费用来源"); + var args=new HashMap();var predicates=new ArrayList(); + int index=0; + for(var b:request.businesses().stream().distinct().toList()) { + predicates.add("(f.customer_id=:customer"+index+" AND f.business_no=:business"+index+")"); + args.put("customer"+index,b.customerId());args.put("business"+index,b.deliveryNo());index++; + } + var fees=new NamedParameterJdbcTemplate(jdbc).query(""" + SELECT f.id,f.customer_id,f.business_no,f.source_no,f.fee_item_name,f.source_fee_version,f.currency_code,f.amount,f.status + FROM fin_fee_source f WHERE f.source_system='LASTLEG' AND f.business_type='DELIVERY_ORDER' + AND f.source_type='OVERSEAS_DELIVERY_FEE' AND f.direction='RECEIVABLE' + AND f.review_status='APPROVED' AND f.status IN ('AVAILABLE','BILLED','SETTLED') + AND NOT EXISTS (SELECT 1 FROM fin_fee_source newer WHERE newer.source_system=f.source_system + AND newer.source_type=f.source_type AND newer.source_fee_id=f.source_fee_id + AND newer.source_fee_version>f.source_fee_version) + AND ( + """+String.join(" OR ",predicates)+") ORDER BY f.customer_id,f.business_no,f.source_fee_id LIMIT 2001", + args,(r,i)->new Fee(r.getLong("id"),r.getLong("customer_id"),r.getString("business_no"),r.getString("source_no"),r.getString("fee_item_name"),r.getInt("source_fee_version"),r.getString("currency_code"),r.getBigDecimal("amount"),r.getString("status"))); + if(fees.size()>2000)throw new FinanceEventException("FIN_400","车次费用来源超过2000条,请核对来源范围"); + return project(fees,request); + } + TripFinanceModels.Result project(List fees,TripFinanceModels.Query request) { + var receivables=new TreeMap();var received=new TreeMap(); + var pending=new HashSet();var docs=new ArrayList(); + var eligible=new HashMap();fees.forEach(f->{eligible.put(f.id(),f);receivables.merge(f.currency(),f.amount(),BigDecimal::add);}); + boolean missing=request.businesses().stream().anyMatch(b->fees.stream().noneMatch(f->f.customerId()==b.customerId()&&f.businessNo().equals(b.deliveryNo()))); + if(request.receiptsVisible()&&!fees.isEmpty()) { + var named=new NamedParameterJdbcTemplate(jdbc); + var refs=named.query(""" + SELECT b.id,b.tenant_id,b.bill_no,b.currency_code,l.source_id FROM fin_bill_line l + JOIN fin_bill b ON b.id=l.bill_id AND b.tenant_id=l.tenant_id + JOIN fin_fee_source f ON f.id=l.source_id AND f.customer_id=l.tenant_id AND f.source_fee_version=l.source_version + WHERE l.source_id IN (:ids) AND b.status IN ('ISSUED','PARTIALLY_PAID','PAID') + """,Map.of("ids",eligible.keySet()),(r,i)->new Object[]{ + new Bill(r.getLong("id"),r.getLong("tenant_id"),r.getString("bill_no"),r.getString("currency_code")),r.getLong("source_id")}); + var bills=new LinkedHashMap();var byFee=new HashMap>(); + for(var ref:refs){var bill=(Bill)ref[0];bills.put(bill.id(),bill);byFee.computeIfAbsent((Long)ref[1],k->new HashSet<>()).add(bill.id());} + for(var fee:fees)if(byFee.getOrDefault(fee.id(),Set.of()).size()!=1)pending.add(fee.currency()); + if(!bills.isEmpty()) { + var lines=named.query("SELECT bill_id,tenant_id,source_id,source_version,currency_code FROM fin_bill_line WHERE bill_id IN (:ids)", + Map.of("ids",bills.keySet()),(r,i)->new Object[]{r.getLong("bill_id"),r.getLong("tenant_id"),r.getLong("source_id"),r.getInt("source_version"),r.getString("currency_code")}); + var byBill=new HashMap>();for(var line:lines)byBill.computeIfAbsent((Long)line[0],k->new ArrayList<>()).add(line); + var payments=named.query(""" + SELECT rb.bill_id,r.currency_code,SUM(rb.applied_amount) amount FROM fin_receipt_bill rb + JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id + JOIN fin_bill b ON b.id=rb.bill_id AND b.tenant_id=rb.tenant_id AND b.currency_code=r.currency_code + WHERE rb.bill_id IN (:ids) AND r.status='CONFIRMED' GROUP BY rb.bill_id,r.currency_code + """,Map.of("ids",bills.keySet()),(r,i)->new Object[]{r.getLong("bill_id"),r.getString("currency_code"),r.getBigDecimal("amount")}); + var paidByBill=new HashMap();for(var payment:payments)paidByBill.put((Long)payment[0],(BigDecimal)payment[2]); + for(var bill:bills.values()) { + var billLines=byBill.getOrDefault(bill.id(),List.of()); + boolean exact=!billLines.isEmpty()&&billLines.stream().allMatch(l->{var f=eligible.get((Long)l[2]);return f!=null&&f.customerId()==bill.customerId()&&(Long)l[1]==bill.customerId()&&f.version()==(Integer)l[3]&&f.currency().equals(bill.currency())&&f.currency().equals(l[4]);}); + BigDecimal paid=exact?paidByBill.get(bill.id()):null; + String reason=!exact?"账单包含本车次以外或无法明确归属的来源,实收待分配":paid==null?"尚无已确认的本车次收款分配":"整张账单的全部来源明确归属本车次"; + if(paid==null)pending.add(bill.currency());else received.merge(bill.currency(),paid,BigDecimal::add); + docs.add(new TripFinanceModels.Document(bill.no(),bill.currency(),paid,paid==null?"PENDING_ALLOCATION":"ATTRIBUTED",reason)); + } + } + } + var totals=receivables.entrySet().stream().map(e->new TripFinanceModels.Money(e.getKey(),e.getValue(), + !request.receiptsVisible()||pending.contains(e.getKey())?null:received.get(e.getKey()))).toList(); + String status=fees.isEmpty()||missing?"PENDING_ALLOCATION":"AVAILABLE"; + String reason=missing?"部分派送尚无已审核有效的Finance来源,已确认金额不代表完整车次应收":"仅包含已审核有效的Finance来源;实收只计明确归属本车次的已确认分配"; + return new TripFinanceModels.Result(status,totals,fees.stream().map(f->new TripFinanceModels.Source(f.customerId(),f.businessNo(),f.sourceNo(),f.name(),f.version(),f.currency(),f.amount(),f.status())).toList(),List.copyOf(docs),reason); + } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java b/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java index 843b3d644..e42137930 100644 --- a/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java +++ b/finance/src/main/java/com/baiyue/finance/service/WalletCommandEventService.java @@ -1,12 +1,12 @@ package com.baiyue.finance.service; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.event.EventEnvelopeValidator; import com.baiyue.common.event.EventIdentifiers; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; -import com.baiyue.common.event.RegionCode; import com.baiyue.common.event.WalletCommandCompletedV1; import com.baiyue.common.event.WalletCommandRequestedV1; import com.baiyue.finance.dto.FinanceWalletDtos.WalletFreezeRequest; @@ -14,10 +14,11 @@ import com.baiyue.finance.dto.FinanceWalletDtos.WalletHold; import com.baiyue.finance.dto.FinanceWalletDtos.WalletHoldActionRequest; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.RequiredArgsConstructor; import org.springframework.dao.DuplicateKeyException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; import org.springframework.transaction.support.TransactionTemplate; import java.math.BigDecimal; @@ -27,35 +28,62 @@ import java.util.Locale; import java.util.Set; @Service -@RequiredArgsConstructor public class WalletCommandEventService { private static final Set OPERATIONS = Set.of("FREEZE", "SETTLE", "RELEASE"); + private static final Set FINAL_REJECTIONS = Set.of( + "FIN-WALLET-INSUFFICIENT", "FIN-WALLET-HOLD-409", "FIN-IDEMPOTENCY-CONFLICT", "FIN-REQ-400"); private final JdbcTemplate jdbc; private final ObjectMapper objectMapper; private final FinanceWalletService walletService; private final TransactionTemplate transactions; - public String consume(EventEnvelope envelope) { + public WalletCommandEventService(JdbcTemplate jdbc, ObjectMapper objectMapper, + FinanceWalletService walletService, PlatformTransactionManager transactionManager) { + this.jdbc = jdbc; + this.objectMapper = objectMapper; + this.walletService = walletService; + this.transactions = new TransactionTemplate(transactionManager); + this.transactions.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + } + + public DeliveryAck consume(EventEnvelope envelope) { WalletCommandRequestedV1 command = validate(envelope); - String existing = existingHash(envelope.eventId()); - if (existing != null && !existing.equals(envelope.payloadHash())) { + // Keep event identity durable even if processing rolls back. + transactions.executeWithoutResult(status -> reserve(envelope)); + try { + return transactions.execute(status -> { + requireLockedIdentity(envelope); + if (resultExists(envelope.eventId())) return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE); + // Wallet methods join this transaction: money, the Inbox outcome and + // the result Outbox commit together, or all roll back. + WalletHold hold = execute(command); + if (hold == null) throw new IllegalStateException("资金操作未返回处理结果"); + finalizeResult(envelope, completed(envelope.eventId(), command, "SUCCEEDED", + hold.status(), hold.amount(), null, null)); + return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED); + }); + } catch (FinanceEventException exception) { + if (!FINAL_REJECTIONS.contains(exception.code())) throw exception; + // A joined wallet transaction may have been marked rollback-only. Persist + // a final business rejection only AFTER that transaction has rolled back. + return transactions.execute(status -> { + requireLockedIdentity(envelope); + if (resultExists(envelope.eventId())) return DeliveryAck.of(envelope, DeliveryAck.Outcome.DUPLICATE); + finalizeResult(envelope, completed(envelope.eventId(), command, "FAILED", + null, command.amount(), exception.code(), exception.getMessage())); + return DeliveryAck.of(envelope, DeliveryAck.Outcome.APPLIED); + }); + } + } + + private void requireLockedIdentity(EventEnvelope envelope) { + List hashes = jdbc.queryForList( + "SELECT payload_hash FROM fin_wallet_command_inbox WHERE event_id=? FOR UPDATE", + String.class, envelope.eventId()); + if (hashes.size() != 1) throw new IllegalStateException("资金指令身份记录不存在"); + if (!hashes.get(0).equals(envelope.payloadHash())) { throw new FinanceEventException("FIN-EVT-002", "同一事件ID对应不同资金指令"); } - if (existing == null) reserve(envelope); - if (resultExists(envelope.eventId())) return envelope.eventId(); - - WalletCommandCompletedV1 result; - try { - WalletHold hold = transactions.execute(status -> execute(command)); - result = completed(envelope.eventId(), command, "SUCCEEDED", hold.status(), hold.amount(), null, null); - } catch (FinanceEventException exception) { - result = completed(envelope.eventId(), command, "FAILED", null, command.amount(), exception.code(), exception.getMessage()); - } catch (RuntimeException exception) { - result = completed(envelope.eventId(), command, "FAILED", null, command.amount(), "FIN-WALLET-COMMAND-FAILED", safe(exception.getMessage())); - } - WalletCommandCompletedV1 finalResult = result; - transactions.executeWithoutResult(status -> finalizeResult(envelope, finalResult)); - return envelope.eventId(); } private WalletHold execute(WalletCommandRequestedV1 command) { @@ -79,9 +107,11 @@ public class WalletCommandEventService { try { EventEnvelopeValidator.requireValid(envelope); } catch (RuntimeException exception) { throw new FinanceEventException("FIN-EVT-001", "资金指令信封无效"); } if (!EventTypes.WALLET_COMMAND_REQUESTED.equals(envelope.eventType()) - || envelope.eventVersion() != WalletCommandRequestedV1.VERSION - || envelope.sourceRegion() != RegionCode.OVERSEAS || envelope.targetRegion() != RegionCode.CN) { - throw new FinanceEventException("FIN-EVT-001", "资金指令类型、版本或区域无效"); + || envelope.eventVersion() != WalletCommandRequestedV1.VERSION) { + throw new FinanceEventException("FIN-EVT-001", "资金指令类型或版本无效"); + } + if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) { + throw new FinanceEventException("FIN-EVT-002", "事件信封摘要与实际载荷不一致"); } try { WalletCommandRequestedV1 value = objectMapper.treeToValue(envelope.payload(), WalletCommandRequestedV1.class); @@ -104,26 +134,25 @@ public class WalletCommandEventService { try { jdbc.update("INSERT INTO fin_wallet_command_inbox(event_id,event_type,payload_hash,payload_json,status,received_at) VALUES(?,?,?,CAST(? AS JSON),'PROCESSING',NOW(3))", envelope.eventId(), envelope.eventType(), envelope.payloadHash(), json(envelope)); - } catch (DuplicateKeyException ignored) { } + } catch (DuplicateKeyException duplicate) { + // The durable winner is verified again under SELECT FOR UPDATE before + // any wallet call, including when both requests initially saw no record. + } } private void finalizeResult(EventEnvelope commandEnvelope, WalletCommandCompletedV1 result) { JsonNode payload = objectMapper.valueToTree(result); String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload)); EventEnvelope completed = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.WALLET_COMMAND_COMPLETED, - WalletCommandCompletedV1.VERSION, Instant.now(), RegionCode.CN, RegionCode.OVERSEAS, + WalletCommandCompletedV1.VERSION, Instant.now(), String.valueOf(result.customerId()), "WALLET_HOLD", result.businessNo(), 1L, null, commandEnvelope.traceId(), commandEnvelope.correlationId(), commandEnvelope.eventId(), payload, payloadHash).requireValid(); - jdbc.update("INSERT IGNORE INTO fin_wallet_event_outbox(event_id,event_key,payload_json,status,next_retry_at,created_at,updated_at) VALUES(?,?,CAST(? AS JSON),'PENDING',NOW(3),NOW(3),NOW(3))", + jdbc.update("INSERT INTO fin_wallet_event_outbox(event_id,event_key,payload_json,status,next_retry_at,created_at,updated_at) VALUES(?,?,CAST(? AS JSON),'PENDING',NOW(3),NOW(3),NOW(3))", completed.eventId(), "WALLET_RESULT:" + commandEnvelope.eventId(), json(completed)); jdbc.update("UPDATE fin_wallet_command_inbox SET status=?,result_json=CAST(? AS JSON),processed_at=NOW(3) WHERE event_id=?", result.result(), json(result), commandEnvelope.eventId()); } - private String existingHash(String eventId) { - List rows = jdbc.queryForList("SELECT payload_hash FROM fin_wallet_command_inbox WHERE event_id=?", String.class, eventId); - return rows.isEmpty() ? null : rows.get(0); - } private boolean resultExists(String eventId) { Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM fin_wallet_event_outbox WHERE event_key=?", Integer.class, "WALLET_RESULT:" + eventId); @@ -139,9 +168,4 @@ public class WalletCommandEventService { catch (Exception exception) { throw new FinanceEventException("FIN-EVT-001", "资金事件序列化失败"); } } private static boolean blank(String value) { return value == null || value.isBlank(); } - private static String safe(String value) { - if (value == null || value.isBlank()) return "资金指令执行失败"; - String normalized = value.replaceAll("[\\r\\n\\t]+", " "); - return normalized.length() <= 500 ? normalized : normalized.substring(0, 500); - } } diff --git a/finance/src/main/java/com/baiyue/finance/service/WalletResultOutboxRelay.java b/finance/src/main/java/com/baiyue/finance/service/WalletResultOutboxRelay.java index 2a57aaed1..d338d3de2 100644 --- a/finance/src/main/java/com/baiyue/finance/service/WalletResultOutboxRelay.java +++ b/finance/src/main/java/com/baiyue/finance/service/WalletResultOutboxRelay.java @@ -1,7 +1,16 @@ package com.baiyue.finance.service; import com.baiyue.common.event.EventEnvelope; -import com.baiyue.finance.client.WalletResultEventBridgeClient; +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import com.baiyue.common.core.outbox.EventDeliveryProducer; +import com.baiyue.common.core.outbox.EventDeliveryJournal.Outcome; +import com.baiyue.common.web.EventDeliveryMetrics; +import com.baiyue.common.web.EventDeliveryMetrics.Stream; +import com.baiyue.common.web.EventDeliveryObservation; +import com.baiyue.common.web.EventDeliveryFailure; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.baiyue.common.core.retry.BoundedRetryPolicy; +import com.baiyue.finance.client.WalletResultLastlegClient; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -18,13 +27,18 @@ import java.util.UUID; @Component @RequiredArgsConstructor @Slf4j -public class WalletResultOutboxRelay { +public class WalletResultOutboxRelay implements EventDeliveryProducer { private final JdbcTemplate jdbc; + private final EventDeliveryJournal journal; + private final EventDeliveryMetrics metrics; private final ObjectMapper objectMapper; - private final WalletResultEventBridgeClient client; + private final WalletResultLastlegClient client; @Value("${baiyue.security.internal-service-token}") private String internalToken; - @Value("${baiyue.wallet-event.max-attempts:12}") private int maxAttempts; - private final String worker = UUID.randomUUID().toString(); + @Value("${baiyue.event-delivery.max-attempts:12}") private int maxAttempts = 12; + @Value("${baiyue.event-delivery.base-delay-seconds:5}") private long baseDelaySeconds = 5; + @Value("${baiyue.event-delivery.max-delay-seconds:1800}") private long maxDelaySeconds = 1800; + @Value("${baiyue.event-delivery.jitter:0.1}") private double retryJitter = 0.1; + @Value("${baiyue.event-delivery.lease-seconds:60}") private long leaseSeconds = 60; @Scheduled(fixedDelayString = "${baiyue.wallet-event.fixed-delay-ms:5000}") public void relay() { @@ -33,33 +47,77 @@ public class WalletResultOutboxRelay { } public void deliver(String eventId) { + var policy = retryPolicy(); + String worker = UUID.randomUUID().toString(); LocalDateTime now = LocalDateTime.now(); - int claimed = jdbc.update("UPDATE fin_wallet_event_outbox SET status='SENDING',locked_by=?,locked_at=?,updated_at=? WHERE event_id=? AND status='PENDING' AND next_retry_at<=?", - worker, now, now, eventId, now); - if (claimed != 1) return; - Map row = jdbc.queryForMap("SELECT payload_json,retry_count FROM fin_wallet_event_outbox WHERE event_id=?", eventId); + if (!journal.begin(worker, Stream.FINANCE.service, "WalletCommandCompleted", eventId, eventId, + Stream.FINANCE.target("WalletCommandCompleted"), now, + () -> jdbc.update("UPDATE fin_wallet_event_outbox SET status='SENDING',locked_by=?,locked_at=?,updated_at=? WHERE event_id=? AND status='PENDING' AND next_retry_at<=?", + worker, now, now, eventId, now) == 1)) return; + Map row = jdbc.queryForMap("SELECT payload_json,retry_count FROM fin_wallet_event_outbox WHERE event_id=? AND status='SENDING' AND locked_by=?", eventId, worker); + int failures = ((Number) row.get("retry_count")).intValue(); + if (policy.exhausted(failures)) { + journal.complete(worker, Outcome.DEAD, LocalDateTime.now(), "RETRY_EXHAUSTED", null, + () -> jdbc.update("UPDATE fin_wallet_event_outbox SET status='DEAD',locked_by=NULL,locked_at=NULL,last_error=?,updated_at=NOW(3) WHERE event_id=? AND status='SENDING' AND locked_by=?", + "自动投递次数已耗尽(含租约超时)", eventId, worker)); + return; + } + Outcome outcome; + String diagnostic = null; + String auditError = null; + boolean requestStarted = false; try { EventEnvelope envelope = objectMapper.readValue(String.valueOf(row.get("payload_json")), EventEnvelope.class).requireValid(); + requestStarted = true; var response = client.receive(internalToken, envelope); - if (response == null || !response.successful() || response.data() == null - || !eventId.equals(response.data().eventId())) throw new IllegalStateException("海外事件桥未确认资金结果事件"); - jdbc.update("UPDATE fin_wallet_event_outbox SET status='PUBLISHED',published_at=NOW(3),locked_by=NULL,locked_at=NULL,last_error=NULL,updated_at=NOW(3) WHERE event_id=? AND locked_by=?", - eventId, worker); + EventDeliveryFailure.requireSuccessful(response); + response.data().requireMatches(envelope); + outcome = Outcome.valueOf(response.data().outcome().name()); } catch (Exception exception) { - int retries = ((Number) row.get("retry_count")).intValue() + 1; - String status = retries >= maxAttempts ? "DEAD" : "PENDING"; - long delay = Math.min(1800, 5L << Math.min(retries, 8)); - jdbc.update("UPDATE fin_wallet_event_outbox SET status=?,retry_count=?,next_retry_at=?,locked_by=NULL,locked_at=NULL,last_error=?,updated_at=NOW(3) WHERE event_id=? AND locked_by=?", - status, retries, now.plusSeconds(delay), safe(exception.getMessage()), eventId, worker); - log.warn("资金结果事件投递失败 eventId={}, reason={}", eventId, exception.getMessage()); + diagnostic = EventDeliveryFailure.diagnostic(exception, internalToken); + boolean permanent = EventDeliveryFailure.isPermanent(exception) + || (!requestStarted && (exception instanceof JsonProcessingException || exception instanceof IllegalArgumentException)); + auditError = EventDeliveryObservation.errorCode(exception, permanent, requestStarted); + outcome = permanent || policy.exhausted(failures + 1) ? Outcome.DEAD : Outcome.RETRY; } + LocalDateTime finished = LocalDateTime.now(); + LocalDateTime next = outcome == Outcome.RETRY ? finished.plus(policy.delayAfterFailure(failures + 1)) : null; + Outcome result = outcome; + String error = diagnostic; + journal.complete(worker, result, finished, auditError, next, () -> { + if (result == Outcome.RETRY || result == Outcome.DEAD) { + return jdbc.update("UPDATE fin_wallet_event_outbox SET status=?,retry_count=?,next_retry_at=?,locked_by=NULL,locked_at=NULL,last_error=?,updated_at=? WHERE event_id=? AND status='SENDING' AND locked_by=?", + result == Outcome.DEAD ? "DEAD" : "PENDING", failures + 1, next == null ? finished : next, safe(error), finished, eventId, worker); + } + return jdbc.update("UPDATE fin_wallet_event_outbox SET status='PUBLISHED',published_at=?,locked_by=NULL,locked_at=NULL,last_error=NULL,updated_at=? WHERE event_id=? AND status='SENDING' AND locked_by=?", + finished, finished, eventId, worker); + }); } + @Scheduled(fixedDelayString = "${baiyue.event-delivery.metrics-sample-ms:30000}") + public void sampleMetrics() { metrics.sample(Stream.FINANCE, LocalDateTime.now()); } + private List candidates() { return jdbc.queryForList("SELECT event_id FROM fin_wallet_event_outbox WHERE status='PENDING' AND next_retry_at<=NOW(3) ORDER BY created_at,event_id LIMIT 50", String.class); } private void recoverExpired() { - jdbc.update("UPDATE fin_wallet_event_outbox SET status='PENDING',locked_by=NULL,locked_at=NULL,next_retry_at=NOW(3),updated_at=NOW(3) WHERE status='SENDING' AND locked_at row : jdbc.queryForList("SELECT event_id,locked_by,retry_count FROM fin_wallet_event_outbox WHERE status='SENDING' AND locked_at jdbc.update("UPDATE fin_wallet_event_outbox SET status=?,retry_count=?,next_retry_at=?,locked_by=NULL,locked_at=NULL,last_error=?,updated_at=? WHERE event_id=? AND status='SENDING' AND locked_by=? AND locked_at0 AND paid_amount>=0 AND paid_amount<=amount), + CONSTRAINT chk_fin_vehicle_cost_owner CHECK (subject_id>0 AND supplier_id>0), + CONSTRAINT chk_fin_vehicle_cost_status CHECK (status IN ('PENDING','APPROVED','PARTIALLY_PAID','PAID','VOIDED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公司车辆费用,不进入客户账务'; + +CREATE TABLE fin_vehicle_cost_revision ( + id BIGINT NOT NULL COMMENT '公司车辆费用历史主键', + cost_id BIGINT NOT NULL COMMENT '所属公司费用', + record_version BIGINT NOT NULL COMMENT '费用版本', + action VARCHAR(32) NOT NULL COMMENT '接收入账作废付款动作', + request_key VARCHAR(128) NOT NULL COMMENT '动作幂等键', + request_hash VARCHAR(71) NOT NULL COMMENT '请求摘要', + operator_id BIGINT NOT NULL COMMENT '操作人或系统标识', + operator_name VARCHAR(128) NOT NULL COMMENT '操作人名称快照', + reason VARCHAR(1000) NULL COMMENT '更正或操作说明', + snapshot_json JSON NOT NULL COMMENT '不可变费用快照', + occurred_at DATETIME(3) NOT NULL COMMENT '操作时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_vehicle_cost_revision (cost_id,record_version), + UNIQUE KEY uk_fin_vehicle_cost_request (operator_id,action,request_key), + CONSTRAINT fk_fin_vehicle_revision_cost FOREIGN KEY (cost_id) REFERENCES fin_vehicle_cost(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公司车辆费用不可变审计'; + +CREATE TABLE fin_vehicle_cost_payment ( + id BIGINT NOT NULL COMMENT '公司车辆付款主键', + cost_id BIGINT NOT NULL COMMENT '所属公司费用', + payment_no VARCHAR(64) NOT NULL COMMENT '公司付款业务编号', + amount DECIMAL(17,2) NOT NULL COMMENT 'CNY实际付款金额', + payment_json JSON NOT NULL COMMENT '实际付款及凭证不可变快照', + created_at DATETIME(3) NOT NULL COMMENT '付款登记时间UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_fin_vehicle_payment_no (payment_no), + KEY idx_fin_vehicle_payment_cost (cost_id,id), + CONSTRAINT fk_fin_vehicle_payment_cost FOREIGN KEY (cost_id) REFERENCES fin_vehicle_cost(id), + CONSTRAINT chk_fin_vehicle_payment_amount CHECK (amount>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='公司车辆实际付款记录'; + CREATE TABLE IF NOT EXISTS fin_customer_settlement_profile ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', @@ -572,3 +631,38 @@ CREATE TABLE IF NOT EXISTS fin_wallet_event_outbox ( UNIQUE KEY uk_fin_wallet_event_key (event_key), KEY idx_fin_wallet_event_publish (status,next_retry_at,created_at) ) ENGINE=InnoDB COMMENT='国内Finance到海外资金结果可靠发送箱'; + +CREATE TABLE IF NOT EXISTS event_delivery_retry_audit ( + request_id VARCHAR(36) NOT NULL COMMENT '人工重试请求UUID和幂等键', + producer_table VARCHAR(64) NOT NULL COMMENT '本服务发件箱表名', + entry_id VARCHAR(64) NOT NULL COMMENT '发件箱记录主键', + event_id VARCHAR(128) NOT NULL COMMENT '原事件全局标识', + event_type VARCHAR(64) NOT NULL COMMENT '原事件类型', + payload_hash VARCHAR(71) NOT NULL COMMENT '原事件不可变负载摘要', + operator_name VARCHAR(128) NOT NULL COMMENT '持有运维凭据的操作人标识', + reason VARCHAR(500) NOT NULL COMMENT '人工重试原因', + previous_failures INT NOT NULL COMMENT '重试前累计失败次数', + previous_error VARCHAR(1000) NULL COMMENT '重试前最后一次脱敏错误', + requested_at DATETIME(3) NOT NULL COMMENT '重试申请提交时间', + PRIMARY KEY (request_id), + KEY idx_event_retry_entry (producer_table,entry_id,requested_at), + KEY idx_event_retry_event (event_id,requested_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务事件人工重试审计,与恢复待发送状态同事务提交'; + +CREATE TABLE IF NOT EXISTS event_delivery_attempt ( + attempt_id VARCHAR(36) NOT NULL COMMENT '本次投递租约UUID和审计主键', + service_name VARCHAR(32) NOT NULL COMMENT '本库所属生产服务', + event_type VARCHAR(96) NOT NULL COMMENT '白名单业务事件类型', + entry_id VARCHAR(128) NOT NULL COMMENT '生产者发件箱主键', + event_id VARCHAR(128) NULL COMMENT '合法原事件UUID或ULID;格式错误时为空', + target_name VARCHAR(64) NOT NULL COMMENT '固定目标业务服务', + started_at DATETIME(3) NOT NULL COMMENT '领取及审计事务提交前的开始时间', + finished_at DATETIME(3) NULL COMMENT '尝试完成或租约过期恢复时间', + duration_ms BIGINT NULL COMMENT '尝试耗时毫秒', + outcome VARCHAR(24) NOT NULL COMMENT 'STARTED/APPLIED/DUPLICATE/STALE/RETRY/DEAD/LEASE_EXPIRED', + error_code VARCHAR(64) NULL COMMENT '有限错误类别或HTTP状态;不含载荷与令牌', + next_retry_at DATETIME(3) NULL COMMENT '本次失败后下次计划投递时间', + PRIMARY KEY (attempt_id), + KEY idx_delivery_attempt_entry (service_name,entry_id,started_at), + KEY idx_delivery_attempt_outcome (outcome,started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; diff --git a/finance/src/main/resources/openapi/finance-delivery-fee-coverage-v1.yaml b/finance/src/main/resources/openapi/finance-delivery-fee-coverage-v1.yaml new file mode 100644 index 000000000..c49328939 --- /dev/null +++ b/finance/src/main/resources/openapi/finance-delivery-fee-coverage-v1.yaml @@ -0,0 +1,48 @@ +openapi: 3.0.3 +info: { title: Delivery fee coverage sources, version: 1.0.0 } +paths: + /internal/finance/v1/delivery-fee-coverage/sources: + post: + summary: 按实际客户及头程入仓单批量读取可引用的原费用版本 + description: 仅内部服务身份,Gateway不公开。只返回该客户、指定原入仓单、FIRSTLEG来源的已审核有效应收最新版本;AVAILABLE或SETTLED不代表自动涵盖任何尾程服务,服务范围由运营明确确认。查询不冻结资金、不变更账务、不新增收费。 + parameters: + - { in: header, name: X-Internal-Service-Token, required: true, schema: { type: string } } + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [customerId, inboundIds] + properties: + customerId: { type: string, pattern: '^[1-9][0-9]*$' } + inboundIds: { type: array, minItems: 1, maxItems: 2000, items: { type: string, pattern: '^[1-9][0-9]*$' } } + responses: + '200': + description: 原费用不可变来源版本;没有符合记录返回空数组 + content: + application/json: + schema: + type: object + properties: + data: { type: array, items: { $ref: '#/components/schemas/FeeSource' } } + '400': { description: 请求字段无效 } + '401': { description: 内部服务令牌无效 } +components: + schemas: + FeeSource: + type: object + properties: + financeFeeId: { type: string } + sourceFeeId: { type: string } + sourceType: { type: string, enum: [INBOUND_FEE, CONTAINER_FEE] } + sourceVersion: { type: integer } + customerId: { type: string } + sourceInboundId: { type: string } + inboundCode: { type: string, nullable: true } + feeItemCode: { type: string } + feeItemName: { type: string } + currency: { type: string } + amount: { type: number } + sourceHash: { type: string } + status: { type: string, enum: [AVAILABLE, SETTLED] } diff --git a/finance/src/main/resources/openapi/finance-trip-receivables-v1.yaml b/finance/src/main/resources/openapi/finance-trip-receivables-v1.yaml new file mode 100644 index 000000000..cb41ba568 --- /dev/null +++ b/finance/src/main/resources/openapi/finance-trip-receivables-v1.yaml @@ -0,0 +1,234 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "车次Finance权威应收与实收只读来源", + "version": "1.0.0", + "description": "Lastleg以不可变DEPART验证后的客户/派送业务号读取。仅本Finance库;最新已审核有效LASTLEG OVERSEAS_DELIVERY_FEE应收(AVAILABLE、正式出账后的BILLED、SETTLED;LOCKED、VOIDED、SUPERSEDED排除),按币种。实收只取fin_receipt_bill已确认分配;整张已出账账单(ISSUED、PARTIALLY_PAID、PAID)全部来源均明确归属请求车次时才计一次。混合账单、无分配、无有效来源为待分配,不按账单总额或比例估算。最多200业务、2000费用,批量查询。现行应收结算单收款沿关联账单,fin_settlement_payment仅用于应付,不重复叠加。" + }, + "paths": { + "/internal/finance/v1/trip-receivables/query": { + "post": { + "security": [ + { + "internalToken": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "businesses", + "receiptsVisible" + ], + "properties": { + "businesses": { + "type": "array", + "maxItems": 200, + "items": { + "type": "object", + "required": [ + "customerId", + "deliveryNo" + ], + "properties": { + "customerId": { + "type": "integer", + "format": "int64", + "minimum": 1 + }, + "deliveryNo": { + "type": "string", + "maxLength": 64 + } + } + } + }, + "receiptsVisible": { + "type": "boolean" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Finance来源及归属,不生成费用/账单/分配", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/TripFinanceAuthority" + } + } + } + } + } + }, + "400": { + "description": "输入或来源数量超限" + }, + "401": { + "description": "内部令牌无效" + } + } + } + } + }, + "components": { + "securitySchemes": { + "internalToken": { + "type": "apiKey", + "in": "header", + "name": "X-Internal-Service-Token" + } + }, + "schemas": { + "TripFinanceMoney": { + "type": "object", + "required": [ + "currency", + "receivableAmount", + "receivedAmount" + ], + "properties": { + "currency": { + "type": "string" + }, + "receivableAmount": { + "type": "number", + "minimum": 0 + }, + "receivedAmount": { + "type": "number", + "minimum": 0, + "nullable": true + } + } + }, + "TripFinanceSource": { + "type": "object", + "required": [ + "customerId", + "businessNo", + "sourceNo", + "feeItemName", + "sourceVersion", + "currency", + "amount", + "status" + ], + "properties": { + "customerId": { + "type": "string" + }, + "businessNo": { + "type": "string" + }, + "sourceNo": { + "type": "string", + "nullable": true + }, + "feeItemName": { + "type": "string" + }, + "sourceVersion": { + "type": "integer", + "minimum": 1 + }, + "currency": { + "type": "string" + }, + "amount": { + "type": "number", + "minimum": 0 + }, + "status": { + "type": "string" + } + } + }, + "TripFinanceDocument": { + "type": "object", + "required": [ + "billNo", + "currency", + "receivedAmount", + "allocationStatus", + "reason" + ], + "properties": { + "billNo": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "receivedAmount": { + "type": "number", + "minimum": 0, + "nullable": true + }, + "allocationStatus": { + "type": "string", + "enum": [ + "ATTRIBUTED", + "PENDING_ALLOCATION" + ] + }, + "reason": { + "type": "string" + } + } + }, + "TripFinanceAuthority": { + "type": "object", + "required": [ + "status", + "totals", + "sources", + "documents", + "reason" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "AVAILABLE", + "PENDING_ALLOCATION" + ] + }, + "totals": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TripFinanceMoney" + } + }, + "sources": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TripFinanceSource" + } + }, + "documents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TripFinanceDocument" + } + }, + "reason": { + "type": "string" + } + } + } + } + } +} diff --git a/finance/src/main/resources/openapi/finance-vehicle-costs-v1.yaml b/finance/src/main/resources/openapi/finance-vehicle-costs-v1.yaml new file mode 100644 index 000000000..c971dd7eb --- /dev/null +++ b/finance/src/main/resources/openapi/finance-vehicle-costs-v1.yaml @@ -0,0 +1,287 @@ +openapi: 3.0.3 +info: + title: 公司车辆运营成本 + version: 1.0.0 + description: >- + 公司费用归平台财务主体PLATFORM_DEFAULT,不进入客户费用池、客户账单、客户应付单或客户钱包。 + 来源接收只生成PENDING;独立入账后才产生应付,付款只登记有凭证的实际CNY支付。 + 原币支持CNY/USD,来源提供业务月份有效汇率与不可变折算快照;Finance复核金额守恒。 + 未付款可作废;已付款禁止直接改写、作废或替换。更高来源版本仅在PENDING/VOIDED且未付款时接收。 +security: + - bearerAuth: [] +x-integration-required: + status: 源码合同,仍待共享集成与总控编译验证,不代表可部署 + numbers: FinanceDocumentNumberGenerator登记VC公司费用与VP付款业务号前缀 + file: BusinessFileCategory登记FINANCE_VEHICLE_VOUCHER;平台0、费用ID复合归属、非空JPG/PNG、10MiB、签名校验 + event: CompanyVehicleCostSubmitted v1;LASTLEG直投内部事件入口;aggregateType=COMPANY_VEHICLE_COST,aggregateId=sourceType:sourceNo,aggregateVersion=sourceVersion;平台信封tenantId为字符串0,不携带customerId + delivery: 注册EventTypes/EventDeliveryRoute及Lastleg Relay;只有匹配eventId/payloadHash的APPLIED或DUPLICATE代表接收完成,不能显示为已入账/已付款 + lastleg: 提交时锁真实来源并冻结版本、供应商、金额、月份汇率;映射与Outbox同事务提交;禁止把维修照片或reportedCost自动解释为付款证据 + sql: + fin_vehicle_cost: + columns: id BIGINT PK, subject_id BIGINT, cost_no VARCHAR(64), source_type VARCHAR(32), source_no VARCHAR(64), source_version BIGINT, source_hash VARCHAR(71), source_json JSON, supplier_id BIGINT, supplier_name VARCHAR(200), amount DECIMAL(17,2), paid_amount DECIMAL(17,2), status VARCHAR(32), record_version BIGINT, created_at DATETIME(3), updated_at DATETIME(3) + unique: [cost_no, 'subject_id + source_type + source_no'] + checks: 正数主体和供应商;amount>0;0<=paid_amount<=amount;状态PENDING/APPROVED/PARTIALLY_PAID/PAID/VOIDED;无customer_id/tenant_id + indexes: [subject_id + updated_at + id] + fin_vehicle_cost_revision: + columns: id BIGINT PK, cost_id BIGINT, record_version BIGINT, action VARCHAR(32), request_key VARCHAR(128), request_hash VARCHAR(71), operator_id BIGINT, operator_name VARCHAR(128), reason VARCHAR(1000), snapshot_json JSON, occurred_at DATETIME(3) + unique: ['cost_id + record_version', 'operator_id + action + request_key'] + description: RECEIVE/APPROVE/VOID/PAY不可变审计及幂等记录,和主记录及付款同事务提交 + fin_vehicle_cost_payment: + columns: id BIGINT PK, cost_id BIGINT, payment_no VARCHAR(64), amount DECIMAL(17,2), payment_json JSON, created_at DATETIME(3) + unique: [payment_no] + indexes: [cost_id + id] + description: 不可变实际付款凭证快照;amount>0;付款与余额、审计同事务 +paths: + /api/platform/finance/v1/vehicle-costs: + get: + description: platform:finance:vehicle-cost:list;只查询公司成本 + parameters: [{$ref: '#/components/parameters/Page'}, {$ref: '#/components/parameters/Size'}] + responses: + '200': {$ref: '#/components/responses/Page'} + /api/platform/finance/v1/vehicle-costs/{costNo}: + parameters: [{$ref: '#/components/parameters/CostNo'}] + get: + description: platform:finance:vehicle-cost:list + responses: + '200': {$ref: '#/components/responses/Cost'} + /api/platform/finance/v1/vehicle-costs/{costNo}/history: + parameters: [{$ref: '#/components/parameters/CostNo'}] + get: + description: platform:finance:vehicle-cost:list;不可变版本与当时金额快照 + parameters: [{$ref: '#/components/parameters/Page'}, {$ref: '#/components/parameters/Size'}] + responses: + '200': {$ref: '#/components/responses/Page'} + /api/platform/finance/v1/vehicle-costs/{costNo}/approve: + parameters: [{$ref: '#/components/parameters/CostNo'}] + post: + description: platform:finance:vehicle-cost:approve;PENDING入账为APPROVED,复核供应商;不是付款 + parameters: [{$ref: '#/components/parameters/Key'}] + requestBody: {$ref: '#/components/requestBodies/Command'} + responses: + '200': {$ref: '#/components/responses/Cost'} + /api/platform/finance/v1/vehicle-costs/{costNo}/void: + parameters: [{$ref: '#/components/parameters/CostNo'}] + post: + description: platform:finance:vehicle-cost:void;仅未付款PENDING/APPROVED可作废;更高来源版本可重新待入账 + parameters: [{$ref: '#/components/parameters/Key'}] + requestBody: {$ref: '#/components/requestBodies/Command'} + responses: + '200': {$ref: '#/components/responses/Cost'} + /api/platform/finance/v1/vehicle-costs/{costNo}/payments: + parameters: [{$ref: '#/components/parameters/CostNo'}] + get: + description: platform:finance:vehicle-cost:list;CNY付款历史 + parameters: [{$ref: '#/components/parameters/Page'}, {$ref: '#/components/parameters/Size'}] + responses: + '200': {$ref: '#/components/responses/Page'} + post: + description: platform:finance:vehicle-cost:pay;仅APPROVED/PARTIALLY_PAID,付款金额不超过未付余额,必须有本费用的公司付款凭证 + parameters: [{$ref: '#/components/parameters/Key'}] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/Payment'} + responses: + '200': {$ref: '#/components/responses/Cost'} + /api/platform/finance/v1/vehicle-costs/{costNo}/vouchers: + parameters: [{$ref: '#/components/parameters/CostNo'}] + post: + description: platform:finance:vehicle-cost:upload;上传不是付款,不生成付款流水 + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: {type: string, format: binary} + responses: + '200': {description: ApiResponse,data为本费用专属凭证元数据} + /api/platform/finance/v1/vehicle-costs/{costNo}/vouchers/{fileId}/content: + parameters: + - {$ref: '#/components/parameters/CostNo'} + - {name: fileId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}} + get: + description: platform:finance:vehicle-cost:list;校验平台、文件类别和费用归属 + responses: + '200': {description: 图片字节及File响应头} + /internal/finance/v1/vehicle-costs/events: + post: + security: [{internalService: []}] + description: 内部身份;同eventId异载荷拒绝,同来源版本异内容拒绝,旧来源版本拒绝;业务与Inbox同事务提交后ACK + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [eventId, eventType, eventVersion, occurredAt, tenantId, aggregateType, aggregateId, aggregateVersion, traceId, correlationId, payload, payloadHash] + properties: + eventId: {type: string, format: uuid} + eventType: {type: string, enum: [CompanyVehicleCostSubmitted]} + eventVersion: {type: integer, enum: [1]} + occurredAt: {type: string, format: date-time} + tenantId: {type: string, enum: ['0']} + aggregateType: {type: string, enum: [COMPANY_VEHICLE_COST]} + aggregateId: {type: string} + aggregateVersion: {type: integer, minimum: 1} + traceId: {type: string} + correlationId: {type: string} + payloadHash: {type: string} + payload: {$ref: '#/components/schemas/Source'} + responses: + '200': {description: 'ApiResponse;eventId、payloadHash与请求一致,outcome为APPLIED/DUPLICATE;仅表示接收完成'} + /internal/finance/v1/vehicle-costs/sources/query: + post: + security: [{internalService: []}] + description: 最多100组来源批量查询,未接收来源不返回;来源不存在不能解释为已付款或零成本 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [sources] + properties: + sources: + type: array + minItems: 1 + maxItems: 100 + items: + type: object + required: [sourceType, sourceNo] + properties: + sourceType: {type: string, enum: [REPAIR, RESCUE, TRIP_DIRECT]} + sourceNo: {type: string, maxLength: 64} + responses: + '200': {description: 'ApiResponse>'} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} + internalService: {type: apiKey, in: header, name: X-Internal-Service-Token} + parameters: + CostNo: {name: costNo, in: path, required: true, schema: {type: string}} + Page: {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} + Size: {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}} + Key: {name: Idempotency-Key, in: header, required: true, schema: {type: string, minLength: 1, maxLength: 128}} + requestBodies: + Command: + required: true + content: + application/json: + schema: + type: object + required: [expectedVersion] + properties: + expectedVersion: {type: integer, format: int64, minimum: 0} + reason: {type: string, maxLength: 1000} + responses: + Cost: + description: 业务响应;FIN_400参数/归属、FIN_404不存在、FIN_409状态/版本/幂等冲突 + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - type: object + properties: + data: {$ref: '#/components/schemas/Cost'} + Page: + description: ApiResponse;data含records/page/size/total/pages,列表Cost、历史History或付款Paid + content: + application/json: + schema: + allOf: + - {$ref: '#/components/schemas/Envelope'} + - type: object + properties: + data: + type: object + properties: + page: {type: integer, format: int64} + size: {type: integer, format: int64} + total: {type: integer, format: int64} + pages: {type: integer, format: int64} + records: + type: array + items: + oneOf: + - {$ref: '#/components/schemas/Cost'} + - {$ref: '#/components/schemas/History'} + - {$ref: '#/components/schemas/Paid'} + schemas: + Envelope: + type: object + properties: + code: {type: string} + message: {type: string} + requestId: {type: string} + traceId: {type: string} + data: {type: object} + Source: + type: object + additionalProperties: false + required: [sourceType, sourceNo, sourceVersion, vehicleId, vehiclePlate, supplierId, feeName, businessDate, originalCurrency, originalAmount, exchangeRate, exchangeRateMonth, amount] + properties: + sourceType: {type: string, enum: [REPAIR, RESCUE, TRIP_DIRECT]} + sourceNo: {type: string, maxLength: 64} + sourceVersion: {type: integer, format: int64, minimum: 1} + vehicleId: {type: string, pattern: '^[1-9][0-9]*$'} + vehiclePlate: {type: string, maxLength: 64} + tripNo: {type: string, nullable: true, maxLength: 64, description: TRIP_DIRECT必须提供真实车次号} + supplierId: {type: integer, format: int64, minimum: 1} + feeName: {type: string, maxLength: 200} + businessDate: {type: string, format: date} + originalCurrency: {type: string, enum: [CNY, USD]} + originalAmount: {type: number, minimum: 0, exclusiveMinimum: true, multipleOf: 0.01} + exchangeRate: {type: number, minimum: 0, exclusiveMinimum: true, description: CNY必须为1;USD来自业务月份有效汇率} + exchangeRateMonth: {type: string, pattern: '^[0-9]{4}-[0-9]{2}$'} + amount: {type: number, minimum: 0, exclusiveMinimum: true, multipleOf: 0.01, description: CNY折算金额,与原币乘汇率四舍五入到分严格核平} + remark: {type: string, nullable: true, maxLength: 1000} + Cost: + type: object + properties: + costNo: {type: string} + subjectCode: {type: string, enum: [PLATFORM_DEFAULT]} + source: {$ref: '#/components/schemas/Source'} + supplierName: {type: string} + currency: {type: string, enum: [CNY]} + status: {type: string, enum: [PENDING, APPROVED, PARTIALLY_PAID, PAID, VOIDED]} + paidAmount: {type: number} + outstandingAmount: {type: number, nullable: true, description: 未入账或已作废时为空,不能当已知零应付} + recordVersion: {type: integer, format: int64} + Payment: + type: object + required: [expectedVersion, amount, paidAt, method, voucherFileIds] + properties: + expectedVersion: {type: integer, format: int64, minimum: 0} + amount: {type: number, minimum: 0, exclusiveMinimum: true, multipleOf: 0.01, description: CNY实际付款} + paidAt: {type: string, format: date-time} + method: {type: string, enum: [ONLINE, BANK_TRANSFER, CASH, OTHER]} + referenceNo: {type: string, maxLength: 128} + voucherFileIds: {type: array, minItems: 1, maxItems: 20, uniqueItems: true, items: {type: string, pattern: '^[1-9][0-9]*$'}} + remark: {type: string, maxLength: 1000} + History: + type: object + properties: + recordVersion: {type: integer, format: int64} + action: {type: string, enum: [RECEIVE, APPROVE, VOID, PAY]} + reason: {type: string} + operatorName: {type: string} + occurredAt: {type: string, format: date-time} + snapshot: {$ref: '#/components/schemas/Cost'} + Paid: + type: object + properties: + paymentNo: {type: string} + currency: {type: string, enum: [CNY]} + amount: {type: number} + paidAt: {type: string, format: date-time} + method: {type: string} + referenceNo: {type: string, nullable: true} + voucherFileIds: {type: array, items: {type: string}} + operatorName: {type: string} + recordedAt: {type: string, format: date-time} + remark: {type: string, nullable: true} diff --git a/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java index edce650ca..7e6fd2a2e 100644 --- a/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/BusinessFeeSourceEventServiceTest.java @@ -5,7 +5,6 @@ import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; -import com.baiyue.common.event.RegionCode; import com.baiyue.finance.service.BusinessFeeSourceEventService; import com.baiyue.finance.service.FinanceSupplierValidator; import com.fasterxml.jackson.databind.JsonNode; @@ -52,7 +51,7 @@ class BusinessFeeSourceEventServiceTest { when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class), eq("LASTLEG"), eq("OVERSEAS_PROCUREMENT_FEE"), eq(701L), eq(1))).thenReturn(901L); - assertThat(service.consume(event)).isEqualTo(901L); + assertThat(service.consume(event).eventId()).isEqualTo(event.eventId()); ArgumentCaptor values = ArgumentCaptor.forClass(Object[].class); verify(jdbc).update(argThat(sql -> sql.startsWith("INSERT INTO fin_fee_source") @@ -84,8 +83,7 @@ class BusinessFeeSourceEventServiceTest { JsonNode node = json.valueToTree(payload); String payloadHash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); return new EventEnvelope("123e4567-e89b-12d3-a456-426614174701", - EventTypes.BUSINESS_FEE_SOURCE_CHANGED, 1, Instant.parse("2026-09-02T08:00:00Z"), - RegionCode.OVERSEAS, RegionCode.CN, "10001", "BUSINESS_FEE", "701", 1, + EventTypes.BUSINESS_FEE_SOURCE_CHANGED, 1, Instant.parse("2026-09-02T08:00:00Z"), "10001", "BUSINESS_FEE", "701", 1, null, "123e4567-e89b-12d3-a456-426614174702", "LASTLEG:FEE:701:1", null, node, payloadHash); } @@ -97,7 +95,7 @@ class BusinessFeeSourceEventServiceTest { new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(71L, "供应商", null, "ACTIVE")); when(jdbc.queryForObject(startsWith("SELECT id FROM fin_fee_source"), eq(Long.class), eq("LASTLEG"), eq("OVERSEAS_CONTAINER_FEE"), eq(701L), eq(1))).thenReturn(901L); - assertThat(service.consume(event)).isEqualTo(901L); + assertThat(service.consume(event).eventId()).isEqualTo(event.eventId()); ArgumentCaptor source = ArgumentCaptor.forClass(Object[].class); verify(jdbc).update(startsWith("INSERT INTO fin_fee_source"), source.capture()); assertThat(source.getValue()[0]).isEqualTo("LASTLEG"); @@ -138,7 +136,7 @@ class BusinessFeeSourceEventServiceTest { new com.baiyue.finance.client.WarehouseSupplierClient.SupplierSnapshot(71L, "供应商", null, "ACTIVE")); when(jdbc.queryForList(startsWith("SELECT payload_hash,status"), eq("LASTLEG"), eq(event.eventId()))) .thenReturn(List.of(Map.of("payload_hash", event.payloadHash(), "status", "PROCESSED", "result_resource_id", 901L))); - assertThat(service.consume(event)).isEqualTo(901L); + assertThat(service.consume(event).eventId()).isEqualTo(event.eventId()); verify(jdbc, never()).update(anyString(), any(Object[].class)); } @@ -162,8 +160,7 @@ class BusinessFeeSourceEventServiceTest { payload.putNull("sourceHash"); payload.put("sourceHash", EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); payload.put("action", action); - return new EventEnvelope(base.eventId(), base.eventType(), 1, base.occurredAt(), - RegionCode.OVERSEAS, RegionCode.CN, "10001", "BUSINESS_FEE", "701", version, + return new EventEnvelope(base.eventId(), base.eventType(), 1, base.occurredAt(), "10001", "BUSINESS_FEE", "701", version, null, base.traceId(), "LASTLEG:FEE:701:" + version, null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); } @@ -189,7 +186,7 @@ class BusinessFeeSourceEventServiceTest { void voidedSourceCannotBeRevivedByPublicationReplay() { var event = payableEvent("PUBLISHED", 1); existingVersion(event.payload().get("sourceHash").asText(), "VOIDED"); - assertThat(service.consume(event)).isEqualTo(901L); + assertThat(service.consume(event).eventId()).isEqualTo(event.eventId()); noFinancialWrites(); } @@ -197,7 +194,7 @@ class BusinessFeeSourceEventServiceTest { void voidTargetsOnlyMatchingOverseasCostVersion() { var event = payableEvent("VOIDED", 1); existingVersion(event.payload().get("sourceHash").asText(), "AVAILABLE"); - assertThat(service.consume(event)).isEqualTo(901L); + assertThat(service.consume(event).eventId()).isEqualTo(event.eventId()); verify(jdbc).update(startsWith("UPDATE fin_cost SET status='VOIDED'"), eq("LASTLEG"), eq("OVERSEAS_CONTAINER_FEE"), eq(701L), eq(1)); verify(jdbc, never()).update(startsWith("INSERT INTO fin_cost"), any(Object[].class)); diff --git a/finance/src/test/java/com/baiyue/finance/FinanceExportContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceExportContractTest.java index eed2eb18e..a56e35abc 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceExportContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceExportContractTest.java @@ -22,18 +22,15 @@ class FinanceExportContractTest { void keepsAllFileDefinitionsOnTheSameNineTypes() throws Exception { Path backendRoot = backendRoot(); String application = Files.readString(backendRoot.resolve("file/src/main/resources/application.yml"), StandardCharsets.UTF_8); - String local = Files.readString(backendRoot.resolve("config/nacos/local/file-service.yml"), StandardCharsets.UTF_8); - String cn = Files.readString(backendRoot.resolve("config/nacos/cn/file-service.yml"), StandardCharsets.UTF_8); + String test = Files.readString(backendRoot.resolve("config/nacos/test/file-service.yml"), StandardCharsets.UTF_8); Yaml yaml = new Yaml(); assertThat((Object) yaml.load(application)).isNotNull(); - assertThat((Object) yaml.load(local)).isNotNull(); - assertThat((Object) yaml.load(cn)).isNotNull(); + assertThat((Object) yaml.load(test)).isNotNull(); + assertThat(application).contains("nacos:${spring.application.name}.yml").doesNotContain("optional:nacos:"); for (String type : TYPES) { - assertThat(application).contains(type + ":").contains("provider-path: /internal/exports/v1/" + type + "/batches"); - assertThat(local).contains(type + ":").contains("provider-path: /internal/exports/v1/" + type + "/batches"); - assertThat(cn).contains(type + ":").contains("provider-path: /internal/exports/v1/" + type + "/batches"); + assertThat(test).contains(type + ":").contains("provider-path: /internal/exports/v1/" + type + "/batches"); } } diff --git a/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java b/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java index 42481247f..c0425eeae 100644 --- a/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java +++ b/finance/src/test/java/com/baiyue/finance/WalletCommandEventServiceTest.java @@ -4,7 +4,6 @@ import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; -import com.baiyue.common.event.RegionCode; import com.baiyue.common.event.WalletCommandRequestedV1; import com.baiyue.finance.dto.FinanceWalletDtos.WalletHold; import com.baiyue.finance.service.FinanceEventException; @@ -16,14 +15,12 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.transaction.TransactionStatus; -import org.springframework.transaction.support.TransactionCallback; -import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.transaction.PlatformTransactionManager; import java.math.BigDecimal; import java.time.Instant; import java.util.List; import java.util.UUID; -import java.util.function.Consumer; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -34,39 +31,43 @@ class WalletCommandEventServiceTest { private final JdbcTemplate jdbc = mock(JdbcTemplate.class); private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules(); private final FinanceWalletService wallet = mock(FinanceWalletService.class); - private final TransactionTemplate transactions = mock(TransactionTemplate.class); + private final PlatformTransactionManager transactions = mock(PlatformTransactionManager.class); private final WalletCommandEventService service = new WalletCommandEventService(jdbc, mapper, wallet, transactions); @BeforeEach void executeTransactionCallbacks() { - when(transactions.execute(any())).thenAnswer(invocation -> { - TransactionCallback callback = invocation.getArgument(0); - return callback.doInTransaction(mock(TransactionStatus.class)); - }); - doAnswer(invocation -> { - Consumer callback = invocation.getArgument(0); - callback.accept(mock(TransactionStatus.class)); - return null; - }).when(transactions).executeWithoutResult(any()); + when(transactions.getTransaction(any())).thenAnswer(invocation -> mock(TransactionStatus.class)); + } + + @Test + void rejectsTamperedEnvelopeBeforeOpeningWalletTransaction() { + EventEnvelope valid = envelope("FREEZE", 1L, BigDecimal.TEN); + EventEnvelope tampered = new EventEnvelope(valid.eventId(), valid.eventType(), valid.eventVersion(), + valid.occurredAt(), valid.tenantId(), valid.aggregateType(), + valid.aggregateId(), valid.aggregateVersion(), valid.handoverVersion(), valid.traceId(), + valid.correlationId(), valid.causationId(), valid.payload(), "sha256:" + "0".repeat(64)); + assertThatThrownBy(() -> service.consume(tampered)).isInstanceOf(FinanceEventException.class) + .hasMessageContaining("摘要与实际载荷不一致"); + verifyNoInteractions(jdbc, wallet); } @Test void freezesWalletAndPublishesCompletedEvent() { EventEnvelope envelope = envelope("FREEZE", 31L, new BigDecimal("88.50")); when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), eq(envelope.eventId()))) - .thenReturn(List.of()); + .thenReturn(List.of(envelope.payloadHash())); when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0); when(wallet.freeze(eq(31L), eq("freeze-procurement-1"), any())).thenReturn( new WalletHold(9L, 31L, "OVERSEAS_PROCUREMENT_FEE", "PO-1", new BigDecimal("88.50"), new BigDecimal("80.00"), new BigDecimal("8.50"), "FROZEN", 71L, null, null, null, null)); - assertThat(service.consume(envelope)).isEqualTo(envelope.eventId()); + assertThat(service.consume(envelope).eventId()).isEqualTo(envelope.eventId()); verify(wallet).freeze(eq(31L), eq("freeze-procurement-1"), argThat(request -> request.amount().compareTo(new BigDecimal("88.50")) == 0 && request.businessNo().equals("PO-1"))); - verify(jdbc).update(startsWith("INSERT IGNORE INTO fin_wallet_event_outbox"), any(Object[].class)); + verify(jdbc).update(startsWith("INSERT INTO fin_wallet_event_outbox"), any(Object[].class)); verify(jdbc).update(startsWith("UPDATE fin_wallet_command_inbox SET status="), eq("SUCCEEDED"), contains("\"holdStatus\":\"FROZEN\""), eq(envelope.eventId())); } @@ -75,7 +76,7 @@ class WalletCommandEventServiceTest { void settledWalletHoldMarksMatchingReceivableSourceSettled() { EventEnvelope envelope = envelope("SETTLE", 31L, new BigDecimal("88.50")); when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), eq(envelope.eventId()))) - .thenReturn(List.of()); + .thenReturn(List.of(envelope.payloadHash())); when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0); when(wallet.settle(eq(31L), eq("OVERSEAS_PROCUREMENT_FEE"), eq("PO-1"), eq("freeze-procurement-1"), any())).thenReturn(new WalletHold(9L, 31L, @@ -87,11 +88,40 @@ class WalletCommandEventServiceTest { verify(jdbc).update(startsWith("UPDATE fin_fee_source f JOIN fin_wallet_hold"), eq(9L)); } + @Test + void transientWalletFailureMustNotBecomeAFinalResult() { + EventEnvelope event = envelope("FREEZE", 31L, new BigDecimal("10.00")); + when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), anyString())) + .thenReturn(List.of(event.payloadHash())); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0); + var failure = new org.springframework.dao.TransientDataAccessResourceException("temporary database failure"); + when(wallet.freeze(anyLong(), anyString(), any())).thenThrow(failure); + + assertThatThrownBy(() -> service.consume(event)).isSameAs(failure); + verify(jdbc, never()).update(startsWith("INSERT INTO fin_wallet_event_outbox"), any(Object[].class)); + } + + @Test + void losingInboxInsertMustRecheckThePersistedHash() { + EventEnvelope event = envelope("FREEZE", 31L, new BigDecimal("10.00")); + when(jdbc.queryForList(startsWith("SELECT payload_hash"), eq(String.class), anyString())) + .thenAnswer(invocation -> ((String) invocation.getArgument(0)).contains("FOR UPDATE") + ? List.of("a-different-persisted-hash") : List.of()); + when(jdbc.update(startsWith("INSERT INTO fin_wallet_command_inbox"), any(Object[].class))) + .thenThrow(new org.springframework.dao.DuplicateKeyException("another request won")); + when(jdbc.queryForObject(startsWith("SELECT COUNT(*)"), eq(Integer.class), anyString())).thenReturn(0); + + assertThatThrownBy(() -> service.consume(event)) + .isInstanceOf(FinanceEventException.class) + .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-EVT-002"); + verifyNoInteractions(wallet); + } + @Test void rejectsCustomerMismatchBeforeTouchingWallet() { EventEnvelope envelope = envelope("FREEZE", 31L, new BigDecimal("10.00")); envelope = new EventEnvelope(envelope.eventId(), envelope.eventType(), envelope.eventVersion(), - envelope.occurredAt(), envelope.sourceRegion(), envelope.targetRegion(), "32", envelope.aggregateType(), + envelope.occurredAt(), "32", envelope.aggregateType(), envelope.aggregateId(), envelope.aggregateVersion(), envelope.handoverVersion(), envelope.traceId(), envelope.correlationId(), envelope.causationId(), envelope.payload(), envelope.payloadHash()); @@ -108,8 +138,7 @@ class WalletCommandEventServiceTest { "freeze-procurement-1", Instant.parse("2026-09-05T01:00:00Z")); JsonNode payload = mapper.valueToTree(command); return new EventEnvelope(UUID.randomUUID().toString(), EventTypes.WALLET_COMMAND_REQUESTED, - WalletCommandRequestedV1.VERSION, Instant.parse("2026-09-05T01:00:00Z"), - RegionCode.OVERSEAS, RegionCode.CN, String.valueOf(customerId), "WALLET_HOLD", "PO-1", 1, + WalletCommandRequestedV1.VERSION, Instant.parse("2026-09-05T01:00:00Z"), String.valueOf(customerId), "WALLET_HOLD", "PO-1", 1, null, UUID.randomUUID().toString(), UUID.randomUUID().toString(), null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))).requireValid(); } diff --git a/finance/src/test/java/com/baiyue/finance/WalletCommandTransactionTest.java b/finance/src/test/java/com/baiyue/finance/WalletCommandTransactionTest.java new file mode 100644 index 000000000..9d27000cc --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/WalletCommandTransactionTest.java @@ -0,0 +1,205 @@ +package com.baiyue.finance; + +import com.baiyue.common.event.*; +import com.baiyue.finance.dto.FinanceWalletDtos.WalletHold; +import com.baiyue.finance.service.FinanceEventException; +import com.baiyue.finance.service.FinanceWalletService; +import com.baiyue.finance.service.WalletCommandEventService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.TransientDataAccessResourceException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** Real JDBC transactions and row locks; the wallet boundary is a durable idempotent test double. */ +class WalletCommandTransactionTest { + private JdbcTemplate jdbc; + private FinanceWalletService wallet; + private WalletCommandEventService service; + private final ObjectMapper mapper = new ObjectMapper().findAndRegisterModules(); + + @BeforeEach + void setup() { + JdbcDataSource source = new JdbcDataSource(); + source.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000"); + jdbc = spy(new JdbcTemplate(source)); + jdbc.execute("CREATE TABLE fin_wallet_command_inbox(event_id VARCHAR(128) PRIMARY KEY,event_type VARCHAR(100),payload_hash VARCHAR(128),payload_json JSON,status VARCHAR(32),received_at TIMESTAMP,result_json JSON,processed_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE fin_wallet_event_outbox(event_id VARCHAR(128) PRIMARY KEY,event_key VARCHAR(200) UNIQUE,payload_json JSON,status VARCHAR(32),next_retry_at TIMESTAMP,created_at TIMESTAMP,updated_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE wallet_effect(idempotency_key VARCHAR(128) PRIMARY KEY,amount DECIMAL(18,2))"); + wallet = mock(FinanceWalletService.class); + service = new WalletCommandEventService(jdbc, mapper, wallet, new DataSourceTransactionManager(source)); + doAnswer(invocation -> { + String key = invocation.getArgument(1); + // Models the existing FinanceWalletService's durable replay behavior. + if (jdbc.queryForObject("SELECT COUNT(*) FROM wallet_effect WHERE idempotency_key=?", Integer.class, key) == 0) { + jdbc.update("INSERT INTO wallet_effect VALUES(?,?)", key, new BigDecimal("10.00")); + } + return hold(); + }).when(wallet).freeze(anyLong(), anyString(), any()); + } + + @Test + void transientFailureRollsBackMoneyAndCanRetryTheOriginalEvent() { + EventEnvelope event = event(UUID.randomUUID().toString(), "10.00"); + AtomicBoolean first = new AtomicBoolean(true); + doAnswer(invocation -> { + jdbc.update("INSERT INTO wallet_effect VALUES('freeze-1',10.00)"); + if (first.getAndSet(false)) throw new TransientDataAccessResourceException("temporary outage"); + return hold(); + }).when(wallet).freeze(anyLong(), anyString(), any()); + + assertThatThrownBy(() -> service.consume(event)).isInstanceOf(TransientDataAccessResourceException.class); + assertThat(count("wallet_effect")).isZero(); + assertThat(count("fin_wallet_event_outbox")).isZero(); + assertThat(inboxStatus(event)).isEqualTo("PROCESSING"); + assertThat(service.consume(event).eventId()).isEqualTo(event.eventId()); + assertThat(count("wallet_effect")).isEqualTo(1); + assertThat(count("fin_wallet_event_outbox")).isEqualTo(1); + assertThat(inboxStatus(event)).isEqualTo("SUCCEEDED"); + } + + @Test + void businessRejectionRollsBackMoneyAndPublishesExactlyOneFailedResult() { + EventEnvelope event = event(UUID.randomUUID().toString(), "10.00"); + doAnswer(invocation -> { + jdbc.update("INSERT INTO wallet_effect VALUES('freeze-1',10.00)"); + throw new FinanceEventException("FIN-WALLET-INSUFFICIENT", "insufficient balance"); + }).when(wallet).freeze(anyLong(), anyString(), any()); + service.consume(event); + service.consume(event); + assertThat(count("wallet_effect")).isZero(); + assertThat(count("fin_wallet_event_outbox")).isEqualTo(1); + assertThat(inboxStatus(event)).isEqualTo("FAILED"); + verify(wallet, times(1)).freeze(anyLong(), anyString(), any()); + } + + @Test + void retryableFinanceCodeDoesNotPublishATerminalResult() { + EventEnvelope event = event(UUID.randomUUID().toString(), "10.00"); + doThrow(new FinanceEventException("FIN-IDEMPOTENCY-PROCESSING", "in progress")) + .when(wallet).freeze(anyLong(), anyString(), any()); + assertThatThrownBy(() -> service.consume(event)).isInstanceOf(FinanceEventException.class); + assertThat(inboxStatus(event)).isEqualTo("PROCESSING"); + assertThat(count("fin_wallet_event_outbox")).isZero(); + } + + @Test + void failureAfterResultInsertRollsBackMoneyAndResultAndCanRetry() { + EventEnvelope event = event(UUID.randomUUID().toString(), "10.00"); + AtomicBoolean first = new AtomicBoolean(true); + doAnswer(invocation -> { + if (first.getAndSet(false)) throw new TransientDataAccessResourceException("result commit failed"); + return invocation.callRealMethod(); + }).when(jdbc).update(startsWith("UPDATE fin_wallet_command_inbox SET status="), any(Object[].class)); + + assertThatThrownBy(() -> service.consume(event)).isInstanceOf(TransientDataAccessResourceException.class); + assertThat(count("wallet_effect")).isZero(); + assertThat(count("fin_wallet_event_outbox")).isZero(); + assertThat(inboxStatus(event)).isEqualTo("PROCESSING"); + assertThat(service.consume(event).eventId()).isEqualTo(event.eventId()); + assertThat(count("wallet_effect")).isEqualTo(1); + assertThat(count("fin_wallet_event_outbox")).isEqualTo(1); + assertThat(inboxStatus(event)).isEqualTo("SUCCEEDED"); + verify(wallet, times(2)).freeze(eq(31L), eq("freeze-1"), any()); + } + + @Test + void differentPayloadCannotReplaceIdentityAfterAResultFailure() { + EventEnvelope event = event(UUID.randomUUID().toString(), "10.00"); + doThrow(new TransientDataAccessResourceException("outbox unavailable")) + .when(jdbc).update(startsWith("INSERT INTO fin_wallet_event_outbox"), any(Object[].class)); + assertThatThrownBy(() -> service.consume(event)).isInstanceOf(TransientDataAccessResourceException.class); + assertThatThrownBy(() -> service.consume(event(event.eventId(), "20.00"))) + .isInstanceOf(FinanceEventException.class) + .extracting(e -> ((FinanceEventException) e).code()).isEqualTo("FIN-EVT-002"); + assertThat(count("wallet_effect")).isZero(); + verify(wallet, times(1)).freeze(anyLong(), anyString(), any()); + } + + @Test + void concurrentSamePayloadExecutesOnce() throws Exception { + runConcurrent(false); + } + + @Test + void concurrentDifferentPayloadIsRejectedBeforeWalletExecution() throws Exception { + runConcurrent(true); + } + + private void runConcurrent(boolean differentPayload) throws Exception { + EventEnvelope firstEvent = event(UUID.randomUUID().toString(), "10.00"); + EventEnvelope secondEvent = differentPayload ? event(firstEvent.eventId(), "20.00") : firstEvent; + CountDownLatch enteredWallet = new CountDownLatch(1); + CountDownLatch releaseWallet = new CountDownLatch(1); + CountDownLatch secondStarted = new CountDownLatch(1); + doAnswer(invocation -> { + enteredWallet.countDown(); + if (!releaseWallet.await(5, TimeUnit.SECONDS)) throw new AssertionError("test timed out"); + jdbc.update("INSERT INTO wallet_effect VALUES('freeze-1',10.00)"); + return hold(); + }).when(wallet).freeze(anyLong(), anyString(), any()); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future first = executor.submit(() -> service.consume(firstEvent)); + assertThat(enteredWallet.await(5, TimeUnit.SECONDS)).isTrue(); + Future second = executor.submit(() -> { + secondStarted.countDown(); + return service.consume(secondEvent); + }); + assertThat(secondStarted.await(5, TimeUnit.SECONDS)).isTrue(); + assertThatThrownBy(() -> second.get(150, TimeUnit.MILLISECONDS)).isInstanceOf(TimeoutException.class); + releaseWallet.countDown(); + assertThat(first.get(5, TimeUnit.SECONDS).eventId()).isEqualTo(firstEvent.eventId()); + if (differentPayload) { + assertThatThrownBy(() -> second.get(5, TimeUnit.SECONDS)) + .isInstanceOf(ExecutionException.class).hasCauseInstanceOf(FinanceEventException.class) + .satisfies(e -> assertThat(((FinanceEventException) e.getCause()).code()).isEqualTo("FIN-EVT-002")); + } else { + assertThat(second.get(5, TimeUnit.SECONDS).eventId()).isEqualTo(firstEvent.eventId()); + } + verify(wallet, times(1)).freeze(anyLong(), anyString(), any()); + assertThat(count("wallet_effect")).isEqualTo(1); + assertThat(count("fin_wallet_event_outbox")).isEqualTo(1); + assertThat(inboxStatus(firstEvent)).isEqualTo("SUCCEEDED"); + } finally { + releaseWallet.countDown(); + executor.shutdownNow(); + assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + } + } + + private int count(String table) { + return jdbc.queryForObject("SELECT COUNT(*) FROM " + table, Integer.class); + } + + private String inboxStatus(EventEnvelope event) { + return jdbc.queryForObject("SELECT status FROM fin_wallet_command_inbox WHERE event_id=?", String.class, event.eventId()); + } + + private EventEnvelope event(String id, String amount) { + var command = new WalletCommandRequestedV1("FREEZE", 31L, "OVERSEAS_PROCUREMENT_FEE", "PO-1", + new BigDecimal(amount), "fee", "freeze-1", Instant.parse("2026-09-05T01:00:00Z")); + var payload = mapper.valueToTree(command); + return new EventEnvelope(id, EventTypes.WALLET_COMMAND_REQUESTED, WalletCommandRequestedV1.VERSION, + Instant.now(), "31", "WALLET_HOLD", "PO-1", 1, + null, UUID.randomUUID().toString(), UUID.randomUUID().toString(), null, payload, + EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + } + + private WalletHold hold() { + return new WalletHold(9L, 31L, "OVERSEAS_PROCUREMENT_FEE", "PO-1", new BigDecimal("10.00"), + new BigDecimal("10.00"), BigDecimal.ZERO, "FROZEN", 71L, null, null, null, null); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/WalletResultOutboxLeaseTest.java b/finance/src/test/java/com/baiyue/finance/WalletResultOutboxLeaseTest.java new file mode 100644 index 000000000..f26132b1f --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/WalletResultOutboxLeaseTest.java @@ -0,0 +1,87 @@ +package com.baiyue.finance; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.*; +import com.baiyue.finance.client.WalletResultLastlegClient; +import com.baiyue.finance.service.WalletResultOutboxRelay; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import com.baiyue.common.web.EventDeliveryMetrics; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.test.util.ReflectionTestUtils; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.UUID; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class WalletResultOutboxLeaseTest { + @Test void expiredAttemptCannotOverwriteNewAttemptInSameRelayInstance() throws Exception { + var jdbc = new JdbcTemplate(new DriverManagerDataSource( + "jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "")); + jdbc.execute("CREATE TABLE fin_wallet_event_outbox(event_id VARCHAR(64) PRIMARY KEY, payload_json TEXT, status VARCHAR(32), retry_count INT DEFAULT 0, next_retry_at TIMESTAMP NOT NULL, locked_by VARCHAR(128), locked_at TIMESTAMP, updated_at TIMESTAMP, published_at TIMESTAMP, last_error VARCHAR(1000))"); + jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))"); + var mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + var payload = mapper.createObjectNode().put("result", "SUCCEEDED"); + var event = new EventEnvelope(UUID.randomUUID().toString(), EventTypes.WALLET_COMMAND_COMPLETED, 1, + Instant.now(), "1", "WALLET_HOLD", "ORDER-1", 1L, null, + "trace", "correlation", null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + jdbc.update("INSERT INTO fin_wallet_event_outbox(event_id,payload_json,status,next_retry_at) VALUES(?,?,'PENDING',DATEADD('SECOND',-5,CURRENT_TIMESTAMP))", + event.eventId(), mapper.writeValueAsString(event)); + var client = mock(WalletResultLastlegClient.class); + var relay = new WalletResultOutboxRelay(jdbc, new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(jdbc.getDataSource())), new EventDeliveryMetrics(jdbc, new SimpleMeterRegistry()), mapper, client); + ReflectionTestUtils.setField(relay, "internalToken", "token"); + ReflectionTestUtils.setField(relay, "maxAttempts", 12); + var firstEntered = new CountDownLatch(1); + var secondEntered = new CountDownLatch(1); + var releaseFirst = new CountDownLatch(1); + var releaseSecond = new CountDownLatch(1); + var calls = new AtomicInteger(); + when(client.receive(eq("token"), any())).thenAnswer(invocation -> { + if (calls.incrementAndGet() == 1) { + firstEntered.countDown(); + if (!releaseFirst.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("test timeout"); + throw new IllegalStateException("late failure from expired request"); + } + secondEntered.countDown(); + if (!releaseSecond.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("test timeout"); + return ApiResponse.success(DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED)); + }); + ExecutorService pool = Executors.newFixedThreadPool(2); + try { + Future first = pool.submit(() -> relay.deliver(event.eventId())); + assertThat(firstEntered.await(10, TimeUnit.SECONDS)).isTrue(); + String oldOwner = jdbc.queryForObject("SELECT locked_by FROM fin_wallet_event_outbox", String.class); + // The actual recovery path must finish the old audit and defer the new attempt. + jdbc.update("UPDATE fin_wallet_event_outbox SET locked_at=?", LocalDateTime.now().minusSeconds(61)); + ReflectionTestUtils.invokeMethod(relay, "recoverExpired"); + assertThat(jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt WHERE attempt_id=?", String.class, oldOwner)).isEqualTo("LEASE_EXPIRED"); + assertThat(jdbc.queryForObject("SELECT next_retry_at FROM fin_wallet_event_outbox", LocalDateTime.class)).isAfter(LocalDateTime.now()); + jdbc.update("UPDATE fin_wallet_event_outbox SET next_retry_at=?", LocalDateTime.now().minusSeconds(1)); + Future second = pool.submit(() -> relay.deliver(event.eventId())); + assertThat(secondEntered.await(10, TimeUnit.SECONDS)).isTrue(); + String newOwner = jdbc.queryForObject("SELECT locked_by FROM fin_wallet_event_outbox", String.class); + assertThat(newOwner).isNotEqualTo(oldOwner); + releaseFirst.countDown(); + first.get(10, TimeUnit.SECONDS); + assertThat(jdbc.queryForObject("SELECT status FROM fin_wallet_event_outbox", String.class)).isEqualTo("SENDING"); + assertThat(jdbc.queryForObject("SELECT locked_by FROM fin_wallet_event_outbox", String.class)).isEqualTo(newOwner); + assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isEqualTo(1); + releaseSecond.countDown(); + second.get(10, TimeUnit.SECONDS); + assertThat(jdbc.queryForObject("SELECT status FROM fin_wallet_event_outbox", String.class)).isEqualTo("PUBLISHED"); + assertThat(jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt WHERE attempt_id=?", String.class, newOwner)).isEqualTo("APPLIED"); + assertThat(jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt WHERE attempt_id=?", String.class, oldOwner)).isEqualTo("LEASE_EXPIRED"); + } finally { + releaseFirst.countDown(); releaseSecond.countDown(); pool.shutdownNow(); + } + } +} diff --git a/finance/src/test/java/com/baiyue/finance/WalletResultRetryTest.java b/finance/src/test/java/com/baiyue/finance/WalletResultRetryTest.java new file mode 100644 index 000000000..299e4ed63 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/WalletResultRetryTest.java @@ -0,0 +1,115 @@ +package com.baiyue.finance; + +import com.baiyue.common.event.*; +import com.baiyue.finance.client.WalletResultLastlegClient; +import com.baiyue.finance.service.WalletResultOutboxRelay; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import com.baiyue.common.web.EventDeliveryMetrics; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.test.util.ReflectionTestUtils; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.UUID; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class WalletResultRetryTest { + JdbcTemplate jdbc; + WalletResultLastlegClient client; + WalletResultOutboxRelay relay; + String eventId; + String originalJson; + + @BeforeEach void setup() throws Exception { + jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "")); + jdbc.execute("CREATE TABLE fin_wallet_event_outbox(event_id VARCHAR(64) PRIMARY KEY, payload_json TEXT, status VARCHAR(32), retry_count INT DEFAULT 0, next_retry_at TIMESTAMP NOT NULL, locked_by VARCHAR(128), locked_at TIMESTAMP, updated_at TIMESTAMP, published_at TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_error VARCHAR(1000))"); + jdbc.execute("CREATE TABLE event_delivery_attempt(attempt_id VARCHAR(36) PRIMARY KEY,service_name VARCHAR(32) NOT NULL,event_type VARCHAR(96) NOT NULL,entry_id VARCHAR(128) NOT NULL,event_id VARCHAR(128),target_name VARCHAR(64) NOT NULL,started_at TIMESTAMP(3) NOT NULL,finished_at TIMESTAMP(3),duration_ms BIGINT,outcome VARCHAR(24) NOT NULL,error_code VARCHAR(64),next_retry_at TIMESTAMP(3))"); + var mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + var payload = mapper.createObjectNode().put("result", "SUCCEEDED"); + eventId = UUID.randomUUID().toString(); + var event = new EventEnvelope(eventId, EventTypes.WALLET_COMMAND_COMPLETED, 1, Instant.now(), + "1", "WALLET_HOLD", "ORDER-1", 1L, null, + "trace", "correlation", null, payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + originalJson = mapper.writeValueAsString(event); + jdbc.update("INSERT INTO fin_wallet_event_outbox(event_id,payload_json,status,next_retry_at) VALUES(?,?,'PENDING',?)", + eventId, originalJson, LocalDateTime.now().minusSeconds(1)); + client = mock(WalletResultLastlegClient.class); + relay = new WalletResultOutboxRelay(jdbc, new EventDeliveryJournal(jdbc, new DataSourceTransactionManager(jdbc.getDataSource())), new EventDeliveryMetrics(jdbc, new SimpleMeterRegistry()), mapper, client); + ReflectionTestUtils.setField(relay, "internalToken", "token"); + ReflectionTestUtils.setField(relay, "maxAttempts", 3); + ReflectionTestUtils.setField(relay, "retryJitter", 0.0); + } + + @Test void failedDeliveryBacksOffThenStopsAtConfiguredLimitWithoutChangingPayload() { + when(client.receive(anyString(), any())).thenThrow(new IllegalStateException("service unavailable")); + LocalDateTime before = LocalDateTime.now(); + relay.deliver(eventId); + LocalDateTime next = jdbc.queryForObject("SELECT next_retry_at FROM fin_wallet_event_outbox", LocalDateTime.class); + assertThat(next).isAfterOrEqualTo(before.plusSeconds(5)).isBefore(LocalDateTime.now().plusSeconds(6)); + assertThat(status()).isEqualTo("PENDING"); + for (int i = 0; i < 2; i++) { + jdbc.update("UPDATE fin_wallet_event_outbox SET next_retry_at=?", LocalDateTime.now().minusSeconds(1)); + relay.deliver(eventId); + } + assertThat(status()).isEqualTo("DEAD"); + assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isEqualTo(3); + assertThat(jdbc.queryForObject("SELECT locked_by FROM fin_wallet_event_outbox", String.class)).isNull(); + assertThat(jdbc.queryForObject("SELECT payload_json FROM fin_wallet_event_outbox", String.class)).isEqualTo(originalJson); + relay.deliver(eventId); + verify(client, times(3)).receive(anyString(), any()); + } + + @Test void expiredLastAttemptGoesDeadWithoutAnotherHttpRequest() { + jdbc.update("UPDATE fin_wallet_event_outbox SET status='SENDING',retry_count=2,locked_by='expired',locked_at=?", + LocalDateTime.now().minusSeconds(61)); + jdbc.update("INSERT INTO event_delivery_attempt(attempt_id,service_name,event_type,entry_id,event_id,target_name,started_at,outcome) VALUES('expired','finance','WalletCommandCompleted',?,?,'lastleg-events',?,'STARTED')", eventId, eventId, LocalDateTime.now().minusSeconds(61)); + relay.relay(); + assertThat(status()).isEqualTo("DEAD"); + assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isEqualTo(3); + verifyNoInteractions(client); + } + + @Test void expiredLeaseCountsAsFailureAndWaitsBeforeRetrying() { + jdbc.update("UPDATE fin_wallet_event_outbox SET status='SENDING',locked_by='expired',locked_at=?", + LocalDateTime.now().minusSeconds(61)); + jdbc.update("INSERT INTO event_delivery_attempt(attempt_id,service_name,event_type,entry_id,event_id,target_name,started_at,outcome) VALUES('expired','finance','WalletCommandCompleted',?,?,'lastleg-events',?,'STARTED')", eventId, eventId, LocalDateTime.now().minusSeconds(61)); + relay.relay(); + assertThat(status()).isEqualTo("PENDING"); + assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT next_retry_at FROM fin_wallet_event_outbox", LocalDateTime.class)).isAfter(LocalDateTime.now()); + verifyNoInteractions(client); + } + + @Test void auditStartFailurePreventsHttpAndRollsBackClaim() { + jdbc.execute("DROP TABLE event_delivery_attempt"); + assertThatThrownBy(() -> relay.deliver(eventId)).isInstanceOf(RuntimeException.class); + assertThat(status()).isEqualTo("PENDING"); + verifyNoInteractions(client); + } + + @Test void successfulRemoteResponseWithFailedAuditCommitLeavesStartedLeaseForRecovery() throws Exception { + var mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + var event = mapper.readValue(originalJson, EventEnvelope.class); + when(client.receive(anyString(), any())).thenAnswer(invocation -> { + assertThat(jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt", String.class)).isEqualTo("STARTED"); + return com.baiyue.common.core.api.ApiResponse.success(DeliveryAck.of(event, DeliveryAck.Outcome.APPLIED)); + }); + jdbc.execute("ALTER TABLE event_delivery_attempt ADD CONSTRAINT reject_applied CHECK(outcome <> 'APPLIED')"); + assertThatThrownBy(() -> relay.deliver(eventId)).isInstanceOf(RuntimeException.class); + assertThat(status()).isEqualTo("SENDING"); + assertThat(jdbc.queryForObject("SELECT retry_count FROM fin_wallet_event_outbox", Integer.class)).isZero(); + assertThat(jdbc.queryForObject("SELECT outcome FROM event_delivery_attempt", String.class)).isEqualTo("STARTED"); + verify(client).receive(anyString(), any()); + } + + private String status() { return jdbc.queryForObject("SELECT status FROM fin_wallet_event_outbox", String.class); } +} diff --git a/finance/src/test/java/com/baiyue/finance/controller/EventOperationsControllerTest.java b/finance/src/test/java/com/baiyue/finance/controller/EventOperationsControllerTest.java new file mode 100644 index 000000000..ef3fcfb82 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/controller/EventOperationsControllerTest.java @@ -0,0 +1,22 @@ +package com.baiyue.finance.controller; + +import com.baiyue.common.core.exception.BizException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class EventOperationsControllerTest { + @Test void credentialFailureCannotReadOrMutateDatabase() { + var jdbc = mock(JdbcTemplate.class); + var transactions = mock(PlatformTransactionManager.class); + for (String configured : new String[] {"", "configured-operations-secret-32-bytes"}) { + var controller = new EventOperationsController(jdbc, transactions, new ObjectMapper(), configured); + assertThatThrownBy(() -> controller.inspect(null, "1")).isInstanceOf(BizException.class); + assertThatThrownBy(() -> controller.retry("wrong", "1", null)).isInstanceOf(BizException.class); + } + verifyNoInteractions(jdbc, transactions); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/controller/InternalDeliveryFeeCoverageControllerTest.java b/finance/src/test/java/com/baiyue/finance/controller/InternalDeliveryFeeCoverageControllerTest.java new file mode 100644 index 000000000..07916f586 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/controller/InternalDeliveryFeeCoverageControllerTest.java @@ -0,0 +1,32 @@ +package com.baiyue.finance.controller; + +import com.baiyue.finance.dto.DeliveryFeeCoverageModels; +import com.baiyue.finance.service.DeliveryFeeCoverageQueryService; +import com.baiyue.finance.service.FinanceEventException; +import jakarta.validation.Validation; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class InternalDeliveryFeeCoverageControllerTest { + @Test void requiresTheInternalIdentityBeforeQueryingFinancialData() { + var service = mock(DeliveryFeeCoverageQueryService.class); + var controller = new InternalDeliveryFeeCoverageController(service); + ReflectionTestUtils.setField(controller, "expectedToken", "coverage-test-token"); + var query = new DeliveryFeeCoverageModels.Query(101L, List.of(11L)); + assertThatThrownBy(() -> controller.sources(null, query)).isInstanceOf(FinanceEventException.class); + assertThatThrownBy(() -> controller.sources("wrong-token", query)).isInstanceOf(FinanceEventException.class); + verifyNoInteractions(service); + when(service.query(query)).thenReturn(List.of()); + assertThat(controller.sources("coverage-test-token", query).successful()).isTrue(); + verify(service).query(query); + } + @Test void validatesCustomerAndInboundScope() { + try (var factory = Validation.buildDefaultValidatorFactory()) { + assertThat(factory.getValidator().validate(new DeliveryFeeCoverageModels.Query(0L, List.of()))).hasSize(2); + assertThat(factory.getValidator().validate(new DeliveryFeeCoverageModels.Query(101L, List.of(-1L)))).hasSize(1); + } + } +} diff --git a/finance/src/test/java/com/baiyue/finance/controller/InternalTripFinanceControllerTest.java b/finance/src/test/java/com/baiyue/finance/controller/InternalTripFinanceControllerTest.java new file mode 100644 index 000000000..58d137d07 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/controller/InternalTripFinanceControllerTest.java @@ -0,0 +1,23 @@ +package com.baiyue.finance.controller; +import com.baiyue.finance.dto.TripFinanceModels; +import com.baiyue.finance.service.TripFinanceQueryService; +import com.baiyue.finance.service.FinanceEventException; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +class InternalTripFinanceControllerTest { + @Test void rejectsAbsentOrWrongInternalTokenBeforeReadingFinance() { + var service=mock(TripFinanceQueryService.class);var controller=new InternalTripFinanceController(service,"synthetic-token"); + var query=new TripFinanceModels.Query(List.of(),false); + assertThatThrownBy(()->controller.query(null,query)).isInstanceOf(FinanceEventException.class); + assertThatThrownBy(()->controller.query("wrong",query)).isInstanceOf(FinanceEventException.class); + verifyNoInteractions(service); + } + @Test void validInternalTokenReturnsOnlyTheProviderResult() { + var service=mock(TripFinanceQueryService.class);var query=new TripFinanceModels.Query(List.of(),false); + var result=new TripFinanceModels.Result("PENDING_ALLOCATION",List.of(),List.of(),List.of(),"pending"); + when(service.query(query)).thenReturn(result); + assertThat(new InternalTripFinanceController(service,"synthetic-token").query("synthetic-token",query).data()).isSameAs(result); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostAuthorizationTest.java b/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostAuthorizationTest.java new file mode 100644 index 000000000..8183ac460 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostAuthorizationTest.java @@ -0,0 +1,67 @@ +package com.baiyue.finance.fleet; + +import com.baiyue.common.security.AuthenticatedUser; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.context.annotation.*; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class VehicleCostAuthorizationTest { + @Test void actualMethodSecurityDoesNotLetReadersApprovePayOrVoid() { + try (var context = new AnnotationConfigApplicationContext(Config.class)) { + var controller = context.getBean(PlatformVehicleCostController.class); + var service = context.getBean(VehicleCostService.class); + var user = mock(AuthenticatedUser.class); when(user.userId()).thenReturn(8L); when(user.displayName()).thenReturn("财务人员"); + var request = new VehicleCostModels.Command(0L, null); + login("platform:finance:vehicle-cost:list"); controller.detail("VC-ONE"); + assertThatThrownBy(() -> controller.approve("VC-ONE", user, "approve-one", request)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.pay("VC-ONE", user, "pay-one", null)).isInstanceOf(AccessDeniedException.class); + assertThatThrownBy(() -> controller.voidCost("VC-ONE", user, "void-one", request)).isInstanceOf(AccessDeniedException.class); + login("platform:finance:vehicle-cost:approve"); controller.approve("VC-ONE", user, "approve-one", request); + assertThatThrownBy(() -> controller.pay("VC-ONE", user, "pay-one", null)).isInstanceOf(AccessDeniedException.class); + login("platform:finance:vehicle-cost:pay"); controller.pay("VC-ONE", user, "pay-one", null); + assertThatThrownBy(() -> controller.voidCost("VC-ONE", user, "void-one", request)).isInstanceOf(AccessDeniedException.class); + login("platform:finance:vehicle-cost:void"); controller.voidCost("VC-ONE", user, "void-one", request); + login(); assertThatThrownBy(() -> controller.detail("VC-ONE")).isInstanceOf(AccessDeniedException.class); + verify(service).detail("VC-ONE"); verify(service).approve("VC-ONE", 8L, "财务人员", "approve-one", request); + verify(service).pay("VC-ONE", 8L, "财务人员", "pay-one", null); + verify(service).voidCost("VC-ONE", 8L, "财务人员", "void-one", request); + verifyNoMoreInteractions(service); + } finally { SecurityContextHolder.clearContext(); } + } + private static void login(String... permissions) { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("test", "unused", + java.util.Arrays.stream(permissions).map(SimpleGrantedAuthority::new).toList())); + } + @Test void companyCostActionsHaveIndependentPermissionsAndReadsUseListPermission() throws Exception { + assertThat(PlatformVehicleCostController.class.getMethod("detail", String.class).getAnnotation(PreAuthorize.class).value()) + .isEqualTo("hasAuthority('platform:finance:vehicle-cost:list')"); + for (var entry : java.util.Map.of("approve", "approve", "voidCost", "void").entrySet()) { + assertThat(PlatformVehicleCostController.class.getMethod(entry.getKey(), String.class, AuthenticatedUser.class, String.class, VehicleCostModels.Command.class) + .getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:finance:vehicle-cost:" + entry.getValue() + "')"); + } + assertThat(PlatformVehicleCostController.class.getMethod("pay", String.class, AuthenticatedUser.class, String.class, VehicleCostModels.Payment.class) + .getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('platform:finance:vehicle-cost:pay')"); + } + @Test void internalEndpointsRejectMissingOrWrongServiceIdentityBeforeServiceCalls() { + var service = mock(VehicleCostService.class); + var controller = new InternalVehicleCostController(service, "test-internal-token"); + assertThatThrownBy(() -> controller.receive(null, null)).hasMessageContaining("令牌"); + assertThatThrownBy(() -> controller.sources("wrong", null)).hasMessageContaining("令牌"); + assertThatThrownBy(() -> new InternalVehicleCostController(service, "").receive("", null)).hasMessageContaining("令牌"); + verifyNoInteractions(service); + } + @Configuration @EnableMethodSecurity static class Config { + @Bean VehicleCostService costs() { return mock(VehicleCostService.class); } + @Bean VehicleCostVouchers vouchers() { return mock(VehicleCostVouchers.class); } + @Bean PlatformVehicleCostController controller(VehicleCostService costs, VehicleCostVouchers vouchers) { + return new PlatformVehicleCostController(costs, vouchers); + } + } +} diff --git a/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostHttpContractTest.java b/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostHttpContractTest.java new file mode 100644 index 000000000..9b126e4c3 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostHttpContractTest.java @@ -0,0 +1,23 @@ +package com.baiyue.finance.fleet; + +import com.baiyue.finance.service.FinanceEventException; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.hamcrest.Matchers.*; + +class VehicleCostHttpContractTest { + @Test void fleetBusinessErrorsKeepFinanceCodesAndDiagnosticIds() throws Exception { + var service = mock(VehicleCostService.class); + when(service.detail("VC-ONE")).thenThrow(new FinanceEventException("FIN_409", "公司车辆费用版本已变化")); + var mvc = MockMvcBuilders.standaloneSetup(new PlatformVehicleCostController(service, mock(VehicleCostVouchers.class))) + .setControllerAdvice(new VehicleCostExceptionHandler()).build(); + mvc.perform(get("/api/platform/finance/v1/vehicle-costs/VC-ONE")) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value("FIN_409")) + .andExpect(jsonPath("$.message").value("公司车辆费用版本已变化")) + .andExpect(jsonPath("$.requestId", not(emptyOrNullString()))) + .andExpect(jsonPath("$.traceId", not(emptyOrNullString()))); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostPersistenceTest.java b/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostPersistenceTest.java new file mode 100644 index 000000000..eb31525cd --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostPersistenceTest.java @@ -0,0 +1,187 @@ +package com.baiyue.finance.fleet; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.*; +import com.baiyue.finance.client.WarehouseSupplierClient; +import com.baiyue.finance.service.FinanceDocumentNumberGenerator; +import com.baiyue.finance.service.FinanceSupplierValidator; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import jakarta.validation.Validation; +import jakarta.validation.ValidatorFactory; +import org.junit.jupiter.api.*; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import java.math.BigDecimal; +import java.time.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class VehicleCostPersistenceTest { + private final ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule()); + private JdbcTemplate jdbc; + private VehicleCostService service; + private FinanceSupplierValidator suppliers; + private VehicleCostVouchers vouchers; + private ValidatorFactory validators; + private static final VehicleCostModels.Query PAGE = new VehicleCostModels.Query(1, 20); + @BeforeEach void setup() { + var ds = new DriverManagerDataSource("jdbc:h2:mem:vehicle-finance-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000", "sa", ""); + jdbc = new JdbcTemplate(ds); + jdbc.execute("CREATE TABLE fin_subject(id BIGINT PRIMARY KEY,subject_code VARCHAR(64),status VARCHAR(20))"); + jdbc.update("INSERT INTO fin_subject VALUES(1,'PLATFORM_DEFAULT','ACTIVE')"); + jdbc.execute("CREATE TABLE fin_inbox_event(event_id VARCHAR(128),source_system VARCHAR(32),event_type VARCHAR(64),payload CLOB,payload_hash VARCHAR(71),status VARCHAR(16),result_resource_id BIGINT,processed_at TIMESTAMP(3),UNIQUE(source_system,event_id))"); + jdbc.execute("CREATE TABLE fin_vehicle_cost(id BIGINT PRIMARY KEY,subject_id BIGINT NOT NULL,cost_no VARCHAR(64) NOT NULL UNIQUE,source_type VARCHAR(32) NOT NULL,source_no VARCHAR(64) NOT NULL,source_version BIGINT NOT NULL,source_hash VARCHAR(71) NOT NULL,source_json CLOB NOT NULL,supplier_id BIGINT NOT NULL,supplier_name VARCHAR(200) NOT NULL,amount DECIMAL(17,2) NOT NULL,paid_amount DECIMAL(17,2) NOT NULL,status VARCHAR(32) NOT NULL,record_version BIGINT NOT NULL,created_at TIMESTAMP(3),updated_at TIMESTAMP(3),UNIQUE(subject_id,source_type,source_no),CHECK(paid_amount>=0 AND paid_amount<=amount))"); + jdbc.execute("CREATE TABLE fin_vehicle_cost_revision(id BIGINT PRIMARY KEY,cost_id BIGINT NOT NULL,record_version BIGINT NOT NULL,action VARCHAR(32) NOT NULL,request_key VARCHAR(128) NOT NULL,request_hash VARCHAR(71) NOT NULL,operator_id BIGINT NOT NULL,operator_name VARCHAR(128) NOT NULL,reason VARCHAR(1000),snapshot_json CLOB NOT NULL,occurred_at TIMESTAMP(3),UNIQUE(cost_id,record_version),UNIQUE(operator_id,action,request_key))"); + jdbc.execute("CREATE TABLE fin_vehicle_cost_payment(id BIGINT PRIMARY KEY,cost_id BIGINT NOT NULL,payment_no VARCHAR(64) NOT NULL UNIQUE,amount DECIMAL(17,2) NOT NULL,payment_json CLOB NOT NULL,created_at TIMESTAMP(3))"); + suppliers = mock(FinanceSupplierValidator.class); + when(suppliers.requireActive(8L, null)).thenReturn(new WarehouseSupplierClient.SupplierSnapshot(8L, "维修供应商", null, "ACTIVE")); + vouchers = mock(VehicleCostVouchers.class); + var numbers = mock(FinanceDocumentNumberGenerator.class); var sequence = new AtomicInteger(); + when(numbers.next(anyString())).thenAnswer(call -> call.getArgument(0) + "-" + sequence.incrementAndGet()); + validators = Validation.buildDefaultValidatorFactory(); + var target = new VehicleCostService(jdbc, json, validators.getValidator(), suppliers, numbers, vouchers); + var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource())); + service = (VehicleCostService) proxy.getProxy(); + } + @AfterEach void close() { validators.close(); } + private VehicleCostModels.Source source(long version, String amount) { + return new VehicleCostModels.Source("REPAIR", "VR-ONE", version, "101", "TEST-PLATE", "TR-ONE", 8, + "车辆维修", LocalDate.of(2026, 1, 1), "USD", new BigDecimal(amount), new BigDecimal("7.000000"), "2026-01", + new BigDecimal(amount).multiply(new BigDecimal("7")).setScale(2), null); + } + private EventEnvelope event(VehicleCostModels.Source source) { + var payload = json.valueToTree(source); + return new EventEnvelope(UUID.randomUUID().toString(), VehicleCostModels.EVENT_TYPE, 1, Instant.now(), "0", + "COMPANY_VEHICLE_COST", source.sourceType() + ":" + source.sourceNo(), source.sourceVersion(), null, + UUID.randomUUID().toString(), UUID.randomUUID().toString(), null, payload, + EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + } + private VehicleCostModels.Cost receive() { service.receive(event(source(1, "10"))); return service.list(PAGE).records().get(0); } + private VehicleCostModels.Payment payment(long version, String amount) { + return new VehicleCostModels.Payment(version, new BigDecimal(amount), Instant.parse("2026-01-02T00:00:00Z"), + "BANK_TRANSFER", "PAY-REF", List.of("11"), null); + } + @Test void acceptedSourceIsNotBookedOrPaidAndReplayRequiresTheSameHash() { + var first = event(source(1, "10")); + assertThat(service.receive(first).outcome()).isEqualTo(DeliveryAck.Outcome.APPLIED); + assertThat(service.receive(first).outcome()).isEqualTo(DeliveryAck.Outcome.DUPLICATE); + var other = event(source(1, "11")); + var conflict = new EventEnvelope(first.eventId(), other.eventType(), 1, other.occurredAt(), "0", other.aggregateType(), other.aggregateId(), 1, + null, other.traceId(), other.correlationId(), null, other.payload(), other.payloadHash()); + assertThatThrownBy(() -> service.receive(conflict)).hasMessageContaining("不同公司费用"); + var row = service.list(PAGE).records().get(0); + assertThat(row.status()).isEqualTo("PENDING"); assertThat(row.outstandingAmount()).isNull(); + assertThat(row.paidAmount()).isZero(); assertThat(row.source().amount()).isEqualByComparingTo("70"); + assertThat(service.history(row.costNo(), PAGE).total()).isEqualTo(1); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_inbox_event", Long.class)).isEqualTo(1); + } + @Test void missingVersionCannotApproveOrPayTheInitialRecord() throws Exception { + var row = receive(); + var missing = json.readValue("{}", VehicleCostModels.Command.class); + assertThat(missing.expectedVersion()).isNull(); + assertThatThrownBy(() -> service.approve(row.costNo(), 1, "财务", "missing-version", missing)).hasMessageContaining("参数"); + assertThatThrownBy(() -> service.voidCost(row.costNo(), 1, "财务", "missing-version", missing)).hasMessageContaining("参数"); + var pay = new VehicleCostModels.Payment(null, BigDecimal.ONE, Instant.parse("2026-01-02T00:00:00Z"), "CASH", null, List.of("11"), null); + assertThatThrownBy(() -> service.pay(row.costNo(), 1, "财务", "missing-version", pay)).hasMessageContaining("参数"); + assertThat(service.detail(row.costNo()).status()).isEqualTo("PENDING"); + assertThat(service.history(row.costNo(), PAGE).total()).isEqualTo(1); + } + @Test void failedSourceAuditCannotAcknowledgeOrPersistHalfAnEvent() { + var event = event(source(1, "10")); + jdbc.execute("ALTER TABLE fin_vehicle_cost_revision ADD CONSTRAINT reject_source_audit CHECK(action<>'RECEIVE')"); + assertThatThrownBy(() -> service.receive(event)).isInstanceOf(RuntimeException.class); + assertThat(service.list(PAGE).total()).isZero(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_inbox_event", Long.class)).isZero(); + jdbc.execute("ALTER TABLE fin_vehicle_cost_revision DROP CONSTRAINT reject_source_audit"); + assertThat(service.receive(event).outcome()).isEqualTo(DeliveryAck.Outcome.APPLIED); + assertThat(service.list(PAGE).total()).isEqualTo(1); + } + @Test void concurrentDuplicateEventsOnlyPersistOneSourceAndOneHistory() throws Exception { + var event = event(source(1, "10")); var executor = Executors.newFixedThreadPool(2); + try { + var a = executor.submit(() -> service.receive(event)); var b = executor.submit(() -> service.receive(event)); + assertThat(List.of(a.get(10, TimeUnit.SECONDS).outcome(), b.get(10, TimeUnit.SECONDS).outcome())) + .containsExactlyInAnyOrder(DeliveryAck.Outcome.APPLIED, DeliveryAck.Outcome.DUPLICATE); + } finally { executor.shutdownNow(); } + assertThat(service.list(PAGE).total()).isEqualTo(1); + } + @Test void partialAndFullPaymentConserveAmountsAndReplayNeverPaysTwice() { + var row = receive(); var no = row.costNo(); + assertThatThrownBy(() -> service.pay(no, 1, "财务", "before-approve", payment(0, "1"))).hasMessageContaining("已入账"); + var approved = service.approve(no, 1, "财务", "approve-one", new VehicleCostModels.Command(0L, null)); + assertThat(approved.outstandingAmount()).isEqualByComparingTo("70"); + var partial = service.pay(no, 1, "财务", "payment-one", payment(1, "20")); + assertThat(partial.status()).isEqualTo("PARTIALLY_PAID"); assertThat(partial.outstandingAmount()).isEqualByComparingTo("50"); + assertThat(service.pay(no, 1, "财务", "payment-one", payment(1, "20"))).isEqualTo(partial); + assertThatThrownBy(() -> service.pay(no, 1, "财务", "payment-one", payment(1, "21"))).hasMessageContaining("幂等键"); + assertThatThrownBy(() -> service.pay(no, 1, "财务", "too-much", payment(2, "51"))).hasMessageContaining("未付余额"); + assertThatThrownBy(() -> service.pay(no, 1, "财务", "stale-version", payment(1, "1"))).hasMessageContaining("版本"); + var paid = service.pay(no, 1, "财务", "payment-two", payment(2, "50")); + assertThat(paid.status()).isEqualTo("PAID"); assertThat(paid.outstandingAmount()).isZero(); + assertThat(service.payments(no, PAGE).total()).isEqualTo(2); + assertThatThrownBy(() -> service.voidCost(no, 1, "财务", "void-paid", new VehicleCostModels.Command(3L, null))).hasMessageContaining("已付款"); + assertThatThrownBy(() -> service.receive(event(source(2, "20")))).hasMessageContaining("不能替换"); + } + @Test void unpaidBookedCostRequiresVoidBeforeNewVersionAndRetainsSnapshots() { + var row = receive(); var no = row.costNo(); + service.approve(no, 1, "财务", "approve-one", new VehicleCostModels.Command(0L, null)); + assertThatThrownBy(() -> service.receive(event(source(2, "20")))).hasMessageContaining("先作废"); + service.voidCost(no, 1, "财务", "void-one", new VehicleCostModels.Command(1L, "金额重新核对")); + service.receive(event(source(2, "20"))); + var latest = service.detail(no); + assertThat(latest.status()).isEqualTo("PENDING"); assertThat(latest.source().amount()).isEqualByComparingTo("140"); + assertThat(service.history(no, PAGE).records()).hasSize(4); + assertThat(service.history(no, PAGE).records().get(3).snapshot().source().amount()).isEqualByComparingTo("70"); + assertThatThrownBy(() -> service.receive(event(source(1, "10")))).hasMessageContaining("过期"); + } + @Test void auditFailureRollsBackPaymentAndBalances() { + var row = receive(); var no = row.costNo(); + service.approve(no, 1, "财务", "approve-one", new VehicleCostModels.Command(0L, null)); + jdbc.execute("ALTER TABLE fin_vehicle_cost_revision ADD CONSTRAINT reject_payment_audit CHECK(action<>'PAY')"); + assertThatThrownBy(() -> service.pay(no, 1, "财务", "failing-payment", payment(1, "20"))).isInstanceOf(RuntimeException.class); + assertThat(service.detail(no).paidAmount()).isZero(); assertThat(service.detail(no).recordVersion()).isEqualTo(1); + assertThat(service.payments(no, PAGE).records()).isEmpty(); + } + @Test void voucherOrSupplierFailureDoesNotCreatePayment() { + var row = receive(); service.approve(row.costNo(), 1, "财务", "approve-one", new VehicleCostModels.Command(0L, null)); + doThrow(new IllegalStateException("凭证归属不符")).when(vouchers).validate(anyLong(), anyList()); + assertThatThrownBy(() -> service.pay(row.costNo(), 1, "财务", "payment-one", payment(1, "20"))).hasMessageContaining("归属"); + assertThat(service.payments(row.costNo(), PAGE).total()).isZero(); + reset(vouchers); when(suppliers.requireActive(8L, null)).thenThrow(new IllegalStateException("供应商不可用")); + assertThatThrownBy(() -> service.pay(row.costNo(), 1, "财务", "payment-one", payment(1, "20"))).hasMessageContaining("不可用"); + assertThat(service.detail(row.costNo()).paidAmount()).isZero(); + } + @Test void concurrentPaymentsCannotOverpay() throws Exception { + var row = receive(); service.approve(row.costNo(), 1, "财务", "approve-one", new VehicleCostModels.Command(0L, null)); + var executor = Executors.newFixedThreadPool(2); + try { + Callable first = () -> { try { service.pay(row.costNo(), 1, "财务", "pay-a", payment(1, "50")); return true; } catch (com.baiyue.finance.service.FinanceEventException conflict) { return false; } }; + Callable second = () -> { try { service.pay(row.costNo(), 1, "财务", "pay-b", payment(1, "50")); return true; } catch (com.baiyue.finance.service.FinanceEventException conflict) { return false; } }; + var a = executor.submit(first); var b = executor.submit(second); + assertThat(List.of(a.get(10, TimeUnit.SECONDS), b.get(10, TimeUnit.SECONDS))).containsExactlyInAnyOrder(true, false); + } finally { executor.shutdownNow(); } + assertThat(service.detail(row.costNo()).paidAmount()).isEqualByComparingTo("50"); + assertThat(service.detail(row.costNo()).outstandingAmount()).isEqualByComparingTo("20"); + } + @Test void rejectsClientScopeBadExchangeAndBadSourceBeforeWritingAnything() { + var valid = event(source(1, "10")); + var customer = new EventEnvelope(valid.eventId(), valid.eventType(), 1, valid.occurredAt(), "12", valid.aggregateType(), valid.aggregateId(), 1, + null, valid.traceId(), valid.correlationId(), null, valid.payload(), valid.payloadHash()); + assertThatThrownBy(() -> service.receive(customer)).hasMessageContaining("归属"); + var tree = (com.fasterxml.jackson.databind.node.ObjectNode) valid.payload().deepCopy(); tree.put("amount", 71); + var invalid = new EventEnvelope(UUID.randomUUID().toString(), valid.eventType(), 1, valid.occurredAt(), "0", valid.aggregateType(), valid.aggregateId(), 1, + null, valid.traceId(), valid.correlationId(), null, tree, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(tree))); + assertThatThrownBy(() -> service.receive(invalid)).hasMessageContaining("不守恒"); + assertThat(service.list(PAGE).total()).isZero(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_inbox_event", Long.class)).isZero(); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostVouchersTest.java b/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostVouchersTest.java new file mode 100644 index 000000000..32d0406e6 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/fleet/VehicleCostVouchersTest.java @@ -0,0 +1,42 @@ +package com.baiyue.finance.fleet; + +import com.baiyue.common.core.api.ApiResponse; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class VehicleCostVouchersTest { + @Test void onlyPlatformFilesBoundToThisCompanyCostCanProvePayment() { + var client = mock(VehicleCostFileClient.class); + var service = new VehicleCostVouchers(client, "test-internal-token"); + when(client.metadata("test-internal-token", 5, 11)).thenReturn(ApiResponse.success( + new VehicleCostFileClient.Metadata(11L, "PLATFORM", 0L, "FINANCE_VEHICLE_VOUCHER", "5", "payment.png", "image/png", 100L))); + service.validate(5, List.of("11")); + for (var wrong : List.of( + new VehicleCostFileClient.Metadata(11L, "CUSTOMER", 12L, "FINANCE_VOUCHER", "12", "payment.png", "image/png", 100L), + new VehicleCostFileClient.Metadata(11L, "PLATFORM", 0L, "FINANCE_VEHICLE_VOUCHER", "6", "payment.png", "image/png", 100L), + new VehicleCostFileClient.Metadata(11L, "PLATFORM", 0L, "FLEET_VEHICLE_RECORD_PHOTO", "5", "repair.png", "image/png", 100L))) { + when(client.metadata("test-internal-token", 5, 11)).thenReturn(ApiResponse.success(wrong)); + assertThatThrownBy(() -> service.validate(5, List.of("11"))).hasMessageContaining("归属"); + } + } + @Test void absentConfigurationOrDuplicateVouchersFailClosed() { + var client = mock(VehicleCostFileClient.class); + assertThatThrownBy(() -> new VehicleCostVouchers(client, "").validate(5, List.of("11"))).hasMessageContaining("不可用"); + assertThatThrownBy(() -> new VehicleCostVouchers(client, "test").validate(5, List.of("11", "11"))).hasMessageContaining("重复"); + verifyNoInteractions(client); + } + @Test void fileServiceBusinessErrorBodyCannotBeForwardedAsPaymentEvidence() { + var client = mock(VehicleCostFileClient.class); + var service = new VehicleCostVouchers(client, "test"); + when(client.metadata("test", 5, 11)).thenReturn(ApiResponse.success( + new VehicleCostFileClient.Metadata(11L, "PLATFORM", 0L, "FINANCE_VEHICLE_VOUCHER", "5", "payment.png", "image/png", 100L))); + when(client.download("test", 5, 11)).thenReturn(org.springframework.http.ResponseEntity.ok() + .contentType(org.springframework.http.MediaType.APPLICATION_JSON).body("{\"code\":\"A0404\"}".getBytes(java.nio.charset.StandardCharsets.UTF_8))); + assertThatThrownBy(() -> service.download(5, 11)).hasMessageContaining("不可用"); + when(client.download("test", 5, 11)).thenReturn(org.springframework.http.ResponseEntity.ok() + .contentType(org.springframework.http.MediaType.IMAGE_PNG).body(new byte[100])); + assertThat(service.download(5, 11).getBody()).hasSize(100); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/service/DeliveryFeeCoverageQueryServiceTest.java b/finance/src/test/java/com/baiyue/finance/service/DeliveryFeeCoverageQueryServiceTest.java new file mode 100644 index 000000000..c7b6d0081 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/service/DeliveryFeeCoverageQueryServiceTest.java @@ -0,0 +1,50 @@ +package com.baiyue.finance.service; + +import com.baiyue.finance.dto.DeliveryFeeCoverageModels; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.util.List; +import java.util.UUID; +import static org.assertj.core.api.Assertions.*; + +class DeliveryFeeCoverageQueryServiceTest { + JdbcTemplate jdbc; + DeliveryFeeCoverageQueryService service; + @BeforeEach void setup() { + jdbc = new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", "")); + jdbc.execute("CREATE TABLE fin_fee_source(id BIGINT PRIMARY KEY,source_system VARCHAR(32),source_fee_id BIGINT,source_type VARCHAR(32),source_fee_version INT,customer_id BIGINT,inbound_id BIGINT,source_no VARCHAR(64),direction VARCHAR(16),review_status VARCHAR(16),status VARCHAR(16),fee_item_code VARCHAR(64),fee_item_name VARCHAR(128),currency_code VARCHAR(3),amount DECIMAL(20,6),source_hash VARCHAR(71),inbound_code VARCHAR(64))"); + service = new DeliveryFeeCoverageQueryService(jdbc); + } + @Test void returnsOnlyThisCustomersActualInboundLatestApprovedReceivables() { + insert(1, 701, 1, 101, 11, "FIRSTLEG", "RECEIVABLE", "APPROVED", "AVAILABLE"); + insert(2, 701, 2, 101, 11, "FIRSTLEG", "RECEIVABLE", "APPROVED", "AVAILABLE"); + insert(3, 702, 1, 202, 11, "FIRSTLEG", "RECEIVABLE", "APPROVED", "AVAILABLE"); + insert(4, 703, 1, 101, 11, "FIRSTLEG", "PAYABLE", "APPROVED", "AVAILABLE"); + insert(5, 704, 1, 101, 11, "LASTLEG", "RECEIVABLE", "APPROVED", "AVAILABLE"); + insert(6, 705, 1, 101, 11, "FIRSTLEG", "RECEIVABLE", "PENDING", "AVAILABLE"); + insert(7, 706, 1, 101, 11, "FIRSTLEG", "RECEIVABLE", "APPROVED", "VOIDED"); + insert(8, 707, 1, 101, 11, "FIRSTLEG", "RECEIVABLE", "APPROVED", "SUPERSEDED"); + insert(9, 708, 1, 101, 11, "FIRSTLEG", "RECEIVABLE", "APPROVED", "SETTLED"); + insert(10, 709, 1, 101, 12, "FIRSTLEG", "RECEIVABLE", "APPROVED", "AVAILABLE"); + var rows = service.query(new DeliveryFeeCoverageModels.Query(101L, List.of(11L, 11L))); + assertThat(rows).extracting(DeliveryFeeCoverageModels.Fee::financeFeeId).containsExactly(2L, 9L); + assertThat(rows).allSatisfy(row -> { assertThat(row.customerId()).isEqualTo(101); assertThat(row.sourceInboundId()).isEqualTo(11); assertThat(row.sourceHash()).startsWith("sha256:"); }); + assertThat(service.query(new DeliveryFeeCoverageModels.Query(101L, List.of(999L)))).isEmpty(); + } + @Test void aVoidedLatestVersionCannotFallBackToAnOlderActiveVersion() { + insert(1, 701, 1, 101, 11, "FIRSTLEG", "RECEIVABLE", "APPROVED", "AVAILABLE"); + insert(2, 701, 2, 101, 11, "FIRSTLEG", "RECEIVABLE", "APPROVED", "VOIDED"); + assertThat(service.query(new DeliveryFeeCoverageModels.Query(101L, List.of(11L)))).isEmpty(); + } + @Test void aContainerBusinessNumberIsNotMislabelledAsAnInboundCode() { + insert(1, 701, 1, 101, 11, "FIRSTLEG", "RECEIVABLE", "APPROVED", "AVAILABLE"); + jdbc.update("UPDATE fin_fee_source SET source_type='CONTAINER_FEE',source_no='MSCU1234567'"); + assertThat(service.query(new DeliveryFeeCoverageModels.Query(101L, List.of(11L))).get(0).inboundCode()).isNull(); + } + void insert(long id, long fee, int version, long customer, long inbound, String system, String direction, String review, String status) { + jdbc.update("INSERT INTO fin_fee_source VALUES(?, ?, ?, 'INBOUND_FEE', ?, ?, ?, ?, ?, ?, ?, 'FREIGHT', '原运费', 'CNY', 100, ?, NULL)", + id, system, fee, version, customer, inbound, "IN-" + inbound, direction, review, status, "sha256:" + "a".repeat(64)); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/service/FinanceDocumentNumberGeneratorTest.java b/finance/src/test/java/com/baiyue/finance/service/FinanceDocumentNumberGeneratorTest.java index 26720351a..ba0177c12 100644 --- a/finance/src/test/java/com/baiyue/finance/service/FinanceDocumentNumberGeneratorTest.java +++ b/finance/src/test/java/com/baiyue/finance/service/FinanceDocumentNumberGeneratorTest.java @@ -56,4 +56,15 @@ class FinanceDocumentNumberGeneratorTest { .isInstanceOf(FinanceEventException.class) .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409"); } + + @Test + void vehicleCostsAndPaymentsUseIndependentSequenceKeys() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(1L); + FinanceDocumentNumberGenerator generator = new FinanceDocumentNumberGenerator(jdbc, CLOCK); + assertThat(generator.next("VC")).isEqualTo("VC-260818-0001"); + assertThat(generator.next("VP")).isEqualTo("VP-260818-0001"); + verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_document_sequence"), eq("VC:20260818")); + verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_document_sequence"), eq("VP:20260818")); + } } diff --git a/finance/src/test/java/com/baiyue/finance/service/TripFinanceQueryServiceTest.java b/finance/src/test/java/com/baiyue/finance/service/TripFinanceQueryServiceTest.java new file mode 100644 index 000000000..c0fe30027 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/service/TripFinanceQueryServiceTest.java @@ -0,0 +1,126 @@ +package com.baiyue.finance.service; +import com.baiyue.finance.dto.TripFinanceModels; +import org.junit.jupiter.api.*; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.util.*; +import java.math.BigDecimal; +import static org.assertj.core.api.Assertions.*; +class TripFinanceQueryServiceTest { + JdbcTemplate jdbc;TripFinanceQueryService service; + @BeforeEach void setup() { + jdbc=new JdbcTemplate(new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","")); + jdbc.execute("CREATE TABLE fin_fee_source(id BIGINT,customer_id BIGINT,business_no VARCHAR(64),business_type VARCHAR(32),source_no VARCHAR(64),fee_item_name VARCHAR(64),source_fee_id BIGINT,source_fee_version INT,source_system VARCHAR(32),source_type VARCHAR(32),direction VARCHAR(32),review_status VARCHAR(32),status VARCHAR(32),currency_code VARCHAR(3),amount DECIMAL(20,6))"); + jdbc.execute("CREATE TABLE fin_bill(id BIGINT,tenant_id BIGINT,bill_no VARCHAR(64),currency_code VARCHAR(3),status VARCHAR(16))"); + jdbc.execute("CREATE TABLE fin_bill_line(tenant_id BIGINT,bill_id BIGINT,source_id BIGINT,source_version INT,currency_code VARCHAR(3))"); + jdbc.execute("CREATE TABLE fin_receipt(id BIGINT,tenant_id BIGINT,status VARCHAR(16),currency_code VARCHAR(3))"); + jdbc.execute("CREATE TABLE fin_receipt_bill(tenant_id BIGINT,receipt_id BIGINT,bill_id BIGINT,applied_amount DECIMAL(20,6))"); + service=new TripFinanceQueryService(jdbc); + } + void fee(long id,long customer,String business,int version,String currency,String amount,String status) { + jdbc.update("INSERT INTO fin_fee_source VALUES(?,?,?,'DELIVERY_ORDER',?,'Delivery',99,?,'LASTLEG','OVERSEAS_DELIVERY_FEE','RECEIVABLE','APPROVED',?,?,?)",id,customer,business,business,version,status,currency,new BigDecimal(amount)); + } + TripFinanceModels.Query request(boolean receipts) {return new TripFinanceModels.Query(List.of(new TripFinanceModels.Business(1,"DL-A")),receipts);} + void bill(long source,int version) { + jdbc.update("INSERT INTO fin_bill VALUES(1,1,'BILL-A','CNY','ISSUED')"); + jdbc.update("INSERT INTO fin_bill_line VALUES(1,1,?,?,'CNY')",source,version); + // Fixture models the real ISSUE source state; this does not call the write service. + jdbc.update("UPDATE fin_fee_source SET status='BILLED' WHERE id=? AND status='AVAILABLE'",source); + } + void receipt(String amount) { + jdbc.update("INSERT INTO fin_receipt VALUES(1,1,'CONFIRMED','CNY')"); + jdbc.update("INSERT INTO fin_receipt_bill VALUES(1,1,1,?)",new BigDecimal(amount)); + } + @Test void exactWholeBillUsesRecordedAllocationAmount() { + fee(1,1,"DL-A",1,"CNY","20","AVAILABLE");bill(1,1);receipt("3.5"); + var result=service.query(request(true));assertThat(result.totals().get(0).receivedAmount()).isEqualByComparingTo("3.5"); + assertThat(result.totals().get(0).receivableAmount()).isEqualByComparingTo("20"); + assertThat(result.documents().get(0).allocationStatus()).isEqualTo("ATTRIBUTED"); + } + @Test void legalSourceAndBillStatesKeepReceivableAndRecordedReceiptAcrossLifecycle() { + for(String sourceStatus:List.of("AVAILABLE","BILLED","SETTLED"))for(String billStatus:List.of("ISSUED","PARTIALLY_PAID","PAID")) { + setup();fee(1,1,"DL-A",1,"CNY","20",sourceStatus);bill(1,1); + jdbc.update("UPDATE fin_fee_source SET status=?",sourceStatus); + jdbc.update("UPDATE fin_bill SET status=?",billStatus); + if(!"ISSUED".equals(billStatus))receipt("PAID".equals(billStatus)?"20":"3.5"); + var result=service.query(request(true)); + assertThat(result.sources()).as(sourceStatus+"/"+billStatus).hasSize(1); + assertThat(result.sources().get(0).status()).isEqualTo(sourceStatus); + assertThat(result.totals().get(0).receivableAmount()).isEqualByComparingTo("20"); + assertThat(result.documents()).hasSize(1); + if("ISSUED".equals(billStatus)) { + assertThat(result.totals().get(0).receivedAmount()).isNull(); + assertThat(result.documents().get(0).allocationStatus()).isEqualTo("PENDING_ALLOCATION"); + } else { + assertThat(result.totals().get(0).receivedAmount()).isEqualByComparingTo("PAID".equals(billStatus)?"20":"3.5"); + assertThat(result.documents().get(0).allocationStatus()).isEqualTo("ATTRIBUTED"); + } + } + } + @Test void lockedVoidedAndSupersededSourcesRemainExcluded() { + for(String status:List.of("LOCKED","VOIDED","SUPERSEDED")) { + setup();fee(1,1,"DL-A",1,"CNY","20",status);bill(1,1);receipt("20"); + var result=service.query(request(true));assertThat(result.sources()).as(status).isEmpty(); + assertThat(result.totals()).isEmpty();assertThat(result.documents()).isEmpty(); + } + } + @Test void issuedPartialAndPaidBillsKeepTheirConfirmedAllocation() { + for(String status:List.of("ISSUED","PARTIALLY_PAID","PAID")) { + setup();fee(1,1,"DL-A",1,"CNY","20","AVAILABLE");bill(1,1); + String amount="PAID".equals(status)?"20":"3.5";receipt(amount); + jdbc.update("UPDATE fin_bill SET status=?",status); + var result=service.query(request(true)); + assertThat(result.documents()).as(status).hasSize(1); + assertThat(result.documents().get(0).allocationStatus()).isEqualTo("ATTRIBUTED"); + assertThat(result.totals().get(0).receivedAmount()).as(status).isEqualByComparingTo(amount); + } + } + @Test void draftPendingApprovedAndVoidedBillsNeverContributeReceipts() { + for(String status:List.of("DRAFT","PENDING_REVIEW","APPROVED","VOIDED")) { + setup();fee(1,1,"DL-A",1,"CNY","20","AVAILABLE");bill(1,1);receipt("3.5"); + jdbc.update("UPDATE fin_bill SET status=?",status); + var result=service.query(request(true));assertThat(result.documents()).as(status).isEmpty(); + assertThat(result.totals().get(0).receivedAmount()).isNull(); + } + } + @Test void paidBillStillEnforcesCustomerVersionAndCurrencyAttribution() { + for(String change:List.of("source_version=2","tenant_id=2","currency_code='USD'")) { + setup();fee(1,1,"DL-A",1,"CNY","20","AVAILABLE");bill(1,1);receipt("20"); + jdbc.update("UPDATE fin_bill SET status='PAID'");jdbc.update("UPDATE fin_bill_line SET "+change); + assertThat(service.query(request(true)).totals().get(0).receivedAmount()).as(change).isNull(); + } + } + @Test void mixedBillNeverProratesReceipt() { + fee(1,1,"DL-A",1,"CNY","20","AVAILABLE");bill(1,1);receipt("30"); + jdbc.update("INSERT INTO fin_bill_line VALUES(1,1,999,1,'CNY')"); + jdbc.update("UPDATE fin_bill SET status='PAID'"); + var result=service.query(request(true));assertThat(result.totals().get(0).receivedAmount()).isNull(); + assertThat(result.documents()).hasSize(1); + assertThat(result.documents().get(0).billNo()).isEqualTo("BILL-A"); + assertThat(result.documents().get(0).allocationStatus()).isEqualTo("PENDING_ALLOCATION"); + } + @Test void unbilledOrNoConfirmedReceiptIsUnknownNotZero() { + fee(1,1,"DL-A",1,"CNY","0","AVAILABLE"); + assertThat(service.query(request(true)).totals().get(0).receivedAmount()).isNull(); + bill(1,1);var unpaid=service.query(request(true));assertThat(unpaid.totals().get(0).receivedAmount()).isNull(); + assertThat(unpaid.documents()).hasSize(1);assertThat(unpaid.documents().get(0).allocationStatus()).isEqualTo("PENDING_ALLOCATION"); + assertThat(service.query(request(true)).totals().get(0).receivableAmount()).isEqualByComparingTo("0"); + } + @Test void latestVoidedDoesNotFallBackAndOtherCustomerExcluded() { + fee(1,1,"DL-A",1,"CNY","20","BILLED");fee(2,1,"DL-A",2,"CNY","20","VOIDED"); + fee(3,2,"DL-A",1,"CNY","999","BILLED"); + var result=service.query(request(true));assertThat(result.sources()).isEmpty();assertThat(result.status()).isEqualTo("PENDING_ALLOCATION"); + } + @Test void currenciesSeparateAndNoReceiptPermissionMasksDocuments() { + fee(1,1,"DL-A",1,"CNY","20","AVAILABLE"); + jdbc.update("UPDATE fin_fee_source SET source_fee_id=1 WHERE id=1"); + fee(2,1,"DL-A",1,"USD","5","AVAILABLE"); + var result=service.query(request(false));assertThat(result.totals()).extracting(TripFinanceModels.Money::currency).containsExactly("CNY","USD"); + assertThat(result.totals()).allSatisfy(m->assertThat(m.receivedAmount()).isNull());assertThat(result.documents()).isEmpty(); + } + @Test void wholeTripBillIsCountedOnceAcrossTwoFeeLines() { + fee(1,1,"DL-A",1,"CNY","20","AVAILABLE");jdbc.update("UPDATE fin_fee_source SET source_fee_id=1 WHERE id=1"); + fee(2,1,"DL-A",1,"CNY","10","AVAILABLE");bill(1,1);jdbc.update("INSERT INTO fin_bill_line VALUES(1,1,2,1,'CNY')");receipt("7"); + var result=service.query(request(true));assertThat(result.documents()).hasSize(1);assertThat(result.totals().get(0).receivedAmount()).isEqualByComparingTo("7"); + } +} diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 691ae867a..38d0741ce 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -9,12 +9,15 @@ ## 模块入口 +事件人工重试:`controller/EventOperationsController.java` 提供内部 `/internal/firstleg/event-operations/{entryId}` 查询及 `/retry` 写入,只允许装柜完成与船节点事件;独立 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可修改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务提交,脚本见根 `scripts/event-outbox-operations.mjs`。 + 柜次号与散货托盘码:`service/FirstlegDocumentNumberGenerator.java` 为新柜生成全局连续且不按日期重置的 `D0001`、`D0002` 柜次号;`service/LoadingPalletLabelService.java`、`mapper/LoadingPalletLabelMapper.java`、`controller/PlatformLoadingListController.java` 负责散货托盘码,契约为 `src/main/resources/openapi/loading-lists-v1.yaml` 的 `/pallet-labels/prepare` 与 `/pallet-labels/print`。仅在计划 `LOADING/COMPLETED` 且真实柜号有效时,为非一件代发的 FBA 明细按平台柜次号、FBA 地址内部编号、三位序号生成冻结标签;托盘码不依赖可跨提单复用的真实柜号,内部编号从 Warehouse 启用主数据读取,缺失或同柜重复时拒绝生成。重复生成须核对已有标签与明细一致,冲突直接拒绝。全部或按仓指定张数打印统一调用 Print `/internal/print/v1/pdfs/fba-pallet-labels`,模板 `FBA_PALLET_A4_LANDSCAPE_V1` 每托一页 297×210 mm,顶部为 SCK8 等 FBA 仓码,条码和底部文字为完整托盘号,与海外补打共用版式;装柜完成通过 `LoadingCompletedOutboxService.java` 传递来源托盘。 | 目标 | 入口 | |---|---| | 内部模块化基线与依赖分析 | `docs/firstleg-internal-modularization-baseline.md`、`scripts/analyze-firstleg-dependencies.mjs`、`scripts/analyze-firstleg-dependencies.test.mjs`、`src/test/resources/architecture/firstleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 | | 履约任务/分配 | `controller/PlatformFulfillmentAssignmentController.java`、`service/FulfillmentTaskService.java` | +| 客户工作台筛选范围 | `service/MerchantWorkbenchFilterService.java`、`mapper/MerchantWorkbenchFilterMapper.java`、`dto/MerchantWorkbenchFilters.java`;内部入口 `InternalInboundOrderController` 的 `/unified-orders/workbench-filters`,按客户只读计算履约、费用待确认与最新已装柜计划的报关待上传订单范围,Order 负责最终筛选和分页 | | 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.java` | | 入库单扣货、履约门禁与凭证 | `service/InboundHoldService.java`、`InboundHoldGuard.java`、`InboundHoldEvidenceService.java`、`client/InboundHoldFileClient.java`;凭证固定按客户、入库单和 `INBOUND_HOLD_EVIDENCE` 精确归属,只有进入不可变扣货历史的文件可下载 | | Order 申报同步与渠道变更履约门禁 | `controller/InternalInboundOrderController.java` 的 `/order-channel-changes/validate`、`/order-declaration-updated`,`service/OrderDeclarationSyncService.java`、`mapper/OrderDeclarationSyncMapper.java`、`dto/OrderDeclarationSync.java`;契约 `src/main/resources/openapi/firstleg-v1.yaml`,只接受内部服务令牌 | @@ -41,20 +44,20 @@ | 货柜与全局智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`client/PendingReviewOrderClient.java`、`loading/`;全局候选合并本服务履约投影与 Order `/internal/order/v1/pending-review-orders` 权威待审核页,工作台柜次从 `/inbound-candidates`、`/containers` 进入;普通箱数、待审核占位、同柜组和全部未拣移除分别走 `/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,明细规格拆分留在服务端 | | 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 | | PDA 拣货与装柜、证据幂等上传 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java`、`loading/service/CabinetLoadingEvidenceService.java`;证据写入和 multipart 上传均先以任务内幂等键匹配请求哈希,上传哈希覆盖仓库、版本、证据类型、锁码、备注、文件名、媒体类型、大小与文件内容;完全重放返回当前视图且不得再次上传 File,相同键不同内容返回冲突 | -| 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`loading/mapper/CabinetLoadingEvidenceMapper.java`、`client/EventBridgeClient.java`;当前发布 V3,从 `fl_inbound_order.destination_type` 冻结 `orders[].destinationType`(`FBA` 或 `OVERSEAS`),OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数,并冻结物理柜五类有效装柜照片引用;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` | +| 装柜完成直连事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`loading/mapper/CabinetLoadingEvidenceMapper.java`、`client/LastlegEventClient.java`;当前发布 V3,从 `fl_inbound_order.destination_type` 冻结 `orders[].destinationType`(`FBA` 或 `OVERSEAS`),OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数,并冻结物理柜五类有效装柜照片引用;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 海外暂扣状态回写 | 同一内部事件入口、消费者 `service/OverseasInboundOrderDetainedConsumer.java`;按来源入仓单 ID 更新 `fl_inbound_order.hold_flag/current_hold_reason`,复用跨区 Inbox 并写 `fl_inbound_hold_record` | | 海外派送完成回写 | 同一内部事件入口、消费者 `service/OverseasDeliveryCompletedConsumer.java`;按来源入仓单、订单、入仓码和 FBA 仓校验后推进当前入仓单 `LOADED→DELIVERED`,同一 Order 全部有效入仓单完成后才经 `OrderFulfillmentCompletionClient` 推进 Order `COMPLETED` | | 国内货柜履约海运订舱、资料/打印、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`OceanBookingDocumentService.java`、`OceanBookingPrintService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;文件经 `client/OceanBookingFileClient.java` 进入 File,关联候选 `/api/platform/v1/ocean-bookings/link-candidates`,契约 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | | 海外仓业务国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/海外仓业务/船节点标准化契约.md` | | 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING/OVERSEAS` 海外车队供应商,依次选择车队、预约提柜、确认实际提柜,海外卸柜收货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 邮件由 `terminaldrayage/service/TerminalDrayageMailService.java` 入队,`TerminalDrayageMailDispatcher.java` 顺序投递,模板与 SMTP 配置见 `docs/terminal-drayage-mail.md`;首次选队不发信,换队事务内生成取消与请求两封邮件。 | -| 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后按 `containerBookingId` 柜级发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,信封固定使用 `tenantId=0` 平台技术范围且 Payload 不携带客户,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | +| 船节点交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后按 `containerBookingId` 柜级发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,信封固定使用 `tenantId=0` 平台技术范围且 Payload 不携带客户,复用 `LoadingCompletedOutboxRepository`/Relay 直投 Lastleg `/internal/lastleg/events`;消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java`,ACK/重试按 `../../docs/统一认证与服务通信契约.md` | | Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | | 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单/有效订舱文件或已有非终态预约的订舱;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张有效预约,已有完成单不得重建收费;PDA 完成不推进拖车状态,全部要求货柜确认国内离仓后由 `TruckAppointmentCompletionService` 自动完成并经 `TruckAppointmentFeeService` 发布应付 | | 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;正式申报批次与海关结果在同一模块独立记录,不改报关四态、PDA 或船期事实;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` | | 买单订单明细导出 Provider | `customs/container/controller/LoadingListBuyDeclarationExportController.java`、`service/LoadingListBuyDeclarationExportService.java`;只为 File 导出中心提供 `LOADING_LIST_BUY_DECLARATION_ITEMS` 的已完成装柜实际买单行,不在 Firstleg 生成文件 | | 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` | -| 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java`、`LoadingEvidenceFileClient.java`、`EventBridgeClient.java` | +| 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java`、`LoadingEvidenceFileClient.java`、`LastlegEventClient.java` | | 仓库公共供应商快照 | `client/WarehouseSupplierClient.java`、`service/WarehouseSupplierSnapshotService.java`;报关、订舱和拖车分别消费 `customs-v1.yaml`、`ocean-bookings-v1.yaml`、`truck-appointments-v1.yaml` 中的供应商 ID,并由 Warehouse 返回服务端名称快照 | | 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` | diff --git a/firstleg/pom.xml b/firstleg/pom.xml index 98dc43916..a9e9e245a 100644 --- a/firstleg/pom.xml +++ b/firstleg/pom.xml @@ -5,6 +5,7 @@ com.baiyue.firstlegbaiyue-firstleg-platform1.0.0-SNAPSHOT../pom.xml firstleg-service + com.h2databaseh2test org.springframework.bootspring-boot-starter-mail com.baiyue.sharedbaiyue-common-core${project.version} com.baiyue.sharedcommon-event-contract${project.version} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java deleted file mode 100644 index f48074f63..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.baiyue.firstleg.client; - -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.event.EventEnvelope; -import org.springframework.cloud.openfeign.FeignClient; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; - -/** 将本地业务 Outbox 投递到跨区事件桥。 */ -@FeignClient(name = "event-bridge-service", contextId = "loadingCompletedEventBridgeClient", url = "${baiyue.lastleg.event-bridge-url:}") -public interface EventBridgeClient { - @PostMapping("/internal/event-bridge/events") - ApiResponse receive( - @RequestHeader("X-Internal-Service-Token") String token, - @RequestBody EventEnvelope envelope); - - record Acknowledgement(String eventId, String result, String receivedAt) { } -} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/LastlegEventClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/LastlegEventClient.java new file mode 100644 index 000000000..b6ea27a97 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/LastlegEventClient.java @@ -0,0 +1,20 @@ +package com.baiyue.firstleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.web.EventDeliveryFeignConfiguration; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.DeliveryAck; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +/** 将本地业务 Outbox 投递到Lastleg 消费者。 */ +@FeignClient(configuration = EventDeliveryFeignConfiguration.class, name = "lastleg-service", contextId = "loadingCompletedLastlegEventClient", url = "${LASTLEG_SERVICE_URL:${baiyue.clients.lastleg.url:}}") +public interface LastlegEventClient { + @PostMapping("/internal/lastleg/events") + ApiResponse receive( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestBody EventEnvelope envelope); + +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentCompletionClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentCompletionClient.java index 8833d0115..1e8a0e4b1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentCompletionClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentCompletionClient.java @@ -15,7 +15,7 @@ public interface OrderFulfillmentCompletionClient { ApiResponse complete(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody Request request); - record Request(String tenantId, String orderId, String orderNo, String inboundCode, + record Request(String tenantId, String orderId, String orderNo, String inboundCode, String rootOrderId, String deliveryNo, Instant completedAt, String eventId, String payloadHash) { } - record Result(Long orderId, String status, boolean replayed) { } + record Result(Long orderId, String status, boolean replayed, Long rootOrderId, String rootStatus) { } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/EventConsumptionExceptionHandler.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/EventConsumptionExceptionHandler.java new file mode 100644 index 000000000..cda48dd7a --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/EventConsumptionExceptionHandler.java @@ -0,0 +1,21 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.EventProtocolException; +import com.baiyue.common.event.EventDependencyNotReadyException; +import org.slf4j.MDC; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import java.time.Instant; + +@RestControllerAdvice +@Order(Ordered.HIGHEST_PRECEDENCE) +public class EventConsumptionExceptionHandler { + @ExceptionHandler({EventProtocolException.class, EventDependencyNotReadyException.class}) + public ApiResponse event(IllegalArgumentException failure) { + String code = failure instanceof EventDependencyNotReadyException ? "EVT_DEPENDENCY" : "EVT_PROTOCOL"; + return new ApiResponse<>(code, failure.getMessage(), null, MDC.get("requestId"), MDC.get("traceId"), Instant.now()); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/EventOperationsController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/EventOperationsController.java new file mode 100644 index 000000000..1535c3e26 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/EventOperationsController.java @@ -0,0 +1,56 @@ +package com.baiyue.firstleg.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.outbox.OutboxOperations; +import com.baiyue.common.core.outbox.EventOperationsTokenGuard; +import com.baiyue.common.event.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.bind.annotation.*; +import java.util.Set; + +/** Internal-only operator recovery. It cannot mutate the original event or call a consumer directly. */ +@RestController +@RequestMapping("/internal/firstleg/event-operations") +public class EventOperationsController { + private final EventOperationsTokenGuard guard; + private final OutboxOperations operations; + + public EventOperationsController(JdbcTemplate jdbc, PlatformTransactionManager transactions, + ObjectMapper mapper, @Value("${baiyue.event-delivery.operations-token:}") String token) { + guard = new EventOperationsTokenGuard(token); + operations = new OutboxOperations(jdbc, transactions, + new OutboxOperations.Layout("fl_inbound_outbox", "id", "publish_status", Set.of(EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED)), + json -> { + try { + EventEnvelope envelope = mapper.readValue(json, EventEnvelope.class).requireValid(); + if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) { + throw new IllegalArgumentException("Hash mismatch"); + } + return new OutboxOperations.Identity(envelope.eventId(), envelope.eventType(), envelope.payloadHash()); + } catch (Exception invalid) { + throw new BizException(ResultCode.CONFLICT, "原事件格式或摘要无效,禁止人工重试"); + } + }); + } + + @GetMapping("/{entryId}") + public ApiResponse inspect( + @RequestHeader(value = "X-Event-Operations-Token", required = false) String token, + @PathVariable String entryId) { + guard.require(token); + return ApiResponse.success(operations.inspect(entryId)); + } + + @PostMapping("/{entryId}/retry") + public ApiResponse retry( + @RequestHeader(value = "X-Event-Operations-Token", required = false) String token, + @PathVariable String entryId, @RequestBody OutboxOperations.RetryRequest request) { + guard.require(token); + return ApiResponse.success(operations.retry(entryId, request)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java index 24b464e85..88cd299c9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java @@ -43,10 +43,12 @@ public class InternalInboundOrderController { private InboundCartonLabelService cartonLabels; @Resource private UnifiedInboundQueryService unifiedQueryService; + @Resource private com.baiyue.firstleg.service.MerchantWorkbenchFilterService workbenchFilters; @Resource private OrderDeclarationSyncService declarationSync; @Resource private OrderRemarksSyncService remarksSync; + @Resource private com.baiyue.firstleg.service.OrderFulfillmentSnapshotService fulfillmentSnapshot; @Value("${baiyue.security.internal-service-token}") private String internalToken; private byte[] expectedToken; @@ -56,6 +58,14 @@ public class InternalInboundOrderController { expectedToken = internalToken.getBytes(StandardCharsets.UTF_8); } + @PostMapping("/order-fulfillment-completed") + public ApiResponse completeFulfillmentSnapshot( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody com.baiyue.firstleg.dto.OrderFulfillmentCompleted command) { + requireToken(token); + return ApiResponse.success(fulfillmentSnapshot.complete(command)); + } + @PostMapping("/order-approved") public ApiResponse createFromApprovedOrder( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @@ -139,6 +149,14 @@ public class InternalInboundOrderController { return ApiResponse.success(remarksSync.sync(command)); } + @PostMapping("/unified-orders/workbench-filters") + public ApiResponse workbenchFilters( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody com.baiyue.firstleg.dto.MerchantWorkbenchFilters.Request request) { + requireToken(token); + return ApiResponse.success(workbenchFilters.read(request)); + } + @PostMapping("/unified-orders/query") public ApiResponse> unifiedPage( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java index 039cce166..40d0ef37f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalOverseasContainerEventController.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.event.EventTypes; import com.baiyue.firstleg.service.OverseasContainerEventConsumer; import com.baiyue.firstleg.service.OverseasInboundOrderDetainedConsumer; @@ -27,14 +28,17 @@ public class InternalOverseasContainerEventController { @Value("${baiyue.security.internal-service-token}") private String expectedToken; @PostMapping("/internal/firstleg/v1/container-events") - public ApiResponse receive( + public ApiResponse receive( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @RequestBody EventEnvelope event) { authenticate(token); - Object result = EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED.equals(event.eventType()) - ? detainedConsumer.consume(event) - : EventTypes.OVERSEAS_DELIVERY_COMPLETED.equals(event.eventType()) - ? deliveryCompletedConsumer.consume(event) : consumer.consume(event); + event.requireValid(); + DeliveryAck result = switch (event.eventType()) { + case EventTypes.OVERSEAS_CONTAINER_UNLOADED -> consumer.consume(event); + case EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED -> detainedConsumer.consume(event); + case EventTypes.OVERSEAS_DELIVERY_COMPLETED -> deliveryCompletedConsumer.consume(event); + default -> throw new com.baiyue.common.event.EventProtocolException("不支持的 Firstleg 业务事件类型"); + }; return ApiResponse.success(result); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java index e4946da34..0cb37d577 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/mapper/ContainerCustomsMapper.java @@ -8,6 +8,16 @@ import java.util.List; @Mapper public interface ContainerCustomsMapper { + /** Same settled loading-item scope as the V3 handover; retained skipped rows are not customs cargo. */ + String ACTUAL_ITEM_SCOPE = """ + EXISTS (SELECT 1 FROM fl_loading_pick_item actual_pick + JOIN fl_loading_pick_task actual_task ON actual_task.id=actual_pick.pick_task_id + WHERE actual_task.loading_plan_id=item.loading_plan_id + AND actual_pick.loading_plan_item_id=item.id + AND actual_pick.item_status IN ('PICKED','PARTIAL') + AND actual_pick.picked_package_count > 0 + AND actual_pick.picked_package_count=item.package_count) + """; String DOCUMENT_COLUMNS = """ d.id id,d.customs_container_id customsContainerId,d.tenant_id tenantId,d.file_id fileId, d.file_name fileName,d.file_hash fileHash,d.content_type contentType,d.size_bytes sizeBytes, @@ -36,6 +46,9 @@ public interface ContainerCustomsMapper { JOIN fl_loading_plan_item item ON item.loading_plan_id=p.id AND item.deleted=0 JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0 WHERE p.id=#{planId} + AND (p.plan_status != 'COMPLETED' OR + """ + ACTUAL_ITEM_SCOPE + """ + ) ORDER BY i.tenant_id,i.id """) List planInbounds(@Param("planId") long planId); @@ -46,6 +59,8 @@ public interface ContainerCustomsMapper { JOIN fl_loading_plan p ON p.id=item.loading_plan_id AND p.plan_status='COMPLETED' JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0 WHERE item.loading_plan_id=#{planId} AND item.deleted=0 + AND + """ + ACTUAL_ITEM_SCOPE + """ AND (#{tenantId} IS NULL OR i.tenant_id=#{tenantId}) AND (#{taxMode} IS NULL OR i.tax_mode=#{taxMode}) """) @@ -66,14 +81,17 @@ public interface ContainerCustomsMapper { JOIN fl_loading_plan_item item ON item.loading_plan_id=p.id AND item.deleted=0 JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0 WHERE p.plan_status='COMPLETED' AND i.tenant_id=#{tenantId} AND i.order_id=#{orderId} AND i.tax_mode='TAX_REFUND' + AND + """ + ACTUAL_ITEM_SCOPE + """ ORDER BY COALESCE(p.confirmed_at,p.updated_at) DESC,p.id DESC LIMIT 1 """) Long completedPlanForOrder(@Param("tenantId") long tenantId, @Param("orderId") long orderId); @Insert(""" - INSERT IGNORE INTO fl_customs_container + INSERT INTO fl_customs_container (id,loading_plan_id,container_booking_id,status,created_at,updated_at,version) VALUES (#{id},#{planId},#{containerBookingId},#{status},CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0) + ON DUPLICATE KEY UPDATE id=id """) int insertContainer(@Param("id") long id,@Param("planId") long planId, @Param("containerBookingId") long containerBookingId,@Param("status") String status); @@ -325,6 +343,8 @@ public interface ContainerCustomsMapper { SELECT DISTINCT i.id FROM fl_loading_plan_item item JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0 WHERE item.loading_plan_id=#{planId} AND item.deleted=0 AND i.tax_mode='TAX_REFUND' + AND + """ + ACTUAL_ITEM_SCOPE + """ ) target LEFT JOIN fl_customs_customer_document_inbound s ON s.inbound_id=target.id LEFT JOIN fl_customs_customer_document d ON d.id=s.document_id AND d.customs_container_id=#{containerId} AND d.status='ACTIVE' WHERE d.id IS NULL @@ -336,6 +356,8 @@ public interface ContainerCustomsMapper { FROM fl_loading_plan_item item JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0 WHERE item.loading_plan_id=#{planId} AND item.deleted=0 AND i.tax_mode='TAX_REFUND' + AND + """ + ACTUAL_ITEM_SCOPE + """ """) int taxRefundCount(@Param("planId") long planId); @@ -541,6 +563,8 @@ public interface ContainerCustomsMapper { LEFT JOIN fl_outbound_dispatch d ON d.inbound_id=item.inbound_id AND d.tenant_id=item.account_id AND d.container_booking_id=c.container_booking_id WHERE item.loading_plan_id=c.loading_plan_id AND item.deleted=0 + AND + """ + ACTUAL_ITEM_SCOPE + """ GROUP BY item.loading_plan_id HAVING COUNT(*)=SUM(CASE WHEN d.status='WAITING_FOR_LOADING' THEN 1 ELSE 0 END) ) ORDER BY c.completed_at,c.id LIMIT #{limit} """) @@ -557,6 +581,8 @@ public interface ContainerCustomsMapper { JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE' JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0 WHERE p.id=#{planId} AND item.deleted=0 AND i.tax_mode='BUY_DECLARATION' + AND + """ + ACTUAL_ITEM_SCOPE + """ AND item.id>#{cursor} AND item.id<=#{maximumId} ORDER BY item.id LIMIT #{limit} """) @@ -575,6 +601,8 @@ public interface ContainerCustomsMapper { JOIN fl_loading_plan p ON p.id=item.loading_plan_id AND p.plan_status='COMPLETED' JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0 WHERE p.id=#{planId} AND item.deleted=0 AND i.tax_mode='BUY_DECLARATION' + AND + """ + ACTUAL_ITEM_SCOPE + """ """) long maximumBuyItemId(@Param("planId") long planId); @@ -583,6 +611,8 @@ public interface ContainerCustomsMapper { JOIN fl_loading_plan p ON p.id=item.loading_plan_id AND p.plan_status='COMPLETED' JOIN fl_inbound_order i ON i.id=item.inbound_id AND i.tenant_id=item.account_id AND i.deleted=0 WHERE p.id=#{planId} AND item.deleted=0 AND i.tax_mode='BUY_DECLARATION' AND item.id<=#{maximumId} + AND + """ + ACTUAL_ITEM_SCOPE + """ """) long countBuyRows(@Param("planId") long planId,@Param("maximumId") long maximumId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java index b4ab07155..7882613ba 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java @@ -428,8 +428,11 @@ public class ContainerCustomsService { if (mapper.insertClearanceResult(resultId, batch.id(), batch.customsContainerId(), type, resultStatus, batch.customsEntryNo(), request.occurredAt(), trim(request.resolution()), request.evidenceFileId(), operatorId, operatorName, null) != 1) throw conflict("海关结果保存失败"); - if ("OPEN".equals(resultStatus)) mapper.markDeclarationResultPending(batch.id()); - else mapper.closeDeclarationBatch(batch.id()); + if ("OPEN".equals(resultStatus) || mapper.openClearanceResultCount(batch.id()) > 0) { + mapper.markDeclarationResultPending(batch.id()); + } else { + mapper.closeDeclarationBatch(batch.id()); + } ContainerRow container = mapper.lockContainerById(batch.customsContainerId()); if (container != null) history(container, "RECORD_CUSTOMS_CLEARANCE_RESULT", container.status(), container.status(), type, operatorId, operatorName); completeCommand(operatorId, action, key, resultId); @@ -560,7 +563,13 @@ public class ContainerCustomsService { if (!"COMPLETED".equals(plan.loadingPlanStatus())) throw conflict("装柜完成后才能处理报关资料"); boolean refund = rows.stream().anyMatch(row -> "TAX_REFUND".equals(row.taxMode())); mapper.insertContainer(IdWorker.getId(), planId, plan.containerBookingId(), refund ? WAITING : READY); - return refresh(planId); + // The no-op upsert already takes an exclusive lock on a duplicate, avoiding the + // shared-to-exclusive lock upgrade of INSERT IGNORE followed by FOR UPDATE. + // Earlier plan reads may retain an RR snapshot predating the winning insert, so + // retrieve the real aggregate with a current read, not a snapshot read. + ContainerRow container = mapper.lockContainer(planId); + if (container == null) throw conflict("整柜报关记录初始化失败"); + return refresh(planId, container); } private ContainerRow ensureMutable(long planId) { @@ -570,7 +579,10 @@ public class ContainerCustomsService { } private ContainerRow refresh(long planId) { - ContainerRow value = mapper.container(planId); + return refresh(planId, mapper.container(planId)); + } + + private ContainerRow refresh(long planId, ContainerRow value) { if (value == null || !Set.of(WAITING, READY).contains(value.status())) return value; String status = mapper.missingRefundCount(planId, value.id()) == 0 ? READY : WAITING; if (!status.equals(value.status())) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/CustomerTransportFragment.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/CustomerTransportFragment.java new file mode 100644 index 000000000..e5914c7a7 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/CustomerTransportFragment.java @@ -0,0 +1,17 @@ +package com.baiyue.firstleg.dto; + +import java.time.LocalDateTime; +import java.util.List; + +/** Customer-safe, read-only transport facts for one actually loaded inbound/container fragment. */ +public record CustomerTransportFragment(String inboundCode, String cabinetNo, String containerNo, + List nodes) { + public record Node(String standardNode, Integer nodeSequence, String providerCode, + LocalDateTime estimatedAt, LocalDateTime actualAt, + String locationCode, String locationName) {} + + public record Row(Long inboundId, Long containerBookingId, String inboundCode, String cabinetNo, + String containerNo, String standardNode, Integer nodeSequence, String providerCode, + LocalDateTime estimatedAt, LocalDateTime actualAt, + String locationCode, String locationName) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantWorkbenchFilters.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantWorkbenchFilters.java new file mode 100644 index 000000000..5866dc917 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/MerchantWorkbenchFilters.java @@ -0,0 +1,17 @@ +package com.baiyue.firstleg.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Pattern; +import java.util.List; + +/** Customer-scoped filter identifiers; never a replacement order list or cross-schema query. */ +public final class MerchantWorkbenchFilters { + private MerchantWorkbenchFilters() {} + + public record Request(@NotNull @Positive Long tenantId, + @Pattern(regexp = "WAIT_MEASURE|WAIT_PALLETIZE|WAIT_SPLIT|LOADING|LOADED|DELIVERED") String mainStatus) {} + + public record Response(List matchingStatusOrderIds, List feeOrderIds, + List customsOrderIds) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderFulfillmentCompleted.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderFulfillmentCompleted.java new file mode 100644 index 000000000..d9cb5cfef --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/OrderFulfillmentCompleted.java @@ -0,0 +1,11 @@ +package com.baiyue.firstleg.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import java.time.LocalDateTime; + +/** Order authoritative final state, delivered through its transactional Outbox. */ +public record OrderFulfillmentCompleted(@Positive long tenantId, @Positive long orderId, + @NotBlank String orderNo, @NotBlank String inboundCode, + @NotNull LocalDateTime completedAt) { } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundDetail.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundDetail.java index e50938cac..c306ff6dd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundDetail.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundDetail.java @@ -6,7 +6,8 @@ import java.util.List; /** Detail payload for the product-level order detail. */ public record UnifiedInboundDetail( UnifiedInboundSummary summary, - List timeline + List timeline, + List transportFragments ) { public record TimelineEvent(String source, String eventType, String fromStatus, String toStatus, String action, String remark, Long operatorId, String operatorName, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java index dfa3fdff9..ccea8652c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java @@ -35,7 +35,9 @@ public record UnifiedInboundQuery( LocalDateTime createdFrom, LocalDateTime createdTo, @Min(1) long page, - @Min(1) @Max(200) long size + @Min(1) @Max(200) long size, + @Schema(allowableValues = {"TAX_REFUND", "BUY_DECLARATION"}) + @jakarta.validation.constraints.Pattern(regexp = "TAX_REFUND|BUY_DECLARATION") String taxMode ) { public UnifiedInboundQuery { if (page == 0) page = 1; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java index 1901cacff..fd19f6105 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java @@ -1,6 +1,7 @@ package com.baiyue.firstleg.entity; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; import com.baomidou.mybatisplus.annotation.FieldFill; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableField; @@ -75,6 +76,9 @@ public class ContainerBookingEntity { @TableField(exist = false) private Long planId; @TableField(exist = false) private String planNo; @TableField(exist = false) private String planStatus; + /** Identity of the port schedule selected for the current transport read model. */ + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + @TableField(exist = false) private Long currentPortScheduleId; /** Actual completion timestamp projected from the associated loading plan. */ @JsonIgnore @TableField(exist = false) private LocalDateTime completedLoadingAt; @TableField(exist = false) private Boolean exceptionActive; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceService.java index c5673de44..d42c07b48 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceService.java @@ -84,13 +84,14 @@ public class CabinetLoadingEvidenceService { value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); try { mapper.insert(value); } catch (DuplicateKeyException exception) { - CabinetLoadingEvidenceEntity concurrent = mapper.byOperation(taskId, key); - if (concurrent != null && Objects.equals(concurrent.getRequestHash(), requestHash)) return view(scope); + // A replacement may already have voided the old row in this transaction. + // Roll it back; a subsequent same-key request can read the committed winner. throw conflict("装柜证据发生并发变更,请刷新后重试"); } return view(scope); } + @Transactional public EvidenceList upload(long taskId, long warehouseId, String key, int expectedVersion, String evidenceType, String sealNo, String remark, MultipartFile multipart, long operatorId, String operatorName) { @@ -167,7 +168,7 @@ public class CabinetLoadingEvidenceService { } private Scope scope(long taskId, long warehouseId, long operatorId, boolean mutable) { - LoadingPickTaskEntity task = loadingPicks.task(taskId); + LoadingPickTaskEntity task = mutable ? loadingPicks.taskForUpdate(taskId) : loadingPicks.task(taskId); var booking = task == null ? null : loadingPicks.activeBookingByTask(taskId); if (task == null || task.getContainerBookingId() == null || booking == null || !Objects.equals(task.getContainerBookingId(), booking.getId())) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java index 683c17012..807698ed8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/InboundSplitSettlementMapper.java @@ -250,14 +250,29 @@ public interface InboundSplitSettlementMapper { @Update(""" UPDATE fl_loading_plan_item plan_item JOIN fl_loading_pick_item pick_item ON pick_item.loading_plan_item_id = plan_item.id - SET plan_item.pallet_count = CEIL(COALESCE(plan_item.pallet_count,0) - * pick_item.picked_package_count / NULLIF(plan_item.package_count,0)), + SET plan_item.pallet_count = CEIL(CAST(COALESCE(plan_item.pallet_count,0) AS DECIMAL(30,6)) + * pick_item.picked_package_count / NULLIF(pick_item.planned_package_count,0)), + plan_item.product_quantity = CASE WHEN plan_item.units_per_carton IS NOT NULL + THEN plan_item.units_per_carton * pick_item.picked_package_count + ELSE ROUND(CAST(plan_item.product_quantity AS DECIMAL(30,6)) * pick_item.picked_package_count + / NULLIF(pick_item.planned_package_count,0),0) END, + plan_item.gross_weight_kg = ROUND(plan_item.gross_weight_kg * pick_item.picked_package_count + / NULLIF(pick_item.planned_package_count,0),3), + plan_item.net_weight_kg = ROUND(plan_item.net_weight_kg * pick_item.picked_package_count + / NULLIF(pick_item.planned_package_count,0),3), + plan_item.dimensional_weight_kg = ROUND(plan_item.dimensional_weight_kg * pick_item.picked_package_count + / NULLIF(pick_item.planned_package_count,0),3), + plan_item.volume_cbm = ROUND(plan_item.volume_cbm * pick_item.picked_package_count + / NULLIF(pick_item.planned_package_count,0),4), plan_item.package_count = pick_item.picked_package_count, plan_item.updated_by = #{operatorId}, plan_item.updated_by_name = #{operatorName}, plan_item.updated_at = CURRENT_TIMESTAMP(3), plan_item.version = plan_item.version + 1 WHERE plan_item.id = #{planItemId} AND plan_item.deleted = 0 + AND plan_item.unit_type = 'PACKAGE' AND plan_item.pallet_id IS NULL AND pick_item.item_status = 'PARTIAL' AND pick_item.picked_package_count > 0 + AND pick_item.picked_package_count < pick_item.planned_package_count + AND plan_item.package_count = pick_item.planned_package_count """) int convergePartialPlanItem(@Param("planItemId") long planItemId, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java index 073fad613..7cc76633b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -13,7 +13,7 @@ import java.util.List; /** 读取 PDA 结算后的最终实装入仓单和冻结货物快照。 */ @Mapper public interface LoadingCompletedEventMapper { - @Select(""" + String SNAPSHOT_ROWS = """ SELECT p.id AS loading_plan_id, p.plan_no, p.container_booking_id, p.version AS aggregate_version, b.container_no, b.cabinet_no, b.warehouse_id, b.warehouse_name, i.customer_name, @@ -29,7 +29,10 @@ public interface LoadingCompletedEventMapper { i.product_name_zh, i.product_name_en, i.package_count, i.product_quantity, i.pallet_count, i.gross_weight_kg, i.net_weight_kg, i.dimensional_weight_kg, i.volume_cbm, i.fba_tracking_no, i.shipment_no, i.shipping_mark - ,i.customer_remark, i.internal_remark, i.warehouse_remark + ,i.customer_remark, i.internal_remark, i.warehouse_remark, + i.units_per_carton, i.unit_type, i.pallet_id, + actual_pick.planned_package_count, actual_pick.picked_package_count, + actual_pick.item_status AS pick_status FROM fl_loading_plan p JOIN fl_container_booking b ON b.id = p.container_booking_id JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0 @@ -37,17 +40,33 @@ public interface LoadingCompletedEventMapper { ON actual_pick.loading_plan_item_id = i.id AND actual_pick.item_status IN ('PICKED','PARTIAL') AND actual_pick.picked_package_count > 0 - AND actual_pick.picked_package_count = i.package_count JOIN fl_loading_pick_task actual_task ON actual_task.id = actual_pick.pick_task_id AND actual_task.loading_plan_id = p.id JOIN fl_inbound_order o ON o.id = i.inbound_id AND o.tenant_id = i.account_id AND o.deleted = 0 LEFT JOIN fl_inbound_order root_order ON root_order.id = COALESCE(o.root_inbound_id, o.id) WHERE p.id = #{planId} AND i.package_count > 0 - ORDER BY i.account_id, i.inbound_id, i.inbound_detail_id, i.id - """) + """; + String SNAPSHOT_ORDER = " ORDER BY i.account_id, i.inbound_id, i.inbound_detail_id, i.id"; + + @Select(SNAPSHOT_ROWS + " AND actual_pick.picked_package_count = i.package_count" + SNAPSHOT_ORDER) List rows(@Param("planId") long planId); + /** 尚未结算的 PACKAGE/PARTIAL 也必须接受整柜预检;这里只读原始冻结行。 */ + @Select(SNAPSHOT_ROWS + SNAPSHOT_ORDER) + List previewRows(@Param("planId") long planId); + + @Select(""" + SELECT COUNT(*) FROM fl_loading_plan_item i + JOIN fl_loading_pick_item actual_pick ON actual_pick.loading_plan_item_id=i.id + JOIN fl_loading_pick_task actual_task ON actual_task.id=actual_pick.pick_task_id + AND actual_task.loading_plan_id=i.loading_plan_id + WHERE i.loading_plan_id=#{planId} AND i.deleted=0 + AND actual_pick.item_status IN ('PICKED','PARTIAL') AND actual_pick.picked_package_count>0 + AND actual_pick.picked_package_count<>i.package_count + """) + int unsettledActualItems(@Param("planId") long planId); + /** 读取头程托盘及箱唛快照;普通订单向尾程交接打印编号,一键代发同时冻结托盘库存。 */ @Select(""" SELECT i.account_id, i.inbound_id AS source_inbound_id, @@ -139,6 +158,8 @@ public interface LoadingCompletedEventMapper { private String fbaTrackingNo; private String shipmentNo; private String shippingMark; private String businessType; private String fulfillmentType; private String lxCode; private String destinationType; private String customerRemark; private String internalRemark; private String warehouseRemark; + private Integer unitsPerCarton; private String unitType; private Long palletId; + private Integer plannedPackageCount; private Integer pickedPackageCount; private String pickStatus; } @Getter @Setter diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java index 1ebb4d447..22a4f2fbc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingListQueryMapper.java @@ -19,6 +19,35 @@ import java.util.List; /** 基于共享装柜计划表提供清单查询投影和状态推进持久化操作。 */ @Mapper public interface LoadingListQueryMapper { + // Completed projections use the same settled physical scope as LoadingCompletedEventMapper. + // Keep skipped plan rows for history and for the unfinished preload projection. + String ITEM_SCOPE = """ + AND (p.plan_status != 'COMPLETED' OR EXISTS ( + SELECT 1 FROM fl_loading_pick_item actual_pick + JOIN fl_loading_pick_task actual_task ON actual_task.id=actual_pick.pick_task_id + WHERE actual_task.loading_plan_id=p.id AND actual_pick.loading_plan_item_id=i.id + AND actual_pick.item_status IN ('PICKED','PARTIAL') + AND actual_pick.picked_package_count > 0 + AND actual_pick.picked_package_count=i.package_count)) + """; + String PALLET_ITEM_SCOPE = """ + AND (p.plan_status != 'COMPLETED' OR EXISTS ( + SELECT 1 FROM fl_loading_pick_item actual_pick + JOIN fl_loading_pick_task actual_task ON actual_task.id=actual_pick.pick_task_id + WHERE actual_task.loading_plan_id=p.id AND actual_pick.loading_plan_item_id=pallet_item.id + AND actual_pick.item_status IN ('PICKED','PARTIAL') + AND actual_pick.picked_package_count > 0 + AND actual_pick.picked_package_count=pallet_item.package_count)) + """; + String CUSTOMER_ACTUAL_SCOPE = """ + AND EXISTS ( + SELECT 1 FROM fl_loading_pick_item actual_pick + JOIN fl_loading_pick_task actual_task ON actual_task.id=actual_pick.pick_task_id + WHERE actual_task.loading_plan_id=p.id AND actual_pick.loading_plan_item_id=customer_item.id + AND actual_pick.item_status IN ('PICKED','PARTIAL') + AND actual_pick.picked_package_count > 0 + AND actual_pick.picked_package_count=customer_item.package_count) + """; String AGGREGATE_COLUMNS = """ p.id AS plan_id, p.plan_no, p.container_booking_id, p.plan_status, p.remark, p.created_by, p.created_by_name, p.created_at, p.version, @@ -50,28 +79,36 @@ public interface LoadingListQueryMapper { FROM fl_dropship_pallet_carton carton WHERE EXISTS (SELECT 1 FROM fl_loading_plan_item pallet_item WHERE pallet_item.loading_plan_id=p.id AND pallet_item.deleted=0 - AND pallet_item.pallet_id=carton.pallet_id)), 0) AS total_package_count, + AND pallet_item.pallet_id=carton.pallet_id + """ + PALLET_ITEM_SCOPE + """ + )), 0) AS total_package_count, COALESCE(SUM(CASE WHEN i.pallet_id IS NULL THEN i.gross_weight_kg ELSE 0 END), 0) + COALESCE((SELECT SUM(inbound.actual_gross_weight_kg/NULLIF(inbound.total_package_count,0)) FROM fl_dropship_pallet_carton carton JOIN fl_inbound_order inbound ON inbound.id=carton.inbound_id AND inbound.tenant_id=carton.tenant_id WHERE EXISTS (SELECT 1 FROM fl_loading_plan_item pallet_item WHERE pallet_item.loading_plan_id=p.id AND pallet_item.deleted=0 - AND pallet_item.pallet_id=carton.pallet_id)), 0) AS total_gross_weight_kg, + AND pallet_item.pallet_id=carton.pallet_id + """ + PALLET_ITEM_SCOPE + """ + )), 0) AS total_gross_weight_kg, COALESCE(SUM(CASE WHEN i.pallet_id IS NULL THEN i.net_weight_kg ELSE 0 END), 0) + COALESCE((SELECT SUM(inbound.actual_net_weight_kg/NULLIF(inbound.total_package_count,0)) FROM fl_dropship_pallet_carton carton JOIN fl_inbound_order inbound ON inbound.id=carton.inbound_id AND inbound.tenant_id=carton.tenant_id WHERE EXISTS (SELECT 1 FROM fl_loading_plan_item pallet_item WHERE pallet_item.loading_plan_id=p.id AND pallet_item.deleted=0 - AND pallet_item.pallet_id=carton.pallet_id)), 0) AS total_net_weight_kg, + AND pallet_item.pallet_id=carton.pallet_id + """ + PALLET_ITEM_SCOPE + """ + )), 0) AS total_net_weight_kg, COALESCE(SUM(CASE WHEN i.pallet_id IS NULL THEN i.volume_cbm ELSE 0 END), 0) + COALESCE((SELECT SUM(inbound.actual_volume_cbm/NULLIF(inbound.total_package_count,0)) FROM fl_dropship_pallet_carton carton JOIN fl_inbound_order inbound ON inbound.id=carton.inbound_id AND inbound.tenant_id=carton.tenant_id WHERE EXISTS (SELECT 1 FROM fl_loading_plan_item pallet_item WHERE pallet_item.loading_plan_id=p.id AND pallet_item.deleted=0 - AND pallet_item.pallet_id=carton.pallet_id)), 0) AS total_volume_cbm, + AND pallet_item.pallet_id=carton.pallet_id + """ + PALLET_ITEM_SCOPE + """ + )), 0) AS total_volume_cbm, COALESCE(SUM(i.product_quantity), 0) AS total_quantity """; @@ -115,8 +152,11 @@ public interface LoadingListQueryMapper { FROM fl_loading_plan p JOIN fl_container_booking b ON b.id = p.container_booking_id LEFT JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0 + """ + ITEM_SCOPE + """ WHERE p.plan_status = 'COMPLETED' - AND EXISTS (SELECT 1 FROM fl_loading_plan_item customer_item WHERE customer_item.loading_plan_id = p.id AND customer_item.deleted = 0 AND customer_item.account_id = #{query.customerId}) + AND EXISTS (SELECT 1 FROM fl_loading_plan_item customer_item WHERE customer_item.loading_plan_id = p.id AND customer_item.deleted = 0 AND customer_item.account_id = #{query.customerId} + """ + CUSTOMER_ACTUAL_SCOPE + """ + ) AND (p.plan_no LIKE CONCAT('%', #{query.keyword}, '%') OR b.container_no LIKE CONCAT('%', #{query.keyword}, '%') @@ -138,6 +178,7 @@ public interface LoadingListQueryMapper { FROM fl_loading_plan p JOIN fl_container_booking b ON b.id = p.container_booking_id LEFT JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0 + """ + ITEM_SCOPE + """ WHERE p.id = #{planId} GROUP BY """ + AGGREGATE_GROUP) @@ -149,6 +190,7 @@ public interface LoadingListQueryMapper { FROM fl_loading_plan p JOIN fl_container_booking b ON b.id = p.container_booking_id LEFT JOIN fl_loading_plan_item i ON i.loading_plan_id = p.id AND i.deleted = 0 + """ + ITEM_SCOPE + """ WHERE p.container_booking_id = #{bookingId} GROUP BY """ + AGGREGATE_GROUP) @@ -224,6 +266,7 @@ public interface LoadingListQueryMapper { JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE' LEFT JOIN fl_inbound_order inbound ON inbound.id=i.inbound_id AND inbound.tenant_id=i.account_id WHERE i.loading_plan_id = #{planId} AND i.deleted = 0 + """ + ITEM_SCOPE + """ ORDER BY i.load_sequence, i.id """) List selectItems(@Param("planId") long planId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapper.java index 6b05deda6..883cd3b80 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPalletLabelMapper.java @@ -11,15 +11,27 @@ import java.util.List; @Mapper public interface LoadingPalletLabelMapper { + // Remainder slices inherit bound slots, not new weight records. Their slot/weight + // identity survives release or transfer; preserved empty slots are not pallets. @Select(""" SELECT p.id AS plan_id,p.plan_no,p.plan_status,b.container_no,b.cabinet_no,b.warehouse_id, i.id AS item_id,i.account_id,i.inbound_id,i.inbound_code,i.destination_warehouse_id, i.destination_warehouse_code,i.destination_warehouse_name, COALESCE(NULLIF(i.pallet_count,0), - CEIL(COALESCE(NULLIF(o.pallet_count,0), + CEIL(CAST(COALESCE(NULLIF(o.pallet_count,0), + NULLIF((SELECT COUNT(DISTINCT weight.id) + FROM fl_inbound_pallet_allocation allocation + JOIN fl_inbound_weight_result weight + ON weight.id=allocation.weight_result_id AND weight.tenant_id=allocation.tenant_id + AND weight.deleted=0 AND weight.recognition_status='CONFIRMED' + AND (weight.inbound_id=o.id OR weight.inbound_id=o.root_inbound_id) + WHERE allocation.tenant_id=i.account_id AND allocation.inbound_id=i.inbound_id + AND allocation.block_id>0 AND allocation.slot_no>0 AND allocation.reservation_id>0 + AND allocation.allocated_package_count>0 + AND allocation.allocation_status IN ('CONFIRMED','TRANSFERRED','RELEASED')),0), (SELECT COUNT(*) FROM fl_inbound_weight_result weight WHERE weight.tenant_id=i.account_id AND weight.inbound_id=i.inbound_id - AND weight.deleted=0 AND weight.recognition_status='CONFIRMED'),0) + AND weight.deleted=0 AND weight.recognition_status='CONFIRMED'),0) AS DECIMAL(30,6)) * i.package_count / NULLIF(o.total_package_count,0)),0) AS pallet_count, COALESCE(o.fulfillment_type,'STANDARD') AS fulfillment_type, o.destination_type,i.load_sequence @@ -33,6 +45,44 @@ public interface LoadingPalletLabelMapper { """) List lockSources(@Param("planId") long planId); + /** 预测结算后的来源范围和托数;不锁行、不生成或占用标签。 */ + @Select(""" + SELECT p.id AS plan_id,p.plan_no,p.plan_status,b.container_no,b.cabinet_no,b.warehouse_id, + i.id AS item_id,i.account_id,i.inbound_id,i.inbound_code,i.destination_warehouse_id, + i.destination_warehouse_code,i.destination_warehouse_name, + COALESCE(NULLIF(CASE WHEN pick.picked_package_count0 AND allocation.slot_no>0 AND allocation.reservation_id>0 + AND allocation.allocated_package_count>0 + AND allocation.allocation_status IN ('CONFIRMED','TRANSFERRED','RELEASED')),0), + (SELECT COUNT(*) FROM fl_inbound_weight_result weight + WHERE weight.tenant_id=i.account_id AND weight.inbound_id=i.inbound_id + AND weight.deleted=0 AND weight.recognition_status='CONFIRMED'),0) AS DECIMAL(30,6)) + * pick.picked_package_count / NULLIF(o.total_package_count,0)),0) AS pallet_count, + COALESCE(o.fulfillment_type,'STANDARD') AS fulfillment_type, + o.destination_type,i.load_sequence + FROM fl_loading_plan p + JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE' + JOIN fl_loading_plan_item i ON i.loading_plan_id=p.id AND i.deleted=0 + JOIN fl_loading_pick_item pick ON pick.loading_plan_item_id=i.id + AND pick.item_status IN ('PICKED','PARTIAL') AND pick.picked_package_count>0 + JOIN fl_loading_pick_task task ON task.id=pick.pick_task_id AND task.loading_plan_id=p.id + LEFT JOIN fl_inbound_order o ON o.id=i.inbound_id AND o.tenant_id=i.account_id AND o.deleted=0 + WHERE p.id=#{planId} + ORDER BY i.destination_warehouse_code,i.load_sequence,i.id + """) + List previewSources(@Param("planId") long planId); + @Select(""" SELECT id AS label_id,loading_plan_id AS plan_id,loading_plan_item_id AS item_id, account_id,inbound_id,inbound_code,container_no,destination_warehouse_code, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java index 5ce20ed4c..e29fbfcfe 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java @@ -165,6 +165,21 @@ public interface LoadingPickItemMapper extends BaseMapper """) java.util.List selectInboundOwners(@Param("taskId") long taskId); + /** Only physical cargo owns completion checkpoints; retained SKIPPED rows are plan history. */ + @Select(""" + SELECT plan_item.account_id tenantId,plan_item.inbound_id inboundId + FROM fl_loading_pick_item pick_item + JOIN fl_loading_pick_task task ON task.id=pick_item.pick_task_id + JOIN fl_loading_plan_item plan_item ON plan_item.id=pick_item.loading_plan_item_id + AND plan_item.loading_plan_id=task.loading_plan_id AND plan_item.deleted=0 + WHERE pick_item.pick_task_id=#{taskId} + AND pick_item.item_status IN ('PICKED','PARTIAL') + AND pick_item.picked_package_count>0 + GROUP BY plan_item.account_id,plan_item.inbound_id + ORDER BY plan_item.account_id,plan_item.inbound_id + """) + java.util.List selectCompletionOwners(@Param("taskId") long taskId); + record InboundOwner(Long tenantId, Long inboundId) {} @Select(""" diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java new file mode 100644 index 000000000..214c45ba0 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java @@ -0,0 +1,66 @@ +package com.baiyue.firstleg.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import java.util.List; + +@Mapper +public interface MerchantWorkbenchFilterMapper { + @Select(""" + + """) + List byProcessStatuses(@Param("tenantId") long tenantId, @Param("statuses") List statuses); + + @Select(""" + SELECT DISTINCT i.order_id + FROM fl_inbound_order i + JOIN fl_inbound_fee f ON f.tenant_id=i.tenant_id AND f.inbound_id=i.id + WHERE i.tenant_id=#{tenantId} AND i.deleted=0 AND i.order_id IS NOT NULL + AND f.fee_status='SENT' AND f.customer_confirmed_at IS NULL + AND NOT EXISTS (SELECT 1 FROM fl_inbound_fee newer + WHERE newer.tenant_id=f.tenant_id AND newer.inbound_id=f.inbound_id + AND (newer.fee_version>f.fee_version OR (newer.fee_version=f.fee_version AND newer.id>f.id))) + ORDER BY i.order_id + """) + List pendingFees(@Param("tenantId") long tenantId); + + @Select(""" + SELECT DISTINCT i.order_id + FROM fl_inbound_order i + JOIN fl_loading_plan p ON p.id=( + SELECT latest.id FROM fl_loading_plan latest + JOIN fl_loading_plan_item li ON li.loading_plan_id=latest.id AND li.deleted=0 + JOIN fl_inbound_order own ON own.id=li.inbound_id AND own.tenant_id=li.account_id AND own.deleted=0 + WHERE latest.plan_status='COMPLETED' AND own.tenant_id=i.tenant_id + AND own.order_id=i.order_id AND own.tax_mode='TAX_REFUND' + ORDER BY COALESCE(latest.confirmed_at,latest.updated_at) DESC,latest.id DESC LIMIT 1 + ) + JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE' + LEFT JOIN fl_customs_container c ON c.loading_plan_id=p.id + WHERE i.tenant_id=#{tenantId} AND i.deleted=0 AND i.tax_mode='TAX_REFUND' + AND i.process_status IN ('LOADED','DELIVERED') + AND (c.id IS NULL OR c.status='WAITING_CUSTOMER_DOCUMENTS') + AND EXISTS ( + SELECT 1 FROM fl_loading_plan_item item + JOIN fl_inbound_order member ON member.id=item.inbound_id + AND member.tenant_id=item.account_id AND member.deleted=0 + WHERE item.loading_plan_id=p.id AND item.deleted=0 + AND member.tenant_id=i.tenant_id AND member.tax_mode='TAX_REFUND' + AND NOT EXISTS ( + SELECT 1 FROM fl_customs_customer_document_inbound scope + JOIN fl_customs_customer_document d ON d.id=scope.document_id + AND d.customs_container_id=c.id AND d.tenant_id=i.tenant_id AND d.status='ACTIVE' + WHERE scope.inbound_id=member.id AND scope.tenant_id=i.tenant_id + ) + ) + ORDER BY i.order_id + """) + List pendingCustoms(@Param("tenantId") long tenantId); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/UnifiedInboundTransportMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/UnifiedInboundTransportMapper.java index c90eb6763..e7b4c7583 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/UnifiedInboundTransportMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/UnifiedInboundTransportMapper.java @@ -10,6 +10,29 @@ import java.util.List; /** 统一订单按当前有效装柜明细批量读取柜次船期。 */ @Mapper public interface UnifiedInboundTransportMapper { + @Select(""" + SELECT owned.inbound_id, owned.container_booking_id, owned.inbound_code, + b.cabinet_no, b.container_no, n.standard_node, n.node_sequence, n.provider_code, + n.estimated_at, n.actual_at, n.location_code, n.location_name + FROM ( + SELECT DISTINCT o.id AS inbound_id, p.container_booking_id, o.inbound_code + FROM fl_inbound_order o + JOIN fl_loading_plan_item i ON i.inbound_id=o.id AND i.account_id=o.tenant_id + JOIN fl_loading_plan p ON p.id=i.loading_plan_id AND p.plan_status='COMPLETED' + JOIN fl_loading_pick_task t ON t.loading_plan_id=p.id AND t.task_status='COMPLETED' + JOIN fl_loading_pick_item picked ON picked.pick_task_id=t.id + AND picked.loading_plan_item_id=i.id + WHERE o.tenant_id=#{tenantId} AND o.order_id=#{orderId} AND o.deleted=0 + AND i.deleted=0 AND picked.item_status IN ('PICKED','PARTIAL') + AND picked.picked_package_count>0 + ) owned + JOIN fl_container_booking b ON b.id=owned.container_booking_id + LEFT JOIN fl_carrier_schedule_node_fact n ON n.container_booking_id=owned.container_booking_id + ORDER BY owned.inbound_id, owned.container_booking_id, n.node_sequence, n.id + """) + List findCustomerTransportNodes( + @Param("tenantId") long tenantId, @Param("orderId") long orderId); + @Select(""" ") + List available(@Param("customerId") String customerId, @Param("orderNo") String orderNo, + @Param("fba") String fba, @Param("draftToken") String draftToken); + + @Select("") + List lockSelected(@Param("customerId") String customerId, @Param("ids") List ids); + + @Select("") + List customers(@Param("ids") List ids); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java index 1fb5f6e9c..9b57005ec 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java @@ -3,42 +3,87 @@ package com.baiyue.lastleg.dispatch.model; import java.math.BigDecimal; import java.time.LocalDateTime; -public record DispatchPalletCandidate( - Long planId, - Long planPalletId, - Long warehousePalletId, - Long orderId, - String customerId, - String customerName, - String planNo, - String orderType, - BigDecimal volumeWeightKg, - BigDecimal grossWeightKg, - String containerNo, - LocalDateTime appointmentAt, - LocalDateTime expectedLoadingAt, - String overseasWarehouseId, - String overseasWarehouseCode, - String overseasWarehouseName, - String orderNo, - String orderRefs, - String inboundCode, - String palletNo, - String destinationWarehouseId, - String destinationWarehouseCode, - String destinationWarehouseName, - String fbaWarehouseCode, - Integer packageCount, - BigDecimal volumeCbm, - String sourceStage, - String cartonMarks, - String relabelCartonMarks, - String destinationType, - String deliveryAddress, - String postalCode, - String contactName, - String contactPhone -) { +@lombok.Data +@lombok.NoArgsConstructor +@lombok.experimental.Accessors(fluent = true) +@com.fasterxml.jackson.annotation.JsonAutoDetect(fieldVisibility = com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility.ANY) +public class DispatchPalletCandidate { + private Long planId; + private Long planPalletId; + private Long warehousePalletId; + private Long orderId; + private String customerId; + private String customerName; + private String planNo; + private String orderType; + private BigDecimal volumeWeightKg; + private BigDecimal grossWeightKg; + private String containerNo; + private LocalDateTime appointmentAt; + private LocalDateTime expectedLoadingAt; + private String overseasWarehouseId; + private String overseasWarehouseCode; + private String overseasWarehouseName; + private String orderNo; + private String orderRefs; + private String inboundCode; + private String palletNo; + private String destinationWarehouseId; + private String destinationWarehouseCode; + private String destinationWarehouseName; + private String fbaWarehouseCode; + private Integer packageCount; + private BigDecimal volumeCbm; + private String sourceStage; + private String cartonMarks; + private String relabelCartonMarks; + private String destinationType; + private String deliveryAddress; + private String postalCode; + private String contactName; + private String contactPhone; + private Long returnReceiptId; + private String returnReceiptNo; + @com.fasterxml.jackson.annotation.JsonProperty("sourceType") + public String sourceType() { return returnReceiptId == null ? "INBOUND_PLAN" : "RETURN_RECEIPT"; } + + public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId, String customerId, String customerName, String planNo, String orderType, BigDecimal volumeWeightKg, BigDecimal grossWeightKg, String containerNo, LocalDateTime appointmentAt, LocalDateTime expectedLoadingAt, String overseasWarehouseId, String overseasWarehouseCode, String overseasWarehouseName, String orderNo, String orderRefs, String inboundCode, String palletNo, String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String fbaWarehouseCode, Integer packageCount, BigDecimal volumeCbm, String sourceStage, String cartonMarks, String relabelCartonMarks, String destinationType, String deliveryAddress, String postalCode, String contactName, String contactPhone) { + this.planId = planId; + this.planPalletId = planPalletId; + this.warehousePalletId = warehousePalletId; + this.orderId = orderId; + this.customerId = customerId; + this.customerName = customerName; + this.planNo = planNo; + this.orderType = orderType; + this.volumeWeightKg = volumeWeightKg; + this.grossWeightKg = grossWeightKg; + this.containerNo = containerNo; + this.appointmentAt = appointmentAt; + this.expectedLoadingAt = expectedLoadingAt; + this.overseasWarehouseId = overseasWarehouseId; + this.overseasWarehouseCode = overseasWarehouseCode; + this.overseasWarehouseName = overseasWarehouseName; + this.orderNo = orderNo; + this.orderRefs = orderRefs; + this.inboundCode = inboundCode; + this.palletNo = palletNo; + this.destinationWarehouseId = destinationWarehouseId; + this.destinationWarehouseCode = destinationWarehouseCode; + this.destinationWarehouseName = destinationWarehouseName; + this.fbaWarehouseCode = fbaWarehouseCode; + this.packageCount = packageCount; + this.volumeCbm = volumeCbm; + this.sourceStage = sourceStage; + this.cartonMarks = cartonMarks; + this.relabelCartonMarks = relabelCartonMarks; + this.destinationType = destinationType; + this.deliveryAddress = deliveryAddress; + this.postalCode = postalCode; + this.contactName = contactName; + this.contactPhone = contactPhone; + } + public DispatchPalletCandidate(Long planId, Long planPalletId, Long warehousePalletId, Long orderId, String overseasWarehouseId, String overseasWarehouseName, String orderNo, String orderRefs, String inboundCode, String palletNo, String destinationWarehouseId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java index bc05e0a53..397f162e3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java @@ -5,6 +5,8 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests; import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses; import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService; +import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService; +import com.baiyue.lastleg.fleet.trip.FleetExecutionModels; import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; import jakarta.annotation.Resource; import jakarta.validation.Valid; @@ -19,19 +21,24 @@ import java.util.List; @PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") public class DispatchPdaController { @Resource private DispatchPdaWorkflowService service; + @Resource private FleetTripExecutionService trips; @GetMapping public ApiResponse> list(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String status) { PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); - return ApiResponse.success(service.list(String.valueOf(identity.overseasWarehouseId()), status)); + String warehouse = String.valueOf(identity.overseasWarehouseId()); + var tasks = service.list(warehouse, status); + var gates = trips.gates(warehouse, tasks.stream().map(DispatchPdaResponses.Task::dispatchNo).toList()); + return ApiResponse.success(tasks.stream().map(task -> task.withGate(gates.get(task.dispatchNo()))).toList()); } @GetMapping("/{dispatchNo}") public ApiResponse task(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo) { PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); - return ApiResponse.success(service.task(String.valueOf(identity.overseasWarehouseId()), dispatchNo)); + String warehouse = String.valueOf(identity.overseasWarehouseId()); + return ApiResponse.success(service.task(warehouse, dispatchNo).withGate(trips.gate(warehouse, dispatchNo))); } @PostMapping("/{dispatchNo}/items/{itemId}/pick") @@ -54,11 +61,11 @@ public class DispatchPdaController { } @PostMapping("/{dispatchNo}/depart") - public ApiResponse depart(@AuthenticationPrincipal AuthenticatedUser user, + public ApiResponse depart(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo, - @Valid @RequestBody DispatchPdaRequests.Depart request) { + @Valid @RequestBody FleetExecutionModels.Depart request) { PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); - return ApiResponse.success(service.depart(String.valueOf(identity.overseasWarehouseId()), dispatchNo, + return ApiResponse.success(trips.depart(String.valueOf(identity.overseasWarehouseId()), dispatchNo, identity.operatorKey(), request)); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java index ec1d7cd4b..3a0c1aa9e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java @@ -1,11 +1,18 @@ package com.baiyue.lastleg.dispatch.pda.dto; import java.util.List; +import com.baiyue.lastleg.fleet.trip.FleetExecutionModels; public final class DispatchPdaResponses { private DispatchPdaResponses() {} public record Task(Long id, String taskNo, String dispatchNo, String vehiclePlate, String warehouseId, String status, - boolean canDepart, String departBlockReason, List items) {} + boolean canDepart, String departBlockReason, List items, String tripNo, Long tripRecordVersion, int tripDispatchCount, String driverWarning, + boolean canPick, String pickBlockReason) { + public Task withGate(FleetExecutionModels.Gate gate) { + return new Task(id, taskNo, dispatchNo, vehiclePlate, warehouseId, status, gate.canDepart(), gate.blockReason(), items, + gate.tripNo(), gate.recordVersion(), gate.dispatchCount(), gate.driverWarning(), gate.canPick(), gate.pickBlockReason()); + } + } public record Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode, String destinationWarehouseName, String fbaWarehouseCode, String warehouseRouteLabel, int scheduledPackages, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java index 4f87b9f53..c59eb46d5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java @@ -15,6 +15,7 @@ public class DispatchPdaPickOrder { private String customerId; private Long pickId; private Long receiptId; + private Long returnReceiptId; private Long orderId; private Long packages; private Long productQuantity; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java index 14571382b..26b78cd40 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java @@ -11,6 +11,26 @@ import java.util.List; @Mapper public interface DispatchPdaPickOrderMapper extends BaseMapper { + /** Current dispatch pick quantities; mixed/unknown SKU identities remain unknown. */ + @Select(""" + SELECT p.dispatch_item_id AS dispatchItemId,a.order_id AS orderId, + CASE WHEN a.receipt_id IS NOT NULL THEN i.sku_code ELSE b.sku_code END AS skuCode, + CASE WHEN a.receipt_id IS NOT NULL THEN i.product_name_zh ELSE b.product_name_zh END AS productName, + COALESCE(a.packages,p.packages) AS packages + FROM ll_dispatch_pda_task t + JOIN ll_dispatch_pda_pick p ON p.task_id=t.id AND BINARY p.customer_id=BINARY t.customer_id + LEFT JOIN ll_dispatch_pda_pick_order a ON a.pick_id=p.id AND BINARY a.customer_id=BINARY p.customer_id + LEFT JOIN ll_inbound_stock_receipt i ON i.id=a.receipt_id AND BINARY i.customer_id=BINARY a.customer_id + LEFT JOIN ll_inventory_position ip ON ip.id=p.inventory_position_id AND BINARY ip.customer_id=BINARY p.customer_id + LEFT JOIN ll_inventory_balance b ON b.id=ip.balance_id AND BINARY b.customer_id=BINARY ip.customer_id + WHERE BINARY t.customer_id=BINARY #{customerId} AND t.dispatch_order_id=#{dispatchId} + ORDER BY p.id,a.id + """) + List deliveryPickedSkus(@Param("customerId") String customerId, + @Param("dispatchId") Long dispatchId); + + record DeliveryPickedSku(Long dispatchItemId, Long orderId, String skuCode, String productName, Long packages) {} + @Select("SELECT DISTINCT r.* FROM ll_inventory_position current_position " + "JOIN ll_inventory_position receipt_position ON receipt_position.balance_id=current_position.balance_id " + "JOIN ll_inbound_stock_receipt r ON r.inventory_position_id=receipt_position.id " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java index 9d9f22994..1c64a6bda 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java @@ -1,5 +1,7 @@ package com.baiyue.lastleg.dispatch.pda.service; +import com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard; + import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.lastleg.delivery.entity.OutboundOrder; import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; @@ -36,6 +38,7 @@ import java.util.stream.Collectors; @Service public class DispatchPdaWorkflowService { + @Resource private FleetDispatchBindingGuard fleetBindings; @Resource private DispatchOrderMapper orders; @Resource private OutboundOrderMapper outboundOrders; @Resource private OutboundPalletItemMapper outboundPalletItems; @@ -78,12 +81,15 @@ public class DispatchPdaWorkflowService { return task(customerId, order, task, items.findByDispatchOrder(customerId, order.getId())); } - @Transactional + @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) public DispatchPdaResponses.Picked pick(String warehouseId, String dispatchNo, long itemId, String operatorId, String operatorName, DispatchPdaRequests.Pick request) { - DispatchOrder order = requireWarehouseOrder(warehouseId, dispatchNo); - String customerId = order.getCustomerId(); + DispatchOrder found = requireWarehouseOrder(warehouseId, dispatchNo); + String customerId = found.getCustomerId(); + // Transfer and picking serialize on the same dispatch before taking task/inventory locks. + DispatchOrder order = orders.lockById(customerId, found.getId()); + if (order == null) throw notFound("Dispatch order not found"); DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId()); if (task == null) throw conflict("Dispatch PDA task is not available for picking"); if (!warehouseId.equals(task.getWarehouseId())) throw conflict("PDA pick warehouse does not match task warehouse"); @@ -97,6 +103,7 @@ public class DispatchPdaWorkflowService { } requirePdaStatus(order); requireVehicle(order); + fleetBindings.requirePickingApproved(order.getId()); items.bindReceivedPallets(customerId, order.getId()); if (!("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) { throw conflict("Dispatch PDA task is not available for picking"); @@ -114,6 +121,8 @@ public class DispatchPdaWorkflowService { } long picked = picks.totalItem(customerId, task.getId(), itemId); if (picked + request.packages() > item.getPackageCount()) throw conflict("Picked packages exceed dispatch pallet packages"); + pickAttribution.preparePicking(customerId, request.positionId(), + "RELABEL_ORDER".equals(item.getSourceBusinessType()) ? item.getOrderId() : null); String keyBase = "DISPATCH:" + order.getDispatchNo() + ":" + itemId + ":" + request.idempotencyKey(); InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(request.positionId(), request.packages(), order.getDispatchNo(), order.getDispatchNo(), null, @@ -138,7 +147,7 @@ public class DispatchPdaWorkflowService { pick.setProductQuantity(request.productQuantity()); pick.setIdempotencyKey(request.idempotencyKey()); pick.setPickedAt(LocalDateTime.now()); pick.setCreatedAt(pick.getPickedAt()); picks.insert(pick); pickAttribution.attribute(customerId, pick, - "RELABEL_ORDER".equals(item.getSourceBusinessType()) ? item.getOrderId() : null); + "RELABEL_ORDER".equals(item.getSourceBusinessType()) ? item.getOrderId() : null, item.getReturnReceiptId()); long total = picked + request.packages(); order.setPickedPackageCount((int) (n(order.getPickedPackageCount()) + request.packages())); task.setStatus("PICKING"); task.setUpdatedAt(LocalDateTime.now()); @@ -164,6 +173,7 @@ public class DispatchPdaWorkflowService { if (replay != null) return read(replay, DispatchPdaResponses.PickingCompleted.class).withReplay(); requireVehicle(order); DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId()); + fleetBindings.requirePickingApproved(order.getId()); if (task != null && "COMPLETED".equals(task.getStatus())) { List completedItems = items.findByDispatchOrder(customerId, order.getId()); long planned = completedItems.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull) @@ -199,15 +209,16 @@ public class DispatchPdaWorkflowService { } /** Final warehouse exit: only fully PDA-picked dispatches can consume picked inventory. */ - @Transactional + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) public DispatchPdaResponses.Departed depart(String warehouseId, String dispatchNo, String operatorId, - DispatchPdaRequests.Depart request) { + DispatchPdaRequests.Depart request, LocalDateTime departedAt) { DispatchOrder found = requireWarehouseOrder(warehouseId, dispatchNo); String customerId = found.getCustomerId(); DispatchOrder order = orders.lockById(customerId, found.getId()); if (order == null) throw notFound("Dispatch order not found"); DispatchPdaOperation replay = replay(customerId, "DEPART", request.idempotencyKey(), request); if (replay != null) return read(replay, DispatchPdaResponses.Departed.class).withReplay(); + fleetBindings.requireCoordinatedDeparture(order.getId()); requireVehicle(order); OutboundOrder outbound = outboundOrders.lockByDispatch(customerId, order.getId()); if (outbound == null || !"WAIT_OUTBOUND".equals(outbound.getStatus())) { @@ -227,7 +238,7 @@ public class DispatchPdaWorkflowService { List dispatchPicks = picks.listTask(customerId, task.getId()); if (dispatchPicks.isEmpty()) throw conflict("Dispatch has no PDA picks to ship"); long shipped = dispatchPicks.stream().mapToLong(p -> n(p.getPackages())).sum(); - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = Objects.requireNonNull(departedAt, "departedAt"); pickAttribution.depart(customerId, outbound.getOutboundNo(), order.getDispatchNo(), dispatchPicks, now); if (outboundPalletItems != null) { for (var outboundItem : outboundPalletItems.findByOutbound(customerId, outbound.getId())) { @@ -287,7 +298,8 @@ public class DispatchPdaWorkflowService { OutboundOrder outbound = outboundOrders.findByDispatch(customerId, order.getId()); String departBlockReason = departBlockReason(order, task, source, outbound); return new DispatchPdaResponses.Task(task.getId(), task.getTaskNo(), order.getDispatchNo(), order.getVehiclePlate(), - task.getWarehouseId(), task.getStatus(), departBlockReason == null, departBlockReason, values); + task.getWarehouseId(), "DEPARTED".equals(order.getStatus()) ? "DEPARTED" : task.getStatus(), + departBlockReason == null, departBlockReason, values, null, null, 0, null, false, "请先关联车次并通过调度审核"); } private String departBlockReason(DispatchOrder order, DispatchPdaTask task, List source, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java index baae29fb7..3c1bcc9e5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java @@ -15,7 +15,7 @@ import org.springframework.stereotype.Service; import org.springframework.web.server.ResponseStatusException; import java.time.LocalDateTime; -import java.util.LinkedHashMap; +import java.util.TreeMap; import java.util.List; import java.util.Map; @@ -24,6 +24,31 @@ public class DispatchPickOrderAttributionService { @Resource private DispatchPdaPickOrderMapper attributions; @Resource private LastlegOrderMapper orders; @Resource private LastlegOrderStatusHistoryMapper history; + @Resource private com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper returnReceipts; + + /** Acquire source order locks before any inventory lock, matching order hold/release. */ + @org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public void preparePicking(String customerId, long positionId, Long fallbackOrderId) { + var sourceIds = new java.util.TreeSet(); + attributions.availableReceipts(customerId, positionId).forEach(receipt -> sourceIds.add(receipt.getOrderId())); + returnReceipts.available(customerId, positionId).forEach(receipt -> sourceIds.add(receipt.getOrderId())); + if (fallbackOrderId != null && fallbackOrderId > 0) sourceIds.add(fallbackOrderId); + if (sourceIds.isEmpty()) throw conflict("PDA pick has no attributable source order"); + for (Long orderId : sourceIds) { + LastlegOrder order = orders.lockById(customerId, orderId); + if (order == null || Boolean.TRUE.equals(order.getDetained())) { + throw conflict("PDA pick source order is missing or held"); + } + } + if (attributions.availableReceipts(customerId, positionId).stream() + .anyMatch(receipt -> !sourceIds.contains(receipt.getOrderId()))) { + throw conflict("PDA pick source orders changed; reload the task"); + } + for (var receipt : returnReceipts.available(customerId, positionId)) { + if (!sourceIds.contains(receipt.getOrderId())) throw conflict("Returned source orders changed; reload the task"); + if (returnReceipts.lock(customerId, receipt.getId()) == null) throw conflict("Returned stock receipt disappeared"); + } + } /** Call after the inventory position has been locked/moved and the PDA pick row has been inserted. */ public List attribute(String customerId, DispatchPdaPick pick) { @@ -36,14 +61,18 @@ public class DispatchPickOrderAttributionService { * authoritative tail-leg order, so use that order only for relabel picks. */ public List attribute(String customerId, DispatchPdaPick pick, Long fallbackOrderId) { + return attribute(customerId, pick, fallbackOrderId, null); + } + public List attribute(String customerId, DispatchPdaPick pick, Long fallbackOrderId, Long selectedReturnReceiptId) { long remainingPackages = value(pick.getPackages()); long totalPackages = remainingPackages; long totalQuantity = value(pick.getProductQuantity()); long allocatedPackages = 0; long allocatedQuantity = 0; - Map pickedByOrder = new LinkedHashMap<>(); - for (InboundStockReceipt receipt : attributions.availableReceipts(customerId, - pick.getInventoryPositionId())) { + Map pickedByOrder = new TreeMap<>(); + List pendingAttributions = new java.util.ArrayList<>(); + for (InboundStockReceipt receipt : selectedReturnReceiptId == null ? attributions.availableReceipts(customerId, + pick.getInventoryPositionId()) : List.of()) { if (remainingPackages == 0) break; long receiptPackages = value(receipt.getReceivedPackages()) - attributions.attributedPackages(customerId, receipt.getId()); @@ -61,17 +90,36 @@ public class DispatchPickOrderAttributionService { row.setPackages(packages); row.setProductQuantity(quantity); row.setCreatedAt(pick.getPickedAt() == null ? LocalDateTime.now() : pick.getPickedAt()); - attributions.insert(row); + pendingAttributions.add(row); pickedByOrder.merge(receipt.getOrderId(), packages, Long::sum); remainingPackages -= packages; allocatedPackages = nextPackages; allocatedQuantity = nextQuantity; } - if (remainingPackages != 0 && fallbackOrderId != null && fallbackOrderId > 0) { + for (var receipt : returnReceipts.available(customerId, pick.getInventoryPositionId())) { + if (remainingPackages == 0) break; + if (selectedReturnReceiptId != null && !selectedReturnReceiptId.equals(receipt.getId())) continue; + var locked = returnReceipts.lock(customerId, receipt.getId()); + if (locked == null) throw conflict("Returned stock receipt disappeared"); + long available = locked.getReceivedPackages() - returnReceipts.picked(customerId, locked.getId()); + if (available <= 0) continue; + long packages = Math.min(remainingPackages, available); + long nextPackages = allocatedPackages + packages; + long nextQuantity = totalPackages == 0 ? 0 : Math.floorDiv(totalQuantity * nextPackages, totalPackages); + DispatchPdaPickOrder row = new DispatchPdaPickOrder(); + row.setId(IdWorker.getId()); row.setCustomerId(customerId); row.setPickId(pick.getId()); + row.setReturnReceiptId(locked.getId()); row.setOrderId(locked.getOrderId()); row.setPackages(packages); + row.setProductQuantity(nextQuantity - allocatedQuantity); + row.setCreatedAt(pick.getPickedAt() == null ? LocalDateTime.now() : pick.getPickedAt()); + pendingAttributions.add(row); pickedByOrder.merge(locked.getOrderId(), packages, Long::sum); + remainingPackages -= packages; allocatedPackages = nextPackages; allocatedQuantity = nextQuantity; + } + if (remainingPackages != 0 && selectedReturnReceiptId == null && fallbackOrderId != null && fallbackOrderId > 0) { pickedByOrder.merge(fallbackOrderId, remainingPackages, Long::sum); remainingPackages = 0; } if (remainingPackages != 0) throw conflict("PDA pick cannot be attributed to inbound receipt orders"); + pendingAttributions.forEach(attributions::insert); for (Map.Entry entry : pickedByOrder.entrySet()) { if (orders.increasePicked(customerId, entry.getKey(), entry.getValue()) != 1) { throw conflict("Picked packages exceed tail-leg order remaining packages"); @@ -83,7 +131,7 @@ public class DispatchPickOrderAttributionService { /** Call once for each departing PDA pick; repeated dispatch departure is protected by its outer idempotency key. */ public List depart(String customerId, String outboundNo, String dispatchNo, List picks, LocalDateTime departedAt) { - Map shippedByOrder = new LinkedHashMap<>(); + Map shippedByOrder = new TreeMap<>(); for (DispatchPdaPick pick : picks) { for (DispatchPdaPickOrder row : attributions.listPick(customerId, pick.getId())) { shippedByOrder.merge(row.getOrderId(), value(row.getPackages()), Long::sum); @@ -98,7 +146,7 @@ public class DispatchPickOrderAttributionService { throw conflict("Tail-leg outbound sub-order status could not be synchronized"); } long shipped = value(before.getShippedPackageCount()) + entry.getValue(); - String to = shipped == value(before.getPackageCount()) ? "COMPLETED" : "PARTIALLY_SHIPPED"; + String to = shipped == value(before.getPackageCount()) + orders.returnedPackages(customerId, entry.getKey()) ? "COMPLETED" : "PARTIALLY_SHIPPED"; LastlegOrderStatusHistory row = new LastlegOrderStatusHistory(); row.setCustomerId(customerId); row.setOrderId(entry.getKey()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index 8336fa19c..62a86b738 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -26,6 +26,7 @@ import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard; import com.baiyue.lastleg.relabel.entity.RelabelApplication; import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; import com.baiyue.lastleg.relabel.service.RelabelApplicationService; @@ -61,10 +62,19 @@ public class DispatchService { @Resource private DispatchDraftMapper drafts; @Resource private DispatchDraftContainerMapper draftContainers; @Resource private DispatchCandidateMapper candidates; + @Resource private com.baiyue.lastleg.dispatch.mapper.ReturnDispatchCandidateMapper returnCandidates; + + public String resolveCustomerScopeForSources(List planPalletIds, List returnReceiptIds) { + var scopes = new java.util.ArrayList(); + if (planPalletIds != null && !planPalletIds.isEmpty()) scopes.addAll(candidates.findCustomerScopesByPlanPalletIds(planPalletIds)); + if (returnReceiptIds != null && !returnReceiptIds.isEmpty()) scopes.addAll(returnCandidates.customers(returnReceiptIds)); + return requireSingleCustomerScope(scopes.stream().distinct().toList(), "Selected dispatch sources"); + } @Resource private DispatchPdaTaskMapper pdaTasks; @Resource private LastlegOrderNoGenerator orderNoGenerator; @Resource private LastlegOrderMapper tailOrders; @Resource private OutboundOrderMapper outboundOrders; + @Resource private FleetDispatchBindingGuard fleetBindings; @Resource private RelabelApplicationMapper relabelApplications; @Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard relabelBindingGuard; @@ -118,8 +128,11 @@ public class DispatchService { .toUpperCase(java.util.Locale.ROOT).contains(fbaWarehouseCode.trim().toUpperCase(java.util.Locale.ROOT)))).toList(); } } + if (planIdsForReturns(sourceStage)) result = java.util.stream.Stream.concat(result.stream(), + returnCandidates.available(null, trimToNull(orderNo), trimToNull(fbaWarehouseCode), trimToNull(draftToken)).stream()).toList(); return result; } + private boolean planIdsForReturns(String sourceStage) { return sourceStage == null || sourceStage.isBlank() || "RECEIVED".equals(sourceStage); } public List platformCandidates(String orderNo, String fbaWarehouseCode, String sourceStage) { return platformCandidates(orderNo, fbaWarehouseCode, sourceStage, null); } @@ -132,7 +145,7 @@ public class DispatchService { private List containers(String token) { return draftContainers.findByToken(token).stream().map(c -> new DispatchContainerResponse(String.valueOf(c.getId()), c.getCapacity(), locks.findActiveByDraftContainer(token, c.getId()).stream() - .map(x -> String.valueOf(x.getPlanPalletId())).toList())).toList(); + .map(x -> x.getReturnReceiptId() == null ? String.valueOf(x.getPlanPalletId()) : "RETURN:" + x.getReturnReceiptId()).toList())).toList(); } @Transactional @@ -175,7 +188,7 @@ public class DispatchService { boundContainer = new DispatchDraftContainer(); boundContainer.setId(IdWorker.getId()); boundContainer.setDraftId(draft.getId()); boundContainer.setDraftToken(request.draftToken()); boundContainer.setSequenceNo(1); boundContainer.setCapacity(500); boundContainer.setCreatedAt(LocalDateTime.now()); boundContainer.setUpdatedAt(LocalDateTime.now()); draftContainers.insert(boundContainer); } if (boundContainer == null || !request.draftToken().equals(boundContainer.getDraftToken()) || !Objects.equals(boundContainer.getDraftId(), draft.getId())) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "装车托盘不存在"); - DispatchPalletCandidate candidate = requireDraftCandidate(customerId, request.planPalletId()); + DispatchPalletCandidate candidate = requireDraftSource(customerId, request); LocalDateTime now = LocalDateTime.now(); DispatchPalletLock active = activeLock(customerId, candidate); if (active != null && active.getExpiresAt() != null && !active.getExpiresAt().isAfter(now)) { @@ -197,6 +210,7 @@ public class DispatchService { lock.setActiveKey(candidate.sourcePalletKey()); lock.setPlanId(candidate.planId()); lock.setPlanPalletId(candidate.planPalletId()); + lock.setReturnReceiptId(candidate.returnReceiptId()); lock.setWarehousePalletId(candidate.warehousePalletId()); lock.setDraftToken(request.draftToken()); lock.setDraftContainerId(request.draftContainerId()); @@ -216,7 +230,7 @@ public class DispatchService { @Transactional public void unlockDraftPallet(String customerId, String operatorId, DispatchRequests.DraftPallet request) { - DispatchPalletCandidate candidate = requireDraftCandidate(customerId, request.planPalletId()); + DispatchPalletCandidate candidate = requireDraftSource(customerId, request); DispatchPalletLock active = activeLock(customerId, candidate); if (active == null) return; requireDraftOwner(active, operatorId, request.draftToken()); @@ -224,7 +238,9 @@ public class DispatchService { DispatchOrder dispatch = orders.lockById(customerId, active.getDispatchOrderId()); if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); requirePalletEditingAllowed(dispatch); - DispatchItem item = items.lockByPlanPallet(customerId, dispatch.getId(), request.planPalletId()); + DispatchItem item = request.returnReceiptId() == null ? items.lockByPlanPallet(customerId, dispatch.getId(), request.planPalletId()) + : items.selectOne(new LambdaQueryWrapper().eq(DispatchItem::getCustomerId, customerId) + .eq(DispatchItem::getDispatchOrderId, dispatch.getId()).eq(DispatchItem::getReturnReceiptId, request.returnReceiptId()).last("FOR UPDATE")); if (item != null) removeLockedItem(dispatch, item, List.of(active)); else releaseLock(active, LocalDateTime.now()); return; @@ -256,9 +272,91 @@ public class DispatchService { draftContainers.deleteById(containerId); } + @Transactional + public List preArrangeDraft( + String operatorId, DispatchRequests.PreArrangeDraft request) { + String token = trimToNull(request.draftToken()); + if (token == null || request.groups() == null || request.groups().isEmpty() || request.groups().size() > 100) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择需要预排的装车分组"); + DispatchDraft draft = drafts.lockActive(token, trimToNull(operatorId)); + if (draft == null || draft.getExpiresAt() != null && !draft.getExpiresAt().isAfter(LocalDateTime.now())) + throw new ResponseStatusException(HttpStatus.CONFLICT, "排车草稿已失效或不属于当前操作人"); + var containers = draftContainers.findByToken(token); + var groups = new java.util.LinkedHashMap(); + Set allSources = new HashSet<>(); + for (var group : request.groups()) { + if (group == null || group.draftContainerId() == null || groups.putIfAbsent(group.draftContainerId(), group) != null) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "装车分组不能重复"); + Set selected = requestedGroupSources(group); + if (selected.isEmpty() || !selected.stream().allMatch(allSources::add)) + throw new ResponseStatusException(HttpStatus.CONFLICT, "每个来源只能加入一个非空装车分组"); + } + if (allSources.size() > 500) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "每次最多选择500项来源"); + var prepared = new java.util.ArrayList(); + Set physicalPallets = new HashSet<>(); + Set dispatchIds = new HashSet<>(); + Set actualGroups = new HashSet<>(); + for (var container : containers.stream().sorted(java.util.Comparator.comparing(DispatchDraftContainer::getId)).toList()) { + if (!Objects.equals(container.getDraftId(), draft.getId())) + throw new ResponseStatusException(HttpStatus.CONFLICT, "装车分组不属于当前草稿"); + List bound = locks.lockActiveByDraftContainer(token, container.getId()); + if (bound.isEmpty()) continue; + actualGroups.add(container.getId()); + var group = groups.get(container.getId()); + if (group == null) throw new ResponseStatusException(HttpStatus.CONFLICT, "草稿分组已变化,请重新加载"); + if (container.getCapacity() == null || bound.size() > container.getCapacity()) + throw new ResponseStatusException(HttpStatus.CONFLICT, "装车分组超过车型托位容量"); + Set actual = new HashSet<>(); + Set customers = new HashSet<>(); + Set groupDispatchIds = new HashSet<>(); + for (var lock : bound) { + requireDraftOwner(lock, operatorId, token); + actual.add(lock.getReturnReceiptId() == null ? "PLAN:" + lock.getPlanPalletId() : "RETURN:" + lock.getReturnReceiptId()); + customers.add(lock.getCustomerId()); + if (lock.getSourcePalletKey() == null || !physicalPallets.add(lock.getSourcePalletKey())) + throw new ResponseStatusException(HttpStatus.CONFLICT, "同一实物托盘不能重复排车"); + if (lock.getDispatchOrderId() != null) groupDispatchIds.add(lock.getDispatchOrderId()); + } + if (!actual.equals(requestedGroupSources(group)) || actual.size() != bound.size()) + throw new ResponseStatusException(HttpStatus.CONFLICT, "托盘所属装车分组已变化,请重新加载"); + String customer = resolveCustomerScopeForSources(group.planPalletIds(), group.returnReceiptIds()); + if (!customers.equals(Set.of(customer))) throw new ResponseStatusException(HttpStatus.CONFLICT, "装车分组客户归属不一致"); + if (groupDispatchIds.size() > 1 || !groupDispatchIds.stream().allMatch(dispatchIds::add)) + throw new ResponseStatusException(HttpStatus.CONFLICT, "草稿与排车分组不一致,请核对原排车单"); + prepared.add(new PreparedDispatchGroup(container.getId(), customer, group)); + } + if (!actualGroups.equals(groups.keySet())) + throw new ResponseStatusException(HttpStatus.CONFLICT, "装车分组不存在、为空或已失效"); + var results = new java.util.ArrayList(); + for (var group : prepared) { + var command = new DispatchRequests.PreArrange(group.request().planPalletIds(), null, null, null, + token, null, group.request().returnReceiptIds()); + var response = preArrangeContainer(group.customerId(), operatorId, command, group.containerId(), true); + results.add(new com.baiyue.lastleg.dispatch.dto.DispatchDraftGroupResult(group.containerId(), response)); + } + return List.copyOf(results); + } + + private Set requestedGroupSources(DispatchRequests.DraftGroup group) { + Set result = new HashSet<>(); + for (Long id : group.planPalletIds()) if (id == null || !result.add("PLAN:" + id)) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "装车托盘不能重复或为空"); + for (Long id : group.returnReceiptIds()) if (id == null || !result.add("RETURN:" + id)) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "退回来源不能重复或为空"); + return result; + } + + private record PreparedDispatchGroup(Long containerId, String customerId, DispatchRequests.DraftGroup request) { } + @Transactional public DispatchResponse preArrange(String customerId, String operatorId, DispatchRequests.PreArrange request) { + return preArrangeContainer(customerId, operatorId, request, null, false); + } + + private DispatchResponse preArrangeContainer(String customerId, String operatorId, + DispatchRequests.PreArrange request, Long expectedContainerId, boolean preserveExactReplay) { if (StringUtils.hasText(request.relabelNo())) { + if (!request.returnReceiptIds().isEmpty()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "换标与退回来源不能混用"); return preArrangeRelabel(customerId, operatorId, request); } if (!StringUtils.hasText(customerId)) { @@ -266,13 +364,20 @@ public class DispatchService { "No customer business data is available for dispatch"); } List palletIds = request.planPalletIds().stream().filter(Objects::nonNull).distinct().sorted().toList(); - if (palletIds.isEmpty() || palletIds.size() != request.planPalletIds().size()) { + List receiptIds = request.returnReceiptIds().stream().distinct().sorted().toList(); + if (palletIds.size() + receiptIds.size() > 500) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "每次最多选择500项来源"); + if ((palletIds.isEmpty() && receiptIds.isEmpty()) || palletIds.size() != request.planPalletIds().size() + || receiptIds.size() != request.returnReceiptIds().size()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Plan pallet IDs must be unique and non-empty"); } - List selected = candidates.lockSelected(customerId, palletIds); - if (selected.size() != palletIds.size()) { + List selected = new java.util.ArrayList<>(); + if (!palletIds.isEmpty()) selected.addAll(candidates.lockSelected(customerId, palletIds)); + if (!receiptIds.isEmpty()) selected.addAll(returnCandidates.lockSelected(customerId, receiptIds)); + if (selected.size() != palletIds.size() + receiptIds.size()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more selected pallets are not in this tenant"); } + if (selected.stream().map(DispatchPalletCandidate::sourcePalletKey).distinct().count() != selected.size()) + throw new ResponseStatusException(HttpStatus.CONFLICT, "同一实物托盘不能重复选择,请先拆托核对来源"); if (selected.stream().anyMatch(candidate -> !hasValidDestination(candidate))) { boolean legacyMissingFba = selected.stream().allMatch(candidate -> !"PRIVATE".equals(candidate.destinationType())); throw new ResponseStatusException(HttpStatus.CONFLICT, legacyMissingFba @@ -291,6 +396,7 @@ public class DispatchService { throw new ResponseStatusException(HttpStatus.CONFLICT, "一次排车只能包含同一海外仓的托盘"); } String draftToken = trimToNull(request.draftToken()); + Set selectedContainers = new HashSet<>(); for (DispatchPalletCandidate candidate : selected) { DispatchPalletLock active = activeLock(customerId, candidate); if (draftToken != null) { @@ -299,16 +405,27 @@ public class DispatchService { "Pallet draft lock has expired: " + candidate.palletNo()); } requireDraftOwner(active, operatorId, draftToken); + selectedContainers.add(active.getDraftContainerId()); } else if (active != null) { throw new ResponseStatusException(HttpStatus.CONFLICT, "Pallet is already locked by another active dispatch: " + candidate.palletNo()); } } + if (draftToken != null && (selectedContainers.size() != 1 || selectedContainers.contains(null) + || expectedContainerId != null && !selectedContainers.contains(expectedContainerId))) + throw new ResponseStatusException(HttpStatus.CONFLICT, "多个装车分组必须按分组批量预排,不能合并提交"); + LocalDateTime now = LocalDateTime.now(); - Long existingDispatchId = draftToken == null ? null : locks.findDispatchOrderIdByDraft(draftToken); + Long existingDispatchId = draftToken == null ? null : locks.findDispatchOrderIdByDraftContainer(draftToken, selectedContainers.iterator().next()); DispatchOrder dispatch = existingDispatchId == null ? null : orders.lockById(customerId, existingDispatchId); boolean refreshing = dispatch != null; + if (preserveExactReplay && refreshing) { + var existing = items.findByDispatchOrder(customerId, dispatch.getId()); + if (existing.size() == selected.size() && existing.stream().map(DispatchService::selectionKey).collect(Collectors.toSet()) + .equals(selected.stream().map(DispatchService::selectionKey).collect(Collectors.toSet()))) + return response(dispatch, existing); + } if (refreshing) { requirePalletEditingAllowed(dispatch); } else { @@ -336,24 +453,22 @@ public class DispatchService { if (refreshing) orders.updateById(dispatch); else orders.insert(dispatch); List previousItems = items.findByDispatchOrder(customerId, dispatch.getId()); - Set selectedPalletIds = selected.stream().map(DispatchPalletCandidate::planPalletId) + Set selectedPalletIds = selected.stream().map(DispatchService::selectionKey) .collect(Collectors.toSet()); if (refreshing) { List previousLocks = locks.selectList(new LambdaQueryWrapper() .eq(DispatchPalletLock::getCustomerId, customerId) .eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId()) .eq(DispatchPalletLock::getStatus, "ACTIVE")); - previousItems.stream().filter(item -> item.getPlanPalletId() != null - && !selectedPalletIds.contains(item.getPlanPalletId())) + previousItems.stream().filter(item -> !selectedPalletIds.contains(selectionKey(item))) .forEach(item -> { - previousLocks.stream().filter(lock -> Objects.equals(lock.getPlanPalletId(), item.getPlanPalletId())) + previousLocks.stream().filter(lock -> Objects.equals(lock.getPlanPalletId(), item.getPlanPalletId()) && Objects.equals(lock.getReturnReceiptId(), item.getReturnReceiptId())) .forEach(lock -> releaseLock(lock, now)); items.deleteById(item.getId()); }); } - Set existingPalletIds = previousItems.stream() - .filter(item -> item.getPlanPalletId() == null || selectedPalletIds.contains(item.getPlanPalletId())) - .map(DispatchItem::getPlanPalletId).filter(Objects::nonNull).collect(Collectors.toSet()); + Set existingPalletIds = previousItems.stream() + .map(DispatchService::selectionKey).filter(selectedPalletIds::contains).collect(Collectors.toSet()); for (DispatchPalletCandidate candidate : selected) { DispatchPalletLock lock = activeLock(customerId, candidate); @@ -371,6 +486,7 @@ public class DispatchService { lock.setActiveKey(candidate.sourcePalletKey()); lock.setPlanId(candidate.planId()); lock.setPlanPalletId(candidate.planPalletId()); + lock.setReturnReceiptId(candidate.returnReceiptId()); lock.setWarehousePalletId(candidate.warehousePalletId()); lock.setDispatchOrderId(dispatch.getId()); lock.setOperatorId(trimToNull(operatorId)); @@ -385,13 +501,14 @@ public class DispatchService { "Pallet is already locked by another active dispatch: " + candidate.palletNo(), exception); } } - if (existingPalletIds.contains(candidate.planPalletId())) continue; + if (existingPalletIds.contains(selectionKey(candidate))) continue; DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setPlanId(candidate.planId()); item.setPlanPalletId(candidate.planPalletId()); + item.setReturnReceiptId(candidate.returnReceiptId()); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); @@ -410,7 +527,7 @@ public class DispatchService { item.setContactName(candidate.contactName()); item.setContactPhone(candidate.contactPhone()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); - item.setGrossWeightKg(Objects.requireNonNullElse(candidate.grossWeightKg(), BigDecimal.ZERO)); + item.setGrossWeightKg(candidate.grossWeightKg()); item.setVolumeWeightKg(Objects.requireNonNullElse(candidate.volumeWeightKg(), BigDecimal.ZERO)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage(candidate.sourceStage()); @@ -439,7 +556,12 @@ public class DispatchService { drafts.updateById(draft); } } - return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId())); + // Exact grouped replay must return the same persisted timestamp precision + // on the initial call as on later reads from DATETIME(3). + DispatchOrder responseOrder = preserveExactReplay + ? Objects.requireNonNull(orders.lockById(customerId, dispatch.getId()), "Saved dispatch is missing") + : dispatch; + return response(responseOrder, items.findByDispatchOrder(customerId, dispatch.getId())); } @Transactional @@ -459,7 +581,7 @@ public class DispatchService { DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(IdWorker.getId()); dispatch.setCustomerId(customerId); dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", warehouse, dispatch.getId())); dispatch.setOverseasWarehouseId(warehouse); dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName()); dispatch.setRoute(candidateRoute(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setStatus(PENDING_DEPARTURE); dispatch.setItemCount(selected.size()); dispatch.setPalletCount(selected.size()); dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); dispatch.setPickedPackageCount(0); dispatch.setShippedPackageCount(0); dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedAt(now); dispatch.setUpdatedAt(now); orders.insert(dispatch); - for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setGrossWeightKg(Objects.requireNonNullElse(candidate.grossWeightKg(), BigDecimal.ZERO)); item.setVolumeWeightKg(Objects.requireNonNullElse(candidate.volumeWeightKg(), BigDecimal.ZERO)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.relabelCartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); items.insert(item); } + for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setGrossWeightKg(candidate.grossWeightKg()); item.setVolumeWeightKg(Objects.requireNonNullElse(candidate.volumeWeightKg(), BigDecimal.ZERO)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.relabelCartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); items.insert(item); } DispatchPdaTask task = new DispatchPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(dispatch.getId()); task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); task.setWarehouseId(warehouse); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task); if (candidates.markRelabelPreDispatched(customerId, request.relabelNo()) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单已被其他排车占用"); return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId())); @@ -528,6 +650,7 @@ public class DispatchService { public DispatchResponse update(String dispatchNo, DispatchRequests.Update request) { DispatchOrder dispatch = orders.lockGlobalByDispatchNo(trimToNull(dispatchNo)); if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); + fleetBindings.requireUnbound(dispatch.getId()); if (!PENDING_DEPARTURE.equals(dispatch.getStatus())) { throw new ResponseStatusException(HttpStatus.CONFLICT, "Only a pending dispatch can be edited"); @@ -561,7 +684,7 @@ public class DispatchService { .eq(DispatchPalletLock::getCustomerId, dispatch.getCustomerId()) .eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId()) .eq(DispatchPalletLock::getStatus, "ACTIVE")); - List matchingLocks = activeLocks.stream().filter(lock -> Objects.equals(lock.getPlanPalletId(), item.getPlanPalletId()) + List matchingLocks = activeLocks.stream().filter(lock -> Objects.equals(lock.getPlanPalletId(), item.getPlanPalletId()) && Objects.equals(lock.getReturnReceiptId(), item.getReturnReceiptId()) || (item.getWarehousePalletId() != null && Objects.equals(lock.getWarehousePalletId(), item.getWarehousePalletId()))) .toList(); return removeLockedItem(dispatch, item, matchingLocks); @@ -585,6 +708,7 @@ public class DispatchService { } private void requirePalletEditingAllowed(DispatchOrder dispatch) { + fleetBindings.requireUnbound(dispatch.getId()); if (!PENDING_DEPARTURE.equals(dispatch.getStatus()) || Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0) { throw new ResponseStatusException(HttpStatus.CONFLICT, "Only an unpicked pending dispatch can edit pallets"); @@ -598,6 +722,7 @@ public class DispatchService { public void cancel(String dispatchNo) { DispatchOrder dispatch = orders.lockGlobalByDispatchNo(trimToNull(dispatchNo)); if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); + fleetBindings.requireUnbound(dispatch.getId()); if (CANCELLED.equals(dispatch.getStatus())) return; if (!(PENDING_DEPARTURE.equals(dispatch.getStatus()))) { throw new ResponseStatusException(HttpStatus.CONFLICT, "Only a pending dispatch can be cancelled"); @@ -700,6 +825,15 @@ public class DispatchService { } return found.get(0); } + private static String selectionKey(DispatchPalletCandidate value) { return value.returnReceiptId() == null ? "PLAN:" + value.planPalletId() : "RETURN:" + value.returnReceiptId(); } + private static String selectionKey(DispatchItem value) { return value.getReturnReceiptId() == null ? "PLAN:" + value.getPlanPalletId() : "RETURN:" + value.getReturnReceiptId(); } + private DispatchPalletCandidate requireDraftSource(String customerId, DispatchRequests.DraftPallet request) { + if (!request.isValidSource()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择唯一实际来源"); + if (request.returnReceiptId() == null) return requireDraftCandidate(customerId, request.planPalletId()); + var found = returnCandidates.lockSelected(customerId, List.of(request.returnReceiptId())); + if (found.size() != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "退回实收来源当前不可预排"); + return found.get(0); + } private DispatchPalletLock activeLock(String customerId, DispatchPalletCandidate candidate) { DispatchPalletLock active = locks.lockActive(customerId, candidate.sourcePalletKey()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java index f7fa3ddf8..dd2aa3c41 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java @@ -27,7 +27,12 @@ public final class ExceptionOrderResponses { public record ReturnSource(Long outboundPalletItemId, Long inventoryPositionId, String palletNo, String skuCode, long maxReturnPackages) {} - public record Detail(Row order, List returnSources) {} + public record Detail(Row order, List returnSources, List returnReceipts) {} + public record ReturnReceipt(String receiptNo, long receivedPackages, LocalDateTime receivedAt, + List redispatches) {} + public record Redispatch(String dispatchNo, String status, long selectedPackages, long pickedPackages, + List deliveries) {} + public record RelatedDelivery(String deliveryNo, String status) {} public record CommandResult(Long id, String exceptionNo, String status, long version, Long inventoryAdjustmentId, long restoredPackages, LocalDateTime completedAt, boolean replayed) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrder.java index 653b11372..48dde881a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrder.java @@ -31,6 +31,7 @@ public class ExceptionOrder { private Integer packageCount; private String returnDirection; private String returnLogisticsNo; + private String returnContextJson; private String handlingAction; private String handlingResult; private String evidenceFileIdsJson; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionReturnItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionReturnItem.java index 24bf07152..6de63567f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionReturnItem.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionReturnItem.java @@ -19,5 +19,6 @@ public class ExceptionReturnItem { private String skuCode; private Long maxReturnPackages; private Long approvedReturnPackages; + private Long receivedPackages; private LocalDateTime createdAt; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java index be96b379c..32659e2b6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java @@ -9,7 +9,7 @@ import org.apache.ibatis.annotations.Select; @Mapper public interface ExceptionOrderMapper extends BaseMapper { @Select("SELECT * FROM ll_exception_order WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} " + - "AND exception_type='RETURN' LIMIT 1") + "AND exception_type='RETURN' AND status IN ('PENDING','PROCESSING') LIMIT 1") ExceptionOrder findDeliveryReturn(@Param("customerId") String customerId, @Param("deliveryOrderId") Long deliveryOrderId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionReturnItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionReturnItemMapper.java index 0b29a66e7..c7cf170dd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionReturnItemMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionReturnItemMapper.java @@ -13,4 +13,16 @@ public interface ExceptionReturnItemMapper extends BaseMapper findByException(@Param("customerId") String customerId, @Param("exceptionOrderId") Long exceptionOrderId); + + @Select("SELECT COALESCE(SUM(i.received_packages),0) FROM ll_exception_return_item i " + + "JOIN ll_exception_order e ON e.id=i.exception_order_id AND e.customer_id=i.customer_id " + + "WHERE e.customer_id=#{customerId} AND e.delivery_order_id=#{deliveryOrderId}") + long receivedByDelivery(@Param("customerId") String customerId, @Param("deliveryOrderId") Long deliveryOrderId); + + @Select("SELECT COALESCE(SUM(i.received_packages),0) FROM ll_exception_return_item i " + + "JOIN ll_exception_order e ON e.id=i.exception_order_id AND e.customer_id=i.customer_id " + + "WHERE e.customer_id=#{customerId} AND e.delivery_order_id=#{deliveryOrderId} " + + "AND i.outbound_pallet_item_id=#{itemId} AND i.inventory_position_id=#{positionId}") + long receivedBySource(@Param("customerId") String customerId, @Param("deliveryOrderId") Long deliveryOrderId, + @Param("itemId") Long itemId, @Param("positionId") Long positionId); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java index 4bb787b6c..9a8c9f9a7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java @@ -83,6 +83,7 @@ public class ExceptionOrderService { @Resource private InventoryCommandService inventoryCommands; @Resource private ExceptionEvidenceFileGateway evidenceFiles; @Resource private ObjectMapper objectMapper; + @Resource private com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper returnReceipts; public PageResult page(String scopedCustomerId, ExceptionOrderRequests.Query query) { String customerId = customerScope(scopedCustomerId, query.customerId()); @@ -114,7 +115,44 @@ public class ExceptionOrderService { public ExceptionOrderResponses.Detail detail(String customerId, String exceptionNo) { ExceptionOrder order = exceptionOrders.findByNo(customerId, exceptionNo); if (order == null) throw notFound("异常订单不存在"); - return new ExceptionOrderResponses.Detail(row(order), storedReturnSources(order)); + return new ExceptionOrderResponses.Detail(row(order), storedReturnSources(order), + receiptHistory(returnReceipts.history(customerId, order.getId()))); + } + + private List receiptHistory( + List rows) { + var grouped = new LinkedHashMap>(); + for (var item : rows) grouped.computeIfAbsent(item.receiptId(), ignored -> new ArrayList<>()).add(item); + var receipts = new ArrayList(); + for (var batch : grouped.values()) { + var first = batch.get(0); + if (first.receiptId() == null || !StringUtils.hasText(first.receiptNo()) + || first.receivedPackages() == null || first.receivedPackages() <= 0) + throw conflict("退回实收凭据缺少有效业务编号或数量"); + var dispatches = new LinkedHashMap>(); + for (var item : batch) if (StringUtils.hasText(item.dispatchNo())) + dispatches.computeIfAbsent(item.dispatchNo(), ignored -> new ArrayList<>()).add(item); + var redispatches = new ArrayList(); + for (var entry : dispatches.entrySet()) { + var distinctItems = new LinkedHashMap(); + var deliveries = new LinkedHashMap(); + for (var item : entry.getValue()) { + if (item.dispatchItemId() == null || item.selectedPackages() == null || item.selectedPackages() < 0 + || item.pickedPackages() == null || item.pickedPackages() < 0) + throw conflict("退回重派关联缺少有效排车数量"); + distinctItems.putIfAbsent(item.dispatchItemId(), item); + if (StringUtils.hasText(item.deliveryNo())) deliveries.putIfAbsent(item.deliveryNo(), + new ExceptionOrderResponses.RelatedDelivery(item.deliveryNo(), item.deliveryStatus())); + } + long selected = distinctItems.values().stream().mapToLong(item -> item.selectedPackages()).sum(); + long picked = distinctItems.values().stream().mapToLong(item -> item.pickedPackages()).sum(); + redispatches.add(new ExceptionOrderResponses.Redispatch(entry.getKey(), entry.getValue().get(0).dispatchStatus(), + selected, picked, List.copyOf(deliveries.values()))); + } + receipts.add(new ExceptionOrderResponses.ReturnReceipt(first.receiptNo(), first.receivedPackages(), first.receivedAt(), + List.copyOf(redispatches))); + } + return List.copyOf(receipts); } public ExceptionOrderResponses.UploadedEvidence uploadEvidence(String customerId, String operatorId, @@ -130,7 +168,8 @@ public class ExceptionOrderService { public List returnCandidates(String customerId, String deliveryNo) { DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo); if (delivery == null) throw notFound("派送单不存在"); - if (!DeliveryStatusPolicy.allowedActions(delivery.getStatus()).contains("MARK_RETURN")) { + if (!DeliveryStatusPolicy.allowedActions(delivery.getStatus()).contains("MARK_RETURN") + && !"RETURN_PENDING".equals(delivery.getStatus())) { throw conflict("当前派送状态不允许标记退回"); } return returnCandidates(delivery); @@ -157,19 +196,13 @@ public class ExceptionOrderService { request.skuCode(), null, null, "MANUAL", operatorId, operatorName); } - @Transactional + @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) public ExceptionOrderResponses.Created markReturn(String customerId, String operatorId, String operatorName, String deliveryNo, ExceptionOrderRequests.MarkReturn request) { String requestHash = hash(request); ExceptionOrderCommand replay = commands.findByKey(customerId, "MARK_RETURN", request.idempotencyKey().trim()); if (replay != null) return replayCreated(replay, requestHash); - DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo); - if (delivery == null) throw notFound("派送单不存在"); - if (!DeliveryStatusPolicy.allowedActions(delivery.getStatus()).contains("MARK_RETURN")) { - throw conflict("当前派送状态不允许标记退回"); - } - ExceptionOrder existing = exceptionOrders.findDeliveryReturn(customerId, delivery.getId()); - if (existing != null) throw conflict("该派送单已存在退回异常单"); + DeliveryOrder delivery = lockForReturn(customerId, deliveryNo); List candidates = returnCandidates(delivery); ExceptionOrderResponses.Relation selected = candidates.stream() @@ -198,6 +231,7 @@ public class ExceptionOrderService { item.setOutboundPalletItemId(source.outboundPalletItemId()); item.setInventoryPositionId(source.inventoryPositionId()); item.setPalletNo(source.palletNo()); item.setSkuCode(source.skuCode()); item.setMaxReturnPackages(source.maxReturnPackages()); item.setApprovedReturnPackages(requestedItem.packageCount()); + item.setReceivedPackages(0L); item.setCreatedAt(LocalDateTime.now()); returnItems.insert(item); } @@ -222,6 +256,17 @@ public class ExceptionOrderService { return created; } + /** Caller keeps the delivery row locked until return creation commits. */ + DeliveryOrder lockForReturn(String customerId, String deliveryNo) { + DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo); + if (delivery == null) throw notFound("派送单不存在"); + if (!DeliveryStatusPolicy.allowedActions(delivery.getStatus()).contains("MARK_RETURN")) + throw conflict("当前派送状态不允许标记退回"); + if (exceptionOrders.findDeliveryReturn(customerId, delivery.getId()) != null) + throw conflict("该派送单已存在退回异常单"); + return delivery; + } + @Transactional public ExceptionOrderResponses.CommandResult startProcessing(String customerId, String operatorId, String exceptionNo, @@ -247,6 +292,7 @@ public class ExceptionOrderService { ExceptionOrderCommand replay = commands.findByKey(customerId, "CANCEL", request.idempotencyKey().trim()); if (replay != null) return replayResult(replay, requestHash); ExceptionOrder order = lock(customerId, exceptionNo, request.expectedVersion(), "PENDING"); + restoreCancelledReturn(order); LocalDateTime now = LocalDateTime.now(); order.setStatus("CANCELLED"); order.setHandlingResult(trim(request.reason())); order.setHandlerId(operatorId); order.setUpdatedAt(now); order.setVersion(version(order) + 1); @@ -266,29 +312,42 @@ public class ExceptionOrderService { ExceptionOrder order = lock(customerId, exceptionNo, request.expectedVersion(), "PROCESSING"); request.evidenceFileIds().stream().distinct().forEach(fileId -> evidenceFiles.validate(customerId, order.getId(), fileId)); ReturnedInventoryRestoreResponse restored = null; + List stored = List.of(); if ("RETURN".equals(order.getExceptionType()) && "OVERSEAS_WAREHOUSE".equals(order.getReturnDirection())) { - List stored = returnItems.findByException(customerId, order.getId()); + var delivery = deliveryOrders.lockByNo(customerId, order.getDeliveryNo()); + if (delivery == null || !"RETURN_PENDING".equals(delivery.getStatus())) throw conflict("关联派送单不是退回处理中状态"); + stored = returnItems.findByException(customerId, order.getId()); if (stored.isEmpty()) throw conflict("退回异常单缺少已冻结的原出库库存明细"); if (request.receipts() == null || request.receipts().isEmpty()) throw badRequest("请填写退回实收明细"); + Map received = ReturnReceiptPolicy.quantities(stored, request.receipts()); restored = inventoryCommands.restoreReturnedInventory(customerId, operatorId, new ReturnedInventoryRestoreRequest(order.getExceptionNo(), order.getOutboundNo(), order.getOverseasWarehouseId(), order.getInboundOrderId(), - stored.stream().map(item -> new ReturnedInventoryRestoreRequest.SourceItem( + stored.stream().filter(item -> received.containsKey(item.getId())).map(item -> new ReturnedInventoryRestoreRequest.SourceItem( item.getOutboundPalletItemId(), item.getInventoryPositionId(), item.getSkuCode(), - Objects.requireNonNullElse(item.getApprovedReturnPackages(), 0L))).toList(), + item.getApprovedReturnPackages() - ReturnReceiptPolicy.received(item))).toList(), request.receipts().stream().map(receipt -> new ReturnedInventoryRestoreRequest.Receipt( receipt.outboundPalletItemId(), receipt.inventoryPositionId(), receipt.receivedPackages(), receipt.targetPalletId(), receipt.targetPalletNo(), receipt.targetLocationId(), receipt.targetLocationCode())).toList(), request.idempotencyKey().trim(), request.evidenceFileIds().stream().map(String::valueOf).toList())); + if (restored.restoredPackages() != received.values().stream().mapToLong(Long::longValue).sum()) + throw conflict("退回库存实际入账数量与本次实收不一致"); + for (ExceptionReturnItem item : stored) if (received.containsKey(item.getId())) { + item.setReceivedPackages(ReturnReceiptPolicy.received(item) + received.get(item.getId())); + returnItems.updateById(item); + } } else if (request.receipts() != null && !request.receipts().isEmpty()) { throw badRequest("非退回海外仓异常不能提交库存实收明细"); } LocalDateTime now = restored == null ? LocalDateTime.now() : restored.completedAt(); - order.setStatus("COMPLETED"); order.setHandlingResult(trim(request.handlingResult())); - order.setEvidenceFileIdsJson(write(request.evidenceFileIds())); + boolean completed = stored.isEmpty() || ReturnReceiptPolicy.complete(stored); + order.setStatus(completed ? "COMPLETED" : "PROCESSING"); order.setHandlingResult(trim(request.handlingResult())); + List evidence = new ArrayList<>(readEvidenceFileIds(order.getEvidenceFileIdsJson())); + evidence.addAll(request.evidenceFileIds()); + order.setEvidenceFileIdsJson(write(evidence.stream().distinct().toList())); order.setInventoryAdjustmentId(restored == null ? null : restored.adjustmentId()); - order.setHandlerId(operatorId); order.setCompletedAt(now); order.setUpdatedAt(now); + order.setHandlerId(operatorId); order.setCompletedAt(completed ? now : null); order.setUpdatedAt(now); order.setVersion(version(order) + 1); exceptionOrders.updateById(order); closeReturnedDelivery(order, operatorId, now); long restoredPackages = restored == null ? 0 : restored.restoredPackages(); @@ -352,6 +411,11 @@ public class ExceptionOrderService { value.setOverseasWarehouseName(delivery == null ? relation.overseasWarehouseName() : delivery.getOverseasWarehouseName()); value.setSkuCode(trim(sku)); value.setPackageCount(packages); value.setReturnDirection(trim(direction)); value.setReturnLogisticsNo(trim(logisticsNo)); + if (delivery != null) { + LastlegOrder tail = relation.lastMileOrderId() == null ? null : lastMileOrders.selectById(relation.lastMileOrderId()); + value.setReturnContextJson(write(new ReturnContext(delivery.getStatus(), tail == null ? null : tail.getId(), + tail == null ? null : tail.getSubOrderStatus(), tail == null ? null : tail.getAbnormal()))); + } if (StringUtils.hasText(direction)) { value.setHandlingAction("OVERSEAS_WAREHOUSE".equals(direction) ? "退回海外仓" : "CUSTOMER".equals(direction) ? "退回客户" : "其他退货处理"); value.setHandlerId(operatorId); value.setHandlerName(operatorName); @@ -414,7 +478,9 @@ public class ExceptionOrderService { DeliveryOrder delivery) { List sources = returnSources(delivery).stream() .filter(source -> relation.inboundCode() == null || Objects.equals(source.inboundCode(), relation.inboundCode())) - .map(Source::response).toList(); + .map(source -> source.withPackages(source.maxReturnPackages() - returnItems.receivedBySource( + delivery.getCustomerId(), delivery.getId(), source.outboundPalletItemId(), source.inventoryPositionId()))) + .filter(source -> source.maxReturnPackages() > 0).map(Source::response).toList(); return new ExceptionOrderResponses.Relation(relation.lastMileOrderId(), relation.lastMileOrderNo(), relation.inboundOrderId(), relation.inboundCode(), relation.customerId(), relation.customerName(), relation.overseasWarehouseId(), relation.overseasWarehouseName(), relation.deliveryNo(), @@ -457,12 +523,17 @@ public class ExceptionOrderService { for (DispatchPdaPickOrder attribution : attributions) { LastlegOrder tail = lastMileOrders.selectById(attribution.getOrderId()); if (tail == null || !customerId.equals(tail.getCustomerId())) continue; - OutboundPalletItem outboundItem = outboundItemById.values().stream() + List matchingItems = outboundItemById.values().stream() .filter(item -> Objects.equals(item.getWarehousePalletId(), dispatchItem.getWarehousePalletId())) - .filter(item -> !StringUtils.hasText(item.getInboundCode()) - || Objects.equals(item.getInboundCode(), tail.getInboundCode())) - .findFirst().orElse(null); - if (outboundItem == null) continue; + .filter(item -> Objects.equals(item.getInboundCode(), dispatchItem.getInboundCode())) + .filter(item -> Objects.equals(item.getDestinationWarehouseId(), dispatchItem.getDestinationWarehouseId())) + .filter(item -> Objects.equals(item.getDestinationWarehouseCode(), dispatchItem.getDestinationWarehouseCode())) + .filter(item -> Objects.equals(item.getDestinationWarehouseName(), dispatchItem.getDestinationWarehouseName())) + .filter(item -> Objects.equals(item.getOrderRefs(), dispatchItem.getOrderRefs())).toList(); + if (matchingItems.size() > 1) throw conflict("排车明细对应的原出库托盘项不唯一,拒绝退回来源"); + if (matchingItems.isEmpty()) continue; + OutboundPalletItem outboundItem = matchingItems.get(0); + // A mixed pallet has one physical outbound item; each source is identified by the actual pick attribution. String key = outboundItem.getId() + ":" + pick.getInventoryPositionId() + ":" + tail.getInboundCode(); long packages = Objects.requireNonNullElse(attribution.getPackages(), 0L); aggregated.merge(key, new Source(outboundItem.getId(), pick.getInventoryPositionId(), @@ -476,7 +547,8 @@ public class ExceptionOrderService { return returnItems.findByException(order.getCustomerId(), order.getId()).stream() .map(item -> new ExceptionOrderResponses.ReturnSource(item.getOutboundPalletItemId(), item.getInventoryPositionId(), item.getPalletNo(), item.getSkuCode(), - Objects.requireNonNullElse(item.getApprovedReturnPackages(), 0L))).toList(); + Objects.requireNonNullElse(item.getApprovedReturnPackages(), 0L) - ReturnReceiptPolicy.received(item))) + .filter(item -> item.maxReturnPackages() > 0).toList(); } private void validateReturnItems(List available, @@ -506,10 +578,15 @@ public class ExceptionOrderService { private void closeReturnedDelivery(ExceptionOrder order, String operatorId, LocalDateTime now) { if (!"RETURN".equals(order.getExceptionType()) || !StringUtils.hasText(order.getDeliveryNo())) return; + // Closing a non-warehouse case does not establish physical cargo handoff or inventory receipt. + if (!"OVERSEAS_WAREHOUSE".equals(order.getReturnDirection())) return; DeliveryOrder delivery = deliveryOrders.lockByNo(order.getCustomerId(), order.getDeliveryNo()); if (delivery == null || !"RETURN_PENDING".equals(delivery.getStatus())) { throw conflict("关联派送单不是退货处理中状态"); } + long received = returnItems.receivedByDelivery(order.getCustomerId(), delivery.getId()); + if (delivery.getPackageCount() == null || received > delivery.getPackageCount()) throw conflict("累计退回量与派送实发数量不一致"); + if (received < delivery.getPackageCount()) return; delivery.setStatus("RETURNED"); delivery.setUpdatedAt(now); delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1); deliveryOrders.updateById(delivery); @@ -526,6 +603,30 @@ public class ExceptionOrderService { operation.setCreatedAt(now); deliveryOperations.insert(operation); } + private record ReturnContext(String deliveryStatus, Long tailId, String tailStatus, Boolean abnormal) { } + + private void restoreCancelledReturn(ExceptionOrder order) { + if (!"RETURN".equals(order.getExceptionType()) || !StringUtils.hasText(order.getDeliveryNo())) return; + ReturnContext previous; + try { previous = objectMapper.readValue(order.getReturnContextJson(), ReturnContext.class); } + catch (Exception exception) { throw conflict("退回异常缺少原始状态,不能猜测恢复状态"); } + if (previous == null || !Set.of("DELIVERING", "DELIVERED", "REJECTED", "RETURN_PENDING").contains(previous.deliveryStatus())) + throw conflict("退回异常原始状态无效"); + DeliveryOrder delivery = deliveryOrders.lockByNo(order.getCustomerId(), order.getDeliveryNo()); + if (delivery == null || !"RETURN_PENDING".equals(delivery.getStatus())) throw conflict("关联派送状态已变化,不能取消退回"); + if (returnItems.findByException(order.getCustomerId(), order.getId()).stream().anyMatch(item -> ReturnReceiptPolicy.received(item) > 0)) + throw conflict("已有实际退回入仓,不能取消退回事实"); + delivery.setStatus(previous.deliveryStatus()); delivery.setUpdatedAt(LocalDateTime.now()); + delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(), 0L) + 1); deliveryOrders.updateById(delivery); + if (previous.tailId() != null) { + LastlegOrder tail = lastMileOrders.selectById(previous.tailId()); + if (tail == null || !Objects.equals(tail.getCustomerId(), order.getCustomerId()) + || !"DELIVERY:RETURN_PENDING".equals(tail.getSubOrderStatus())) throw conflict("关联尾程状态已变化,不能覆盖"); + tail.setSubOrderStatus(previous.tailStatus()); tail.setAbnormal(previous.abnormal()); + tail.setUpdatedAt(LocalDateTime.now()); lastMileOrders.updateById(tail); + } + } + private List allowedActions(ExceptionOrder order) { return switch (Objects.requireNonNullElse(order.getStatus(), "")) { case "PENDING" -> List.of("START_PROCESSING", "CANCEL_EXCEPTION"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ReturnReceiptPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ReturnReceiptPolicy.java new file mode 100644 index 000000000..e8336d790 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ReturnReceiptPolicy.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.exceptionorder.service; + +import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests.ReturnReceipt; +import com.baiyue.lastleg.exceptionorder.entity.ExceptionReturnItem; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +final class ReturnReceiptPolicy { + private ReturnReceiptPolicy() { } + + static Map quantities(List sources, List receipts) { + Map amounts = new LinkedHashMap<>(); + if (sources.isEmpty() || receipts == null || receipts.isEmpty()) throw conflict("请填写已冻结来源的退回实收明细"); + for (ReturnReceipt receipt : receipts) { + ExceptionReturnItem source = sources.stream().filter(item -> Objects.equals(item.getOutboundPalletItemId(), receipt.outboundPalletItemId()) + && Objects.equals(item.getInventoryPositionId(), receipt.inventoryPositionId())).findFirst() + .orElseThrow(() -> conflict("退回实收明细不属于本异常冻结来源")); + if (receipt.receivedPackages() <= 0) throw conflict("退回实收箱数必须大于零"); + amounts.merge(source.getId(), receipt.receivedPackages(), Math::addExact); + } + for (ExceptionReturnItem source : sources) { + long total = Math.addExact(received(source), amounts.getOrDefault(source.getId(), 0L)); + if (total > source.getApprovedReturnPackages()) throw conflict("累计实收箱数超过批准退回数量"); + } + return amounts; + } + + static long received(ExceptionReturnItem item) { return Objects.requireNonNullElse(item.getReceivedPackages(), 0L); } + static boolean complete(List items) { + return !items.isEmpty() && items.stream().allMatch(item -> received(item) == item.getApprovedReturnPackages()); + } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java index 860dd5991..da1af7cf5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java @@ -58,6 +58,7 @@ public class OverseasFeeSource { private String reviewStatus; private String publicationStatus; private String sourceStatus; + @TableField(exist = false) private String voidStatus; private String reviewReason; private String reviewerId; private LocalDateTime reviewedAt; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/fleet/VehicleCareFinanceAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/fleet/VehicleCareFinanceAdapter.java new file mode 100644 index 000000000..ce32c0452 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/fleet/VehicleCareFinanceAdapter.java @@ -0,0 +1,58 @@ +package com.baiyue.lastleg.financefee.fleet; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventTypes; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import com.baiyue.lastleg.financefee.service.OverseasFeeCurrencyConverter; +import com.baiyue.lastleg.fleet.maintenance.VehicleCareFinanceGateway; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.time.*; +import java.util.*; + +@Component +public class VehicleCareFinanceAdapter implements VehicleCareFinanceGateway { + private final OverseasFeeCurrencyConverter converter; + private final LastlegEventOutboxMapper outbox; + private final JdbcTemplate jdbc; + private final ObjectMapper json; + public VehicleCareFinanceAdapter(OverseasFeeCurrencyConverter converter, LastlegEventOutboxMapper outbox, JdbcTemplate jdbc, ObjectMapper json) { + this.converter = converter; this.outbox = outbox; this.jdbc = jdbc; this.json = json; + } + @Override + public Conversion convert(String currency, BigDecimal amount, LocalDate businessDate) { + var converted = converter.convert(currency, amount, businessDate); + return new Conversion(converted.originalCurrency(), converted.originalAmount(), converted.exchangeRate(), converted.exchangeRateMonth(), converted.settlementAmount()); + } + @Override @Transactional(propagation=Propagation.MANDATORY) + public void publish(EventEnvelope envelope) { + envelope.requireValid(); + if (!EventTypes.COMPANY_VEHICLE_COST_SUBMITTED.equals(envelope.eventType()) || !"0".equals(envelope.tenantId())) + throw new IllegalArgumentException("车辆公司成本Outbox事件类型或平台归属无效"); + var event = new LastlegEventOutbox(); event.setId(IdWorker.getId()); event.setCustomerId("0"); + event.setAggregateType(envelope.aggregateType()); event.setAggregateId(envelope.aggregateId()); + event.setEventType(envelope.eventType()); event.setEventVersion(envelope.eventVersion()); event.setEventKey(envelope.correlationId()); + try { event.setPayloadJson(json.writeValueAsString(envelope)); } + catch (JsonProcessingException error) { throw new IllegalStateException("车辆公司成本事件无法保存", error); } + event.setStatus("PENDING"); event.setRetryCount(0); event.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC)); + if (outbox.insert(event) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "财务投递记录保存失败"); + } + @Override @Transactional(readOnly=true) + public Map deliveryStates(List keys) { + if (keys.size() > 100) throw new IllegalArgumentException("车辆成本投递查询最多100个事件"); + if (keys.isEmpty()) return Map.of(); + var result = new HashMap(); + jdbc.query("SELECT event_key,status FROM ll_event_outbox WHERE event_key IN (" + String.join(",", Collections.nCopies(keys.size(), "?")) + ")", + (org.springframework.jdbc.core.RowCallbackHandler) r -> result.put(r.getString("event_key"), r.getString("status")), keys.toArray()); + return Map.copyOf(result); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeOutboxRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeOutboxRepository.java index c1d28ee5a..57772829a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeOutboxRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeOutboxRepository.java @@ -15,6 +15,9 @@ import java.util.List; @RequiredArgsConstructor public class OverseasFeeOutboxRepository { private final LastlegEventOutboxMapper mapper; + private final com.baiyue.lastleg.financefee.state.OverseasFeeCancellation cancellation; + + public boolean prerequisitePublished(LastlegEventOutbox row) { return cancellation.prerequisitePublished(row); } public List pending(int limit, LocalDateTime now, LocalDateTime stale) { return mapper.selectList(new LambdaQueryWrapper() @@ -22,26 +25,52 @@ public class OverseasFeeOutboxRepository { .and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING") .and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt).or().le(LastlegEventOutbox::getNextRetryAt, now)) .or(processing -> processing.eq(LastlegEventOutbox::getStatus, "PROCESSING").le(LastlegEventOutbox::getLockedAt, stale))) + .and(eligible -> eligible.eq(LastlegEventOutbox::getStatus, "PROCESSING") + .or().isNull(LastlegEventOutbox::getEventKey).or().notLikeLeft(LastlegEventOutbox::getEventKey, ":VOIDED") + .or().apply("EXISTS (SELECT 1 FROM ll_event_outbox prerequisite WHERE prerequisite.event_key IN (" + + "CONCAT(SUBSTRING(ll_event_outbox.event_key,1,LENGTH(ll_event_outbox.event_key)-6),'PUBLISHED')," + + "CONCAT(SUBSTRING(ll_event_outbox.event_key,1,LENGTH(ll_event_outbox.event_key)-6),'ADJUSTED')) " + + "AND prerequisite.status='PUBLISHED')")) .orderByAsc(LastlegEventOutbox::getCreatedAt).last("LIMIT " + limit)); } - public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime stale) { + public boolean claim(long id, String worker, LocalDateTime now) { return mapper.update(null, new LambdaUpdateWrapper() .eq(LastlegEventOutbox::getId, id).eq(LastlegEventOutbox::getEventType, EventTypes.BUSINESS_FEE_SOURCE_CHANGED) - .and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING").or(processing -> - processing.eq(LastlegEventOutbox::getStatus, "PROCESSING").le(LastlegEventOutbox::getLockedAt, stale))) - .set(LastlegEventOutbox::getStatus, "PROCESSING").set(LastlegEventOutbox::getLockedBy, worker) - .set(LastlegEventOutbox::getLockedAt, now)) == 1; + .eq(LastlegEventOutbox::getStatus, "PENDING") + .and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt).or().le(LastlegEventOutbox::getNextRetryAt, now)) + .set(LastlegEventOutbox::getStatus, "PROCESSING") + .set(LastlegEventOutbox::getLockedBy, worker).set(LastlegEventOutbox::getLockedAt, now)) == 1; + } + + public int expire(long id, String worker, LocalDateTime cutoff, int failures, LocalDateTime next, String error) { + return mapper.update(null, new LambdaUpdateWrapper() + .eq(LastlegEventOutbox::getId, id).eq(LastlegEventOutbox::getEventType, EventTypes.BUSINESS_FEE_SOURCE_CHANGED) + .eq(LastlegEventOutbox::getStatus, "PROCESSING").eq(LastlegEventOutbox::getLockedBy, worker) + .le(LastlegEventOutbox::getLockedAt, cutoff) + .set(LastlegEventOutbox::getStatus, next == null ? "DEAD" : "PENDING") + .set(LastlegEventOutbox::getRetryCount, failures).set(LastlegEventOutbox::getNextRetryAt, next) + .set(LastlegEventOutbox::getLockedBy, null).set(LastlegEventOutbox::getLockedAt, null) + .set(LastlegEventOutbox::getLastError, truncate(error))); } public LastlegEventOutbox claimed(long id, String worker) { return mapper.selectOne(new LambdaQueryWrapper().eq(LastlegEventOutbox::getId, id) .eq(LastlegEventOutbox::getStatus, "PROCESSING").eq(LastlegEventOutbox::getLockedBy, worker)); } - public void published(long id, String worker, LocalDateTime now) { - mapper.update(null, claimed(id, worker, "PUBLISHED").set(LastlegEventOutbox::getPublishedAt, now) + @org.springframework.transaction.annotation.Transactional + public int published(long id, String worker, LocalDateTime now) { + int changed = mapper.update(null, claimed(id, worker, "PUBLISHED").set(LastlegEventOutbox::getPublishedAt, now) .set(LastlegEventOutbox::getNextRetryAt, null).set(LastlegEventOutbox::getLastError, null)); + if (changed == 1) cancellation.acknowledge(mapper.selectById(id)); + return changed; } - public void retry(long id, String worker, int retryCount, LocalDateTime next, String error) { - mapper.update(null, claimed(id, worker, "PENDING").set(LastlegEventOutbox::getRetryCount, retryCount) + public int dead(long id, String worker, int failures, String error) { + return mapper.update(null, claimed(id, worker, "DEAD") + .set(LastlegEventOutbox::getRetryCount, failures) + .set(LastlegEventOutbox::getNextRetryAt, null) + .set(LastlegEventOutbox::getLastError, truncate(error))); + } + public int retry(long id, String worker, int retryCount, LocalDateTime next, String error) { + return mapper.update(null, claimed(id, worker, "PENDING").set(LastlegEventOutbox::getRetryCount, retryCount) .set(LastlegEventOutbox::getNextRetryAt, next).set(LastlegEventOutbox::getLastError, truncate(error))); } private LambdaUpdateWrapper claimed(long id, String worker, String status) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxRelay.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxRelay.java index 5ec65a694..98479cd95 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxRelay.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxRelay.java @@ -1,7 +1,16 @@ package com.baiyue.lastleg.financefee.service; +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import com.baiyue.common.core.outbox.EventDeliveryProducer; +import com.baiyue.common.core.outbox.EventDeliveryJournal.Outcome; +import com.baiyue.common.web.EventDeliveryMetrics; +import com.baiyue.common.web.EventDeliveryMetrics.Stream; +import com.baiyue.common.web.EventDeliveryObservation; import com.baiyue.common.event.EventEnvelope; -import com.baiyue.lastleg.client.EventBridgeClient; +import com.baiyue.common.web.EventDeliveryFailure; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.baiyue.common.core.retry.BoundedRetryPolicy; +import com.baiyue.lastleg.client.DirectEventClient; import com.baiyue.lastleg.entity.LastlegEventOutbox; import com.baiyue.lastleg.financefee.repository.OverseasFeeOutboxRepository; import com.fasterxml.jackson.databind.ObjectMapper; @@ -10,37 +19,88 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; -import java.lang.management.ManagementFactory; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.UUID; @Component -public class OverseasFeeOutboxRelay { +public class OverseasFeeOutboxRelay implements EventDeliveryProducer { @Resource private OverseasFeeOutboxRepository repository; - @Resource private EventBridgeClient client; + @Resource private DirectEventClient client; @Resource private ObjectMapper objectMapper; + @Resource private EventDeliveryJournal journal; + @Resource private EventDeliveryMetrics metrics; @Value("${baiyue.security.internal-service-token:}") private String token; - private final String worker = ManagementFactory.getRuntimeMXBean().getName() + ':' + UUID.randomUUID(); + + @Value("${baiyue.event-delivery.max-attempts:12}") private int maxAttempts = 12; + @Value("${baiyue.event-delivery.base-delay-seconds:5}") private long baseDelaySeconds = 5; + @Value("${baiyue.event-delivery.max-delay-seconds:1800}") private long maxDelaySeconds = 1800; + @Value("${baiyue.event-delivery.jitter:0.1}") private double retryJitter = 0.1; + + @Value("${baiyue.event-delivery.lease-seconds:60}") private long leaseSeconds = 60; @Scheduled(fixedDelayString = "${baiyue.finance-fee-relay.fixed-delay:3000}") public void relay() { - LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC); - for (LastlegEventOutbox candidate : repository.pending(50, now, now.minusSeconds(30))) { - if (!repository.claim(candidate.getId(), worker, now, now.minusSeconds(30))) continue; + if (leaseSeconds < 1) throw new IllegalArgumentException("Lease duration must be positive"); + var policy = new BoundedRetryPolicy(maxAttempts, baseDelaySeconds, maxDelaySeconds, retryJitter); + LocalDateTime scan = LocalDateTime.now(ZoneOffset.UTC); + for (LastlegEventOutbox candidate : repository.pending(50, scan, scan.minusSeconds(leaseSeconds))) { + LocalDateTime started = LocalDateTime.now(ZoneOffset.UTC); + if ("PROCESSING".equals(candidate.getStatus())) { + int failures = (candidate.getRetryCount() == null ? 0 : candidate.getRetryCount()) + 1; + LocalDateTime next = policy.exhausted(failures) ? null : started.plus(policy.delayAfterFailure(failures)); + journal.complete(candidate.getLockedBy(), Outcome.LEASE_EXPIRED, started, "LEASE_EXPIRED", next, + () -> repository.expire(candidate.getId(), candidate.getLockedBy(), started.minusSeconds(leaseSeconds), + failures, next, "投递租约超时,消费结果未知")); + continue; // Recovery always waits for the new backoff window; never sends in this pass. + } + if (!repository.prerequisitePublished(candidate)) continue; + String worker = UUID.randomUUID().toString(); + String type = Stream.FEE.type(candidate.getEventType()); + if (!journal.begin(worker, Stream.FEE.service, type, String.valueOf(candidate.getId()), + EventDeliveryObservation.eventId(objectMapper, candidate.getPayloadJson()), Stream.FEE.target(type), started, + () -> repository.claim(candidate.getId(), worker, started))) continue; LastlegEventOutbox row = repository.claimed(candidate.getId(), worker); if (row == null) continue; - try { - EventEnvelope envelope = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class).requireValid(); - var response = client.receive(token, envelope); - if (response == null || !"200".equals(response.code()) || response.data() == null - || !envelope.eventId().equals(response.data().eventId())) - throw new IllegalStateException("Event Bridge未确认海外费用事件"); - repository.published(row.getId(), worker, LocalDateTime.now(ZoneOffset.UTC)); - } catch (Exception exception) { - int retries = row.getRetryCount() == null ? 1 : row.getRetryCount() + 1; - repository.retry(row.getId(), worker, retries, now.plusSeconds(Math.min(900, 5L << Math.min(retries, 7))), exception.getMessage()); + int failures = row.getRetryCount() == null ? 0 : row.getRetryCount(); + if (policy.exhausted(failures)) { + journal.complete(worker, Outcome.DEAD, LocalDateTime.now(ZoneOffset.UTC), "RETRY_EXHAUSTED", null, + () -> repository.dead(row.getId(), worker, failures, "自动投递次数已耗尽(含租约超时)")); + continue; } + Outcome outcome; + String diagnostic = null; + String auditError = null; + boolean permanent = false; + boolean requestStarted = false; + try { + EventEnvelope event = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class).requireValid(); + requestStarted = true; + var response = client.receive(token, event); + EventDeliveryFailure.requireSuccessful(response); + response.data().requireMatches(event); + outcome = Outcome.valueOf(response.data().outcome().name()); + } catch (Exception exception) { + permanent = EventDeliveryFailure.isPermanent(exception) + || (!requestStarted && (exception instanceof JsonProcessingException || exception instanceof IllegalArgumentException)); + diagnostic = EventDeliveryFailure.diagnostic(exception, token); + auditError = EventDeliveryObservation.errorCode(exception, permanent, requestStarted); + outcome = permanent || policy.exhausted(failures + 1) ? Outcome.DEAD : Outcome.RETRY; + } + // Persistence failures propagate. Do not reinterpret a failed commit as an HTTP failure. + LocalDateTime finished = LocalDateTime.now(ZoneOffset.UTC); + LocalDateTime next = outcome == Outcome.RETRY ? finished.plus(policy.delayAfterFailure(failures + 1)) : null; + Outcome result = outcome; + String error = diagnostic; + int retries = failures + 1; + journal.complete(worker, result, finished, auditError, next, () -> { + if (result == Outcome.DEAD) return repository.dead(row.getId(), worker, retries, error); + if (result == Outcome.RETRY) return repository.retry(row.getId(), worker, retries, next, error); + return repository.published(row.getId(), worker, finished); + }); } } + + @Scheduled(fixedDelayString = "${baiyue.event-delivery.metrics-sample-ms:30000}") + public void sampleMetrics() { metrics.sample(Stream.FEE, LocalDateTime.now(ZoneOffset.UTC)); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java index 349dab9e1..98296a317 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java @@ -7,7 +7,6 @@ import com.baiyue.common.event.EventIdentifiers; import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; -import com.baiyue.common.event.RegionCode; import com.baiyue.lastleg.entity.LastlegEventOutbox; import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; @@ -37,7 +36,7 @@ public class OverseasFeeOutboxService { + source.getSourceType() + ':' + source.getSourceFeeId() + ':' + source.getSourceVersion() + ':' + action; if (mapper.findByEventKey(eventKey) != null) return; EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), - EventTypes.BUSINESS_FEE_SOURCE_CHANGED, 1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, + EventTypes.BUSINESS_FEE_SOURCE_CHANGED, 1, Instant.now(), source.getCustomerId(), source.getSourceType(), String.valueOf(source.getSourceFeeId()), source.getSourceVersion(), null, EventIdentifiers.newUuid(), eventKey, null, payloadNode, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java index b2fc2496c..b6a4ab9ff 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java @@ -11,6 +11,7 @@ import com.baiyue.lastleg.financefee.entity.OverseasFeeCommand; import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; import com.baiyue.lastleg.financefee.mapper.OverseasFeeCommandMapper; import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper; +import com.baiyue.lastleg.financefee.state.OverseasFeeCancellation; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; @@ -41,31 +42,43 @@ public class OverseasFeeSourceService { private final OverseasFeeOutboxService outbox; private final OverseasFeeCurrencyConverter currencyConverter; private final ObjectMapper objectMapper; + private final OverseasFeeCancellation cancellation; public List list(String customerId, String businessType, String businessNo, Long containerBookingId) { - return mapper.selectList(new LambdaQueryWrapper() + return cancellation.describe(mapper.selectList(new LambdaQueryWrapper() .eq(text(customerId) != null, OverseasFeeSource::getCustomerId, text(customerId)) .eq(text(businessType) != null, OverseasFeeSource::getBusinessType, text(businessType)) .like(text(businessNo) != null, OverseasFeeSource::getBusinessNo, text(businessNo)) .eq(containerBookingId != null, OverseasFeeSource::getContainerBookingId, containerBookingId) .inSql(OverseasFeeSource::getId, "SELECT MAX(v.id) FROM ll_finance_fee_source v GROUP BY v.source_fee_id") - .orderByDesc(OverseasFeeSource::getCreatedAt).orderByDesc(OverseasFeeSource::getId)); + .orderByDesc(OverseasFeeSource::getCreatedAt).orderByDesc(OverseasFeeSource::getId))); } public List listForBusiness(String customerId, String businessType, long businessId) { - return mapper.selectList(new LambdaQueryWrapper() + return cancellation.describe(mapper.selectList(new LambdaQueryWrapper() .eq(OverseasFeeSource::getCustomerId, customerId) .eq(OverseasFeeSource::getBusinessType, businessType) .eq(OverseasFeeSource::getBusinessId, businessId) .inSql(OverseasFeeSource::getId, "SELECT MAX(v.id) FROM ll_finance_fee_source v GROUP BY v.source_fee_id") - .orderByAsc(OverseasFeeSource::getCreatedAt).orderByAsc(OverseasFeeSource::getId)); + .orderByAsc(OverseasFeeSource::getCreatedAt).orderByAsc(OverseasFeeSource::getId))); } public List listContainerCustomers(long containerBookingId) { return businessGuard.listContainerCustomers(containerBookingId); } + public void requireNoActiveDeliveryReceivables(String customerId, List inboundCodes) { + if (inboundCodes.isEmpty()) return; + long existing = mapper.selectCount(new LambdaQueryWrapper() + .eq(OverseasFeeSource::getCustomerId, customerId).eq(OverseasFeeSource::getBusinessType, "DELIVERY_ORDER") + .eq(OverseasFeeSource::getDirection, "RECEIVABLE").eq(OverseasFeeSource::getFeeItemCode, "DELIVERY_FEE") + .in(OverseasFeeSource::getInboundCode, inboundCodes).ne(OverseasFeeSource::getSourceStatus, "VOIDED") + .and(state -> state.eq(OverseasFeeSource::getReviewStatus, "APPROVED").or().gt(OverseasFeeSource::getSourceVersion, 1)) + .inSql(OverseasFeeSource::getId, "SELECT MAX(v.id) FROM ll_finance_fee_source v GROUP BY v.source_fee_id")); + if (existing > 0) throw conflict("外接货已有派送应收尚未确认作废,请先完成原费用处理再重新转派送"); + } + @Transactional public OverseasFeeSource create(String operatorId, String key, SaveRequest request) { OverseasFeeCommand replay = replay(operatorId, "CREATE", key, request); @@ -125,11 +138,12 @@ public class OverseasFeeSourceService { return value; } - @Transactional + @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) public OverseasFeeSource adjust(long sourceFeeId, String operatorId, AdjustRequest request) { OverseasFeeCommand replay = replay(operatorId, "ADJUST", request.idempotencyKey(), request); if (replay != null) return current(replay.getSourceFeeId()); OverseasFeeSource old = lock(sourceFeeId); + if (cancellation.requested(old)) throw conflict("该费用已申请作废,请先处理财务结果"); if (!"APPROVED".equals(old.getReviewStatus()) || !"ACTIVE".equals(old.getSourceStatus()) || old.getSourceVersion() != request.expectedVersion()) throw conflict("只有当前已发布版本可以调整"); validate(request.fee()); businessGuard.requireOwned(request.fee()); @@ -157,10 +171,15 @@ public class OverseasFeeSourceService { OverseasFeeSource value = lock(sourceFeeId); if (!"APPROVED".equals(value.getReviewStatus()) || !"ACTIVE".equals(value.getSourceStatus()) || value.getSourceVersion() != request.expectedVersion()) throw conflict("只有当前已发布版本可以作废"); - value.setSourceStatus("VOIDED"); value.setReviewReason(request.reason().trim()); + if (cancellation.requested(value)) return cancellation.describe(List.of(value)).get(0); + value.setReviewReason(request.reason().trim()); value.setUpdatedBy(operatorId); value.setUpdatedAt(now()); mapper.updateById(value); - outbox.publish(value, "VOIDED"); + var pending = new OverseasFeeSource(); + org.springframework.beans.BeanUtils.copyProperties(value, pending); + pending.setSourceStatus("VOIDED"); + outbox.publish(pending, "VOIDED"); command(operatorId, "VOID", request.idempotencyKey(), request, sourceFeeId, value.getSourceVersion()); + value.setVoidStatus("PENDING_FINANCE"); return value; } @@ -233,7 +252,7 @@ public class OverseasFeeSourceService { private OverseasFeeSource current(long sourceFeeId) { OverseasFeeSource value = mapper.selectOne(new LambdaQueryWrapper() .eq(OverseasFeeSource::getSourceFeeId, sourceFeeId).orderByDesc(OverseasFeeSource::getSourceVersion).last("LIMIT 1")); - if (value == null) throw notFound(); return value; + if (value == null) throw notFound(); return cancellation.describe(List.of(value)).get(0); } private OverseasFeeSource lock(long sourceFeeId) { OverseasFeeSource value = mapper.lockCurrent(sourceFeeId); if (value == null) throw notFound(); return value; } private OverseasFeeCommand replay(String operator, String action, String key, Object request) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/state/OverseasFeeCancellation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/state/OverseasFeeCancellation.java new file mode 100644 index 000000000..b0a16c6f1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/financefee/state/OverseasFeeCancellation.java @@ -0,0 +1,80 @@ +package com.baiyue.lastleg.financefee.state; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.common.event.BusinessFeeSourceChangedV1; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventTypes; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.financefee.entity.OverseasFeeSource; +import com.baiyue.lastleg.financefee.mapper.OverseasFeeSourceMapper; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.*; +import java.util.stream.Collectors; + +/** A request is not a financial void. Only the matched durable consumer ACK confirms it. */ +@Component +@RequiredArgsConstructor +public class OverseasFeeCancellation { + private final LastlegEventOutboxMapper events; + private final OverseasFeeSourceMapper fees; + private final ObjectMapper json; + + public static String eventKey(OverseasFeeSource fee, String action) { + return EventTypes.BUSINESS_FEE_SOURCE_CHANGED + ":LASTLEG:" + fee.getSourceType() + ":" + + fee.getSourceFeeId() + ":" + fee.getSourceVersion() + ":" + action; + } + public boolean requested(OverseasFeeSource fee) { return events.findByEventKey(eventKey(fee, "VOIDED")) != null; } + + public List describe(List values) { + if (values.isEmpty()) return values; + var keys = values.stream().flatMap(value -> java.util.stream.Stream.of(eventKey(value, "VOIDED"), + eventKey(value, value.getSourceVersion() == 1 ? "PUBLISHED" : "ADJUSTED"))).toList(); + var byKey = events.selectList(new LambdaQueryWrapper().in(LastlegEventOutbox::getEventKey, keys)) + .stream().collect(Collectors.toMap(LastlegEventOutbox::getEventKey, row -> row)); + for (var value : values) { + var request = byKey.get(eventKey(value, "VOIDED")); + var publish = byKey.get(eventKey(value, value.getSourceVersion() == 1 ? "PUBLISHED" : "ADJUSTED")); + value.setVoidStatus("VOIDED".equals(value.getSourceStatus()) ? "CONFIRMED" : request == null ? "NOT_REQUESTED" + : "DEAD".equals(request.getStatus()) || publish == null || "DEAD".equals(publish.getStatus()) + ? "FINANCE_ATTENTION" : "PENDING_FINANCE"); + } + return values; + } + + public boolean prerequisitePublished(LastlegEventOutbox request) { + if (request.getEventKey() == null || !request.getEventKey().endsWith(":VOIDED")) return true; + String prefix = request.getEventKey().substring(0, request.getEventKey().length() - "VOIDED".length()); + return events.selectCount(new LambdaQueryWrapper() + .in(LastlegEventOutbox::getEventKey, List.of(prefix + "PUBLISHED", prefix + "ADJUSTED")) + .eq(LastlegEventOutbox::getStatus, "PUBLISHED")) > 0; + } + + /** Invoked inside the same transaction as the lease-owner CAS and delivery journal completion. */ + @Transactional(propagation = Propagation.MANDATORY) + public void acknowledge(LastlegEventOutbox event) { + if (event.getEventKey() == null || !event.getEventKey().endsWith(":VOIDED")) return; + try { + var envelope = json.readValue(event.getPayloadJson(), EventEnvelope.class).requireValid(); + var payload = json.treeToValue(envelope.payload(), BusinessFeeSourceChangedV1.class); + if (!"VOIDED".equals(payload.action()) || !"LASTLEG".equals(payload.sourceSystem())) + throw new IllegalStateException("费用作废事件与来源不一致"); + var source = fees.lockCurrent(Long.parseLong(payload.sourceFeeId())); + if (source == null || !Objects.equals(source.getSourceVersion(), payload.sourceVersion()) + || !Objects.equals(source.getCustomerId(), payload.customerId()) + || !Objects.equals(source.getSourceType(), payload.sourceType()) + || !eventKey(source, "VOIDED").equals(event.getEventKey()) || !"ACTIVE".equals(source.getSourceStatus())) + throw new IllegalStateException("财务已确认的作废来源与当前业务版本不一致"); + source.setSourceStatus("VOIDED"); source.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC)); + if (fees.updateById(source) != 1) throw new IllegalStateException("费用作废确认未能落账"); + } catch (com.fasterxml.jackson.core.JsonProcessingException error) { + throw new IllegalStateException("费用作废事件无法解析", error); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/customer/FleetCustomerClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/customer/FleetCustomerClient.java new file mode 100644 index 000000000..b60bb226d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/customer/FleetCustomerClient.java @@ -0,0 +1,18 @@ +package com.baiyue.lastleg.fleet.customer; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.*; + +@FeignClient(name = "iam-service", contextId = "fleetCustomerClient", url = "${baiyue.clients.iam.url:}") +public interface FleetCustomerClient { + @GetMapping("/internal/iam/finance-customers") + ApiResponse> customers(@RequestHeader("X-Internal-Service-Token") String token, + @RequestParam("page") long page, @RequestParam("size") long size, @RequestParam("keyword") String keyword, + @RequestParam("status") String status, @RequestParam("customerId") Long customerId); + @GetMapping("/internal/iam/tenants/{customerId}/order-eligibility") + ApiResponse eligibility(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable("customerId") long customerId); + record Customer(Long id, String tenantCode, String tenantName, String status, String contactName, String contactPhone) { } + record Eligibility(boolean eligible, String tenantStatus, String reason) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/customer/FleetCustomerDirectory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/customer/FleetCustomerDirectory.java new file mode 100644 index 000000000..aec4a4f23 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/customer/FleetCustomerDirectory.java @@ -0,0 +1,39 @@ +package com.baiyue.lastleg.fleet.customer; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; +import java.util.List; + +@Component +public class FleetCustomerDirectory { + public record Customer(String id, String code, String name, String contactName, String contactPhone) { } + private final FleetCustomerClient client; + private final String token; + public FleetCustomerDirectory(FleetCustomerClient client, @Value("${baiyue.security.internal-service-token}") String token) { + this.client = client; this.token = token; + } + public List options(String keyword) { return load(keyword, null); } + public Customer required(long id) { + var customer = load(null, id).stream().filter(row -> row.id().equals(Long.toString(id))).findFirst() + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择有效客户")); + try { + var result = client.eligibility(token, id); + if (result == null || !result.successful() || result.data() == null) throw unavailable(); + if (!result.data().eligible()) throw new ResponseStatusException(HttpStatus.CONFLICT, "客户尚不具备下单资格,请先完成客户资料审核"); + } catch (ResponseStatusException error) { throw error; } + catch (RuntimeException error) { throw unavailable(); } + return customer; + } + private List load(String keyword, Long id) { + try { + var result = client.customers(token, 1, 30, keyword == null || keyword.isBlank() ? null : keyword.trim(), "ACTIVE", id); + if (result == null || !result.successful() || result.data() == null) throw unavailable(); + return result.data().records().stream().filter(row -> row.id() != null && row.id() > 0 && "ACTIVE".equals(row.status())) + .map(row -> new Customer(row.id().toString(), row.tenantCode(), row.tenantName(), row.contactName(), row.contactPhone())).toList(); + } catch (ResponseStatusException error) { throw error; } + catch (RuntimeException error) { throw unavailable(); } + } + private static ResponseStatusException unavailable() { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "客户目录暂不可用,请稍后重试"); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/domain/DriverQualificationPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/domain/DriverQualificationPolicy.java new file mode 100644 index 000000000..1453be173 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/domain/DriverQualificationPolicy.java @@ -0,0 +1,52 @@ +package com.baiyue.lastleg.fleet.domain; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; +import java.util.Objects; + +/** Re-evaluated by the server when planning, changing or departing a trip. */ +public final class DriverQualificationPolicy { + private DriverQualificationPolicy() { } + + public enum ReviewStatus { PENDING, APPROVED, REJECTED } + public enum Reason { + ELIGIBLE, INACTIVE, MISSING_LICENSE, MISSING_EXPIRY, PENDING_REVIEW, REJECTED, + EXPIRED, MISSING_TRIP_END, INVALID_TRIP_END, EXPIRES_DURING_TRIP + } + + public record Driver(boolean active, ReviewStatus reviewStatus, boolean hasLicense, + LocalDate licenseExpiresOn, ZoneId licenseZone) { + public Driver { + Objects.requireNonNull(reviewStatus, "reviewStatus"); + Objects.requireNonNull(licenseZone, "licenseZone"); + } + } + + public record Qualification(boolean canDispatch, boolean expiringSoon, Reason reason, + LocalDate licenseExpiresOn) { } + + public static Qualification evaluate(Driver driver, Instant now, Instant expectedEnd) { + Objects.requireNonNull(driver, "driver"); + Objects.requireNonNull(now, "now"); + LocalDate today = now.atZone(driver.licenseZone()).toLocalDate(); + LocalDate expiry = driver.licenseExpiresOn(); + long days = expiry == null ? Long.MAX_VALUE : ChronoUnit.DAYS.between(today, expiry); + boolean expiring = days >= 0 && days <= 30; + Reason reason; + if (!driver.active()) reason = Reason.INACTIVE; + else if (!driver.hasLicense()) reason = Reason.MISSING_LICENSE; + else if (expiry == null) reason = Reason.MISSING_EXPIRY; + else if (driver.reviewStatus() == ReviewStatus.REJECTED) reason = Reason.REJECTED; + else if (driver.reviewStatus() != ReviewStatus.APPROVED) reason = Reason.PENDING_REVIEW; + else if (days < 0) reason = Reason.EXPIRED; + else if (expectedEnd == null) reason = Reason.MISSING_TRIP_END; + else if (!expectedEnd.isAfter(now)) reason = Reason.INVALID_TRIP_END; + // A date-only licence is valid through that calendar day in its recorded zone. + else if (!expectedEnd.isBefore(expiry.plusDays(1).atStartOfDay(driver.licenseZone()).toInstant())) { + reason = Reason.EXPIRES_DURING_TRIP; + } else reason = Reason.ELIGIBLE; + return new Qualification(reason == Reason.ELIGIBLE, expiring, reason, expiry); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/domain/TripCapacityPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/domain/TripCapacityPolicy.java new file mode 100644 index 000000000..11853b693 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/domain/TripCapacityPolicy.java @@ -0,0 +1,69 @@ +package com.baiyue.lastleg.fleet.domain; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; + +/** Pure capacity calculation; the caller must lock the trip before confirming reservations. */ +public final class TripCapacityPolicy { + private TripCapacityPolicy() { } + + public record Load(int palletSpaces, BigDecimal weightKg, BigDecimal volumeCbm) { + public Load { + if (palletSpaces < 0 || weightKg == null || volumeCbm == null + || weightKg.signum() < 0 || volumeCbm.signum() < 0) { + throw new IllegalArgumentException("托位、重量和体积须为非负数"); + } + } + public Load plus(Load other) { + return new Load(Math.addExact(palletSpaces, other.palletSpaces), + weightKg.add(other.weightKg), volumeCbm.add(other.volumeCbm)); + } + } + + public record Booking(String bookingNo, int pickupIndex, int deliveryIndex, Load load) { + public Booking { + if (bookingNo == null || bookingNo.isBlank()) throw new IllegalArgumentException("缺少承运业务编号"); + if (pickupIndex < 0 || deliveryIndex <= pickupIndex) throw new IllegalArgumentException("卸货站必须在提货站之后"); + Objects.requireNonNull(load, "load"); + if (load.palletSpaces() == 0 || load.weightKg().signum() == 0 || load.volumeCbm().signum() == 0) { + throw new IllegalArgumentException("承运货物须有有效托位、重量和体积"); + } + } + } + + public record Segment(int fromIndex, int toIndex, Load occupied, Load remaining) { } + + public static List calculate(int stopCount, Load capacity, List bookings) { + Objects.requireNonNull(capacity, "capacity"); + Objects.requireNonNull(bookings, "bookings"); + if (stopCount < 2 || stopCount > 200) throw new IllegalArgumentException("车次须有2至200个站点"); + if (capacity.palletSpaces() <= 0 || capacity.weightKg().signum() <= 0 || capacity.volumeCbm().signum() <= 0) { + throw new IllegalArgumentException("车辆容量必须大于零"); + } + var numbers = new HashSet(); + for (Booking booking : bookings) { + Objects.requireNonNull(booking, "booking"); + if (!numbers.add(booking.bookingNo())) throw new IllegalArgumentException("承运明细重复分配"); + if (booking.deliveryIndex() >= stopCount) throw new IllegalArgumentException("承运站点不属于当前路线"); + } + List segments = new ArrayList<>(); + for (int index = 0; index < stopCount - 1; index++) { + Load occupied = new Load(0, BigDecimal.ZERO, BigDecimal.ZERO); + for (Booking booking : bookings) { + if (booking.pickupIndex() <= index && index < booking.deliveryIndex()) occupied = occupied.plus(booking.load()); + } + if (occupied.palletSpaces() > capacity.palletSpaces() + || occupied.weightKg().compareTo(capacity.weightKg()) > 0 + || occupied.volumeCbm().compareTo(capacity.volumeCbm()) > 0) { + throw new IllegalArgumentException("第" + (index + 1) + "段路线超过车辆托位、重量或容积限制"); + } + Load remaining = new Load(capacity.palletSpaces() - occupied.palletSpaces(), + capacity.weightKg().subtract(occupied.weightKg()), capacity.volumeCbm().subtract(occupied.volumeCbm())); + segments.add(new Segment(index, index + 1, occupied, remaining)); + } + return List.copyOf(segments); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/domain/TripCostAllocationPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/domain/TripCostAllocationPolicy.java new file mode 100644 index 000000000..7428ca342 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/domain/TripCostAllocationPolicy.java @@ -0,0 +1,60 @@ +package com.baiyue.lastleg.fleet.domain; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; + +/** Operating-cost allocation, not customer billing. One currency per calculation. */ +public final class TripCostAllocationPolicy { + private TripCostAllocationPolicy() { } + + public record Share(String bookingNo, long palletSpaces) { + public Share { + if (bookingNo == null || bookingNo.isBlank() || palletSpaces <= 0) { + throw new IllegalArgumentException("成本分摊须有承运编号及正数托位"); + } + } + } + public record Allocation(String bookingNo, BigDecimal amount) { } + private record Fraction(String bookingNo, BigInteger cents, BigInteger remainder) { } + + public static List allocate(BigDecimal total, String currency, List shares) { + Objects.requireNonNull(total, "total"); + Objects.requireNonNull(shares, "shares"); + if (!("USD".equals(currency) || "CNY".equals(currency))) throw new IllegalArgumentException("不支持的分摊币种"); + if (total.signum() < 0) throw new IllegalArgumentException("直接成本不能为负数"); + BigInteger cents; + try { cents = total.setScale(2, RoundingMode.UNNECESSARY).movePointRight(2).toBigIntegerExact(); } + catch (ArithmeticException e) { throw new IllegalArgumentException("金额最多两位小数", e); } + if (shares.isEmpty()) throw new IllegalArgumentException("没有可分摊的实运货物"); + var numbers = new HashSet(); + BigInteger spaces = BigInteger.ZERO; + for (Share share : shares) { + if (!numbers.add(share.bookingNo())) throw new IllegalArgumentException("成本分摊承运编号重复"); + spaces = spaces.add(BigInteger.valueOf(share.palletSpaces())); + } + List fractions = new ArrayList<>(); + BigInteger assigned = BigInteger.ZERO; + for (Share share : shares) { + BigInteger[] result = cents.multiply(BigInteger.valueOf(share.palletSpaces())).divideAndRemainder(spaces); + fractions.add(new Fraction(share.bookingNo(), result[0], result[1])); + assigned = assigned.add(result[0]); + } + // Largest remainder with a stable business-number tie-break conserves every cent. + fractions.sort(Comparator.comparing(Fraction::remainder).reversed().thenComparing(Fraction::bookingNo)); + int extraCents = cents.subtract(assigned).intValueExact(); + List result = new ArrayList<>(); + for (int i = 0; i < fractions.size(); i++) { + Fraction fraction = fractions.get(i); + BigInteger amount = fraction.cents().add(i < extraCents ? BigInteger.ONE : BigInteger.ZERO); + result.add(new Allocation(fraction.bookingNo(), new BigDecimal(amount, 2))); + } + result.sort(Comparator.comparing(Allocation::bookingNo)); + return List.copyOf(result); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverEntity.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverEntity.java new file mode 100644 index 000000000..a6cd97389 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverEntity.java @@ -0,0 +1,44 @@ +package com.baiyue.lastleg.fleet.driver; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import lombok.Data; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("ll_fleet_driver") +public class DriverEntity { + @TableId(type = IdType.INPUT) private Long id; + private String driverNo; + private String name; + private String phone; + private String ownership; + private String region; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private Long warehouseId; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String warehouseCode; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String warehouseName; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String vehicleModels; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String languagesAndArea; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDate medicalExpiresOn; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDate trainingExpiresOn; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDate insuranceExpiresOn; + private Boolean active; + private String reviewStatus; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String licenseNumber; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String licenseType; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDate licenseIssuedOn; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDate licenseExpiresOn; + private String licenseZone; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private Long licenseFileId; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String remark; + private Long recordVersion; + private String createdBy; + private LocalDateTime createdAt; + private String updatedBy; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverFileGateway.java new file mode 100644 index 000000000..34ae77fde --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverFileGateway.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.fleet.driver; + +import org.springframework.web.multipart.MultipartFile; + +public interface DriverFileGateway { + DriverModels.File upload(long driverId, String operatorId, MultipartFile file); + DriverModels.File metadata(long driverId, long fileId); + byte[] download(long driverId, long fileId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverHistoryEntity.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverHistoryEntity.java new file mode 100644 index 000000000..81068066d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverHistoryEntity.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.fleet.driver; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("ll_fleet_driver_history") +public class DriverHistoryEntity { + @TableId(type = IdType.INPUT) private Long id; + private Long driverId; + private Long recordVersion; + private String action; + private String reason; + private Long licenseFileId; + private String snapshotJson; + private String operatorId; + private LocalDateTime occurredAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverHistoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverHistoryMapper.java new file mode 100644 index 000000000..1af32e451 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverHistoryMapper.java @@ -0,0 +1,7 @@ +package com.baiyue.lastleg.fleet.driver; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface DriverHistoryMapper extends BaseMapper { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverMapper.java new file mode 100644 index 000000000..be440cddc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverMapper.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.fleet.driver; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.toolkit.Constants; +import java.time.LocalDate; +import java.util.List; + +@Mapper +public interface DriverMapper extends BaseMapper { + @Select("SELECT * FROM ll_fleet_driver WHERE id = #{id} FOR UPDATE") + DriverEntity lockById(@Param("id") long id); + + @Select("SELECT DISTINCT license_zone FROM ll_fleet_driver") + List licenseZones(); + + @Select("SELECT license_zone, license_expires_on, ownership, COUNT(*) AS driver_count FROM ll_fleet_driver ${ew.customSqlSegment} GROUP BY license_zone, license_expires_on, ownership") + List dateGroups(@Param(Constants.WRAPPER) Wrapper query); + + record DateGroup(String licenseZone, LocalDate licenseExpiresOn, String ownership, long driverCount) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverModels.java new file mode 100644 index 000000000..60e0f085a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverModels.java @@ -0,0 +1,53 @@ +package com.baiyue.lastleg.fleet.driver; + +import com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; + +public final class DriverModels { + private DriverModels() { } + + public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size, + @Size(max = 128) String keyword, Boolean active, String reviewStatus, + String ownership, String region, String licenseState, + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant expectedEnd) { + public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; } + } + + public record Draft(@NotBlank @Size(max = 128) String name, + @NotBlank @Size(max = 64) String phone, + @NotBlank @Pattern(regexp = "OWNED|DISPATCHED") String ownership, + @NotBlank @Pattern(regexp = "DOMESTIC|OVERSEAS") String region, + @Size(max = 128) String licenseNumber, @Size(max = 64) String licenseType, + LocalDate licenseIssuedOn, LocalDate licenseExpiresOn, + @NotBlank @Size(max = 64) String licenseZone, + @Size(max = 1000) String remark, @Valid Profile profile) { } + + public record Profile(@Positive Long warehouseId, @Size(max = 128) String vehicleModels, + @Size(max = 256) String languagesAndArea, LocalDate medicalExpiresOn, + LocalDate trainingExpiresOn, LocalDate insuranceExpiresOn) { } + public record WarehouseOption(String id, String code, String name, String region) { } + public record Summary(long total, long owned, long dispatched, long expiringSoon, long expired) { } + public record DriverPage(java.util.List records, long page, long size, long total, long pages, Summary summary) { } + + public record Update(@NotNull @Min(0) Long recordVersion, @NotNull @Valid Draft details) { } + public record Submit(@NotNull @Min(0) Long recordVersion) { } + public record ChangeStatus(@NotNull @Min(0) Long recordVersion, @NotNull Boolean active, + @NotBlank @Size(max = 1000) String reason) { } + public record Review(@NotNull @Min(0) Long recordVersion, + @NotBlank @Pattern(regexp = "APPROVED|REJECTED") String decision, + @Size(max = 1000) String reason) { } + public record Response(String id, String driverNo, Draft details, boolean active, String reviewStatus, + String licenseFileId, long recordVersion, String updatedBy, LocalDateTime updatedAt, + DriverQualificationPolicy.Qualification qualification, String licenseState, + String warehouseCode, String warehouseName) { } + public record History(long recordVersion, String action, String reason, String operatorId, + LocalDateTime occurredAt, Response snapshot) { } + public record File(String fileId, String fileName, String contentType, long sizeBytes) { } + public record Download(File metadata, byte[] content) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverService.java new file mode 100644 index 000000000..f1b5a6b33 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverService.java @@ -0,0 +1,346 @@ +package com.baiyue.lastleg.fleet.driver; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.time.*; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +@Service +public class DriverService { + private final DriverMapper drivers; + private final DriverHistoryMapper history; + private final DriverFileGateway files; + private final ObjectMapper json; + private final Clock clock; + private final FleetWarehouseDirectory warehouses; + + @Autowired + public DriverService(DriverMapper drivers, DriverHistoryMapper history, DriverFileGateway files, ObjectMapper json, FleetWarehouseDirectory warehouses) { + this(drivers, history, files, json, Clock.systemUTC(), warehouses); + } + + DriverService(DriverMapper drivers, DriverHistoryMapper history, DriverFileGateway files, ObjectMapper json, Clock clock, FleetWarehouseDirectory warehouses) { + this.drivers = drivers; + this.history = history; + this.files = files; + this.json = json; + this.clock = clock; + this.warehouses = warehouses; + } + + @Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) + public DriverModels.DriverPage page(DriverModels.Query query) { + Instant now = clock.instant(); + var filter = filter(query, now); + var summary = summary(drivers.dateGroups(filter), now); + filter.orderByDesc(DriverEntity::getUpdatedAt, DriverEntity::getId); + var page = drivers.selectPage(new Page<>(query.page(), query.size()), filter); + return new DriverModels.DriverPage(page.getRecords().stream().map(row -> response(row, now, query.expectedEnd())).toList(), + page.getCurrent(), page.getSize(), page.getTotal(), page.getPages(), summary); + } + + private LambdaQueryWrapper filter(DriverModels.Query query, Instant now) { + String review = optionalChoice(query.reviewStatus(), Set.of("PENDING", "APPROVED", "REJECTED"), "审核状态"); + String ownership = optionalChoice(query.ownership(), Set.of("OWNED", "DISPATCHED"), "司机归属"); + String region = optionalChoice(query.region(), Set.of("DOMESTIC", "OVERSEAS"), "区域"); + String keyword = trim(query.keyword()); + var filter = new LambdaQueryWrapper() + .eq(query.active() != null, DriverEntity::getActive, query.active()) + .eq(review != null, DriverEntity::getReviewStatus, review) + .eq(ownership != null, DriverEntity::getOwnership, ownership) + .eq(region != null, DriverEntity::getRegion, region); + if (keyword != null) filter.and(w -> w.like(DriverEntity::getName, keyword) + .or().like(DriverEntity::getPhone, keyword).or().like(DriverEntity::getDriverNo, keyword)); + String state = optionalChoice(query.licenseState(), Set.of("MISSING", "VALID", "EXPIRING", "EXPIRED"), "驾驶证有效期"); + if ("MISSING".equals(state)) filter.isNull(DriverEntity::getLicenseExpiresOn); + else if (state != null) { + List zones = drivers.licenseZones(); + if (zones.isEmpty()) filter.eq(DriverEntity::getId, -1L); + else filter.and(group -> { + for (String zone : zones) { + LocalDate today = now.atZone(ZoneId.of(zone)).toLocalDate(); + group.or(part -> { + part.eq(DriverEntity::getLicenseZone, zone); + if ("EXPIRED".equals(state)) part.lt(DriverEntity::getLicenseExpiresOn, today); + else if ("EXPIRING".equals(state)) part.between(DriverEntity::getLicenseExpiresOn, today, today.plusDays(30)); + else part.gt(DriverEntity::getLicenseExpiresOn, today.plusDays(30)); + }); + } + }); + } + return filter; + } + + private DriverModels.Summary summary(List groups, Instant now) { + long total = 0, owned = 0, dispatched = 0, expiring = 0, expired = 0; + for (var group : groups) { + long count = group.driverCount(); total += count; + if ("OWNED".equals(group.ownership())) owned += count; else dispatched += count; + String state = licenseState(group.licenseExpiresOn(), group.licenseZone(), now); + if ("EXPIRING".equals(state)) expiring += count; + if ("EXPIRED".equals(state)) expired += count; + } + return new DriverModels.Summary(total, owned, dispatched, expiring, expired); + } + + public List warehouseOptions(String region) { return warehouses.options(region); } + + public DriverModels.Response detail(long id, Instant expectedEnd) { + return response(required(id), clock.instant(), expectedEnd); + } + + @Transactional + public DriverModels.Response create(String operator, DriverModels.Draft draft) { + validate(draft); + var row = new DriverEntity(); + row.setId(IdWorker.getId()); + row.setDriverNo("DR-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(java.util.Locale.ROOT)); + assign(row, draft); + row.setActive(true); + row.setReviewStatus("PENDING"); + row.setRecordVersion(0L); + row.setCreatedBy(operator); + row.setCreatedAt(now()); + row.setUpdatedBy(operator); + row.setUpdatedAt(row.getCreatedAt()); + if (drivers.insert(row) != 1) throw conflict(); + record(row, "CREATE", null, operator); + return response(row, clock.instant(), null); + } + + @Transactional + public DriverModels.Response update(long id, String operator, DriverModels.Update request) { + validate(request.details()); + DriverEntity row = locked(id, request.recordVersion()); + DriverModels.Draft next = request.details(); + boolean credentialChanged = !Objects.equals(row.getName(), trim(next.name())) + || !Objects.equals(row.getLicenseNumber(), trim(next.licenseNumber())) + || !Objects.equals(row.getLicenseType(), trim(next.licenseType())) + || !Objects.equals(row.getLicenseIssuedOn(), next.licenseIssuedOn()) + || !Objects.equals(row.getLicenseExpiresOn(), next.licenseExpiresOn()) + || !Objects.equals(row.getLicenseZone(), next.licenseZone()); + assign(row, next); + if (credentialChanged) row.setReviewStatus("PENDING"); + persist(row, request.recordVersion(), "UPDATE", credentialChanged ? "证照或身份信息变化,重新待审" : null, operator); + return response(row, clock.instant(), null); + } + + @Transactional + public DriverModels.Response changeStatus(long id, String operator, DriverModels.ChangeStatus request) { + DriverEntity row = locked(id, request.recordVersion()); + if (trim(request.reason()) == null) throw bad("启停原因不能为空"); + row.setActive(request.active()); + persist(row, request.recordVersion(), request.active() ? "ACTIVATE" : "DEACTIVATE", trim(request.reason()), operator); + return response(row, clock.instant(), null); + } + + @Transactional + public DriverModels.Response submit(long id, String operator, DriverModels.Submit request) { + DriverEntity row = locked(id, request.recordVersion()); + if (!"REJECTED".equals(row.getReviewStatus())) throw bad("仅已打回的司机档案需要重新提交"); + requireLicense(row); + files.metadata(id, row.getLicenseFileId()); + row.setReviewStatus("PENDING"); + persist(row, request.recordVersion(), "RESUBMIT", "重新提交审核", operator); + return response(row, clock.instant(), null); + } + + @Transactional + public DriverModels.Response review(long id, String operator, DriverModels.Review request) { + DriverEntity row = locked(id, request.recordVersion()); + if (!"PENDING".equals(row.getReviewStatus())) throw bad("仅待审核司机可通过或打回;修改证照后重新提交审核"); + String decision = optionalChoice(request.decision(), Set.of("APPROVED", "REJECTED"), "审核结果"); + if (decision == null) throw bad("审核结果不能为空"); + if ("REJECTED".equals(decision) && trim(request.reason()) == null) throw bad("打回原因不能为空"); + if ("APPROVED".equals(decision)) { + requireLicense(row); + if (row.getLicenseIssuedOn() != null && row.getLicenseIssuedOn().isAfter(LocalDate.now(clock.withZone(ZoneId.of(row.getLicenseZone()))))) + throw bad("驾驶证尚未生效"); + if (row.getLicenseExpiresOn().isBefore(LocalDate.now(clock.withZone(ZoneId.of(row.getLicenseZone()))))) + throw bad("驾驶证已过期,不能通过审核"); + files.metadata(id, row.getLicenseFileId()); + } + row.setReviewStatus(decision); + persist(row, request.recordVersion(), decision, trim(request.reason()), operator); + return response(row, clock.instant(), null); + } + + @Transactional + public DriverModels.Response uploadLicense(long id, long version, String operator, MultipartFile file) { + DriverEntity row = locked(id, version); + DriverModels.File uploaded = files.upload(id, operator, file); + row.setLicenseFileId(Long.parseLong(uploaded.fileId())); + row.setReviewStatus("PENDING"); + persist(row, version, "UPLOAD_LICENSE", "驾驶证附件更新,重新待审", operator); + return response(row, clock.instant(), null); + } + + public PageResult history(long id, int page, int size) { + required(id); + var result = history.selectPage(new Page<>(page, size), new LambdaQueryWrapper() + .eq(DriverHistoryEntity::getDriverId, id).orderByDesc(DriverHistoryEntity::getRecordVersion)); + List records = result.getRecords().stream().map(event -> { + try { + return new DriverModels.History(event.getRecordVersion(), event.getAction(), event.getReason(), event.getOperatorId(), + event.getOccurredAt(), json.readValue(event.getSnapshotJson(), DriverModels.Response.class)); + } catch (JsonProcessingException e) { throw new IllegalStateException("司机历史快照读取失败", e); } + }).toList(); + return new PageResult<>(records, result.getCurrent(), result.getSize(), result.getTotal(), result.getPages()); + } + + public DriverModels.Download download(long id, long fileId) { + DriverEntity row = required(id); + if (!Objects.equals(row.getLicenseFileId(), fileId) && history.selectCount(new LambdaQueryWrapper() + .eq(DriverHistoryEntity::getDriverId, id).eq(DriverHistoryEntity::getLicenseFileId, fileId)) == 0) + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "驾驶证附件未关联该司机"); + var metadata = files.metadata(id, fileId); + return new DriverModels.Download(metadata, files.download(id, fileId)); + } + + /** Called by trip commands inside their transaction, so a concurrent credential edit cannot bypass the gate. */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public DriverModels.Response requireDispatchable(long id, Instant expectedEnd) { + DriverEntity row = drivers.lockById(id); + if (row == null) throw notFound(); + var result = response(row, clock.instant(), expectedEnd); + if (!result.qualification().canDispatch()) throw bad(blockReason(result.qualification().reason())); + files.metadata(id, row.getLicenseFileId()); + return result; + } + + private DriverEntity required(long id) { + DriverEntity row = drivers.selectById(id); + if (row == null) throw notFound(); + return row; + } + + private DriverEntity locked(long id, long version) { + DriverEntity row = drivers.lockById(id); + if (row == null) throw notFound(); + if (!Objects.equals(row.getRecordVersion(), version)) throw conflict(); + return row; + } + + private void persist(DriverEntity row, long version, String action, String reason, String operator) { + row.setRecordVersion(version + 1); + row.setUpdatedBy(operator); + row.setUpdatedAt(now()); + if (drivers.update(row, new LambdaUpdateWrapper().eq(DriverEntity::getId, row.getId()) + .eq(DriverEntity::getRecordVersion, version)) != 1) throw conflict(); + record(row, action, reason, operator); + } + + private void record(DriverEntity row, String action, String reason, String operator) { + var event = new DriverHistoryEntity(); + event.setId(IdWorker.getId()); + event.setDriverId(row.getId()); + event.setRecordVersion(row.getRecordVersion()); + event.setAction(action); + event.setReason(reason); + event.setLicenseFileId(row.getLicenseFileId()); + event.setOperatorId(operator); + event.setOccurredAt(row.getUpdatedAt()); + try { event.setSnapshotJson(json.writeValueAsString(response(row, clock.instant(), null))); } + catch (JsonProcessingException e) { throw new IllegalStateException("司机历史快照写入失败", e); } + if (history.insert(event) != 1) throw conflict(); + } + + private DriverModels.Response response(DriverEntity row, Instant now, Instant expectedEnd) { + var details = new DriverModels.Draft(row.getName(), row.getPhone(), row.getOwnership(), row.getRegion(), + row.getLicenseNumber(), row.getLicenseType(), row.getLicenseIssuedOn(), row.getLicenseExpiresOn(), row.getLicenseZone(), row.getRemark(), + new DriverModels.Profile(row.getWarehouseId(), row.getVehicleModels(), row.getLanguagesAndArea(), row.getMedicalExpiresOn(), row.getTrainingExpiresOn(), row.getInsuranceExpiresOn())); + var qualification = DriverQualificationPolicy.evaluate(new DriverQualificationPolicy.Driver(Boolean.TRUE.equals(row.getActive()), + DriverQualificationPolicy.ReviewStatus.valueOf(row.getReviewStatus()), row.getLicenseFileId() != null, + row.getLicenseExpiresOn(), ZoneId.of(row.getLicenseZone())), now, expectedEnd); + return new DriverModels.Response(row.getId().toString(), row.getDriverNo(), details, Boolean.TRUE.equals(row.getActive()), + row.getReviewStatus(), row.getLicenseFileId() == null ? null : row.getLicenseFileId().toString(), + row.getRecordVersion(), row.getUpdatedBy(), row.getUpdatedAt(), qualification, + licenseState(row.getLicenseExpiresOn(), row.getLicenseZone(), now), row.getWarehouseCode(), row.getWarehouseName()); + } + + private static void validate(DriverModels.Draft draft) { + if (trim(draft.name()) == null || trim(draft.phone()) == null) throw bad("司机姓名和电话不能为空"); + if (optionalChoice(draft.ownership(), Set.of("OWNED", "DISPATCHED"), "司机归属") == null + || optionalChoice(draft.region(), Set.of("DOMESTIC", "OVERSEAS"), "区域") == null) throw bad("司机归属和区域不能为空"); + try { ZoneId.of(draft.licenseZone()); } + catch (DateTimeException | NullPointerException e) { throw bad("请选择有效的证照时区"); } + for (LocalDate date : new LocalDate[]{draft.licenseIssuedOn(), draft.licenseExpiresOn()}) { + if (date != null && (date.getYear() < 1900 || date.getYear() > 9998)) throw bad("证照日期超出允许范围"); + } + if (draft.profile() != null) for (LocalDate date : new LocalDate[]{draft.profile().medicalExpiresOn(), draft.profile().trainingExpiresOn(), draft.profile().insuranceExpiresOn()}) { + if (date != null && (date.getYear() < 1900 || date.getYear() > 9998)) throw bad("其他资质日期超出允许范围"); + } + if (draft.licenseIssuedOn() != null && draft.licenseExpiresOn() != null && draft.licenseIssuedOn().isAfter(draft.licenseExpiresOn())) + throw bad("驾驶证到期日期不能早于签发日期"); + } + + private void assign(DriverEntity row, DriverModels.Draft draft) { + row.setName(trim(draft.name())); row.setPhone(trim(draft.phone())); + row.setOwnership(draft.ownership()); row.setRegion(draft.region()); + row.setLicenseNumber(trim(draft.licenseNumber())); row.setLicenseType(trim(draft.licenseType())); + row.setLicenseIssuedOn(draft.licenseIssuedOn()); row.setLicenseExpiresOn(draft.licenseExpiresOn()); + row.setLicenseZone(draft.licenseZone()); row.setRemark(trim(draft.remark())); + var profile = draft.profile(); + var warehouse = profile == null || profile.warehouseId() == null ? null : warehouses.required(profile.warehouseId(), draft.region()); + row.setWarehouseId(warehouse == null ? null : Long.valueOf(warehouse.id())); + row.setWarehouseCode(warehouse == null ? null : warehouse.code()); + row.setWarehouseName(warehouse == null ? null : warehouse.name()); + row.setVehicleModels(profile == null ? null : trim(profile.vehicleModels())); + row.setLanguagesAndArea(profile == null ? null : trim(profile.languagesAndArea())); + row.setMedicalExpiresOn(profile == null ? null : profile.medicalExpiresOn()); + row.setTrainingExpiresOn(profile == null ? null : profile.trainingExpiresOn()); + row.setInsuranceExpiresOn(profile == null ? null : profile.insuranceExpiresOn()); + } + + private static String licenseState(LocalDate expiry, String zone, Instant now) { + if (expiry == null) return "MISSING"; + LocalDate today = now.atZone(ZoneId.of(zone)).toLocalDate(); + if (expiry.isBefore(today)) return "EXPIRED"; + return expiry.isAfter(today.plusDays(30)) ? "VALID" : "EXPIRING"; + } + + private LocalDateTime now() { return LocalDateTime.ofInstant(clock.instant(), ZoneOffset.UTC); } + private static void requireLicense(DriverEntity row) { + if (row.getLicenseFileId() == null || trim(row.getLicenseNumber()) == null || trim(row.getLicenseType()) == null + || row.getLicenseExpiresOn() == null) throw bad("请补齐驾驶证编号、类型、有效期及附件"); + } + private static String blockReason(DriverQualificationPolicy.Reason reason) { + return switch (reason) { + case INACTIVE -> "司机已停用"; + case MISSING_LICENSE -> "请上传驾驶证附件"; + case MISSING_EXPIRY -> "请补齐驾驶证到期日期"; + case PENDING_REVIEW -> "司机证照尚未审核通过"; + case REJECTED -> "司机证照已被打回"; + case EXPIRED -> "驾驶证已过期"; + case MISSING_TRIP_END -> "请填写司机预计任务结束时间"; + case INVALID_TRIP_END -> "司机预计任务结束时间必须晚于当前时间"; + case EXPIRES_DURING_TRIP -> "驾驶证将在预计任务结束前到期,请更换司机或调整计划"; + case ELIGIBLE -> "司机可派车"; + }; + } + private static String trim(String text) { return text == null || text.isBlank() ? null : text.trim(); } + private static String optionalChoice(String value, Set allowed, String label) { + if (trim(value) == null) return null; + if (!allowed.contains(value)) throw bad(label + "不合法"); + return value; + } + private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + private static ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "司机档案不存在"); } + private static ResponseStatusException conflict() { return new ResponseStatusException(HttpStatus.CONFLICT, "司机档案已被修改,请刷新后重试"); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/FleetWarehouseClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/FleetWarehouseClient.java new file mode 100644 index 000000000..82770b304 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/FleetWarehouseClient.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.fleet.driver; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.PathVariable; +import java.util.List; + +@FeignClient(name = "warehouse-service", contextId = "fleetWarehouseClient", url = "${baiyue.clients.domestic-warehouse.url:}") +public interface FleetWarehouseClient { + @GetMapping("/internal/warehouses/order-catalog") + ApiResponse> activeWarehouses(@RequestHeader("X-Internal-Service-Token") String token); + @GetMapping("/internal/warehouses/0/{warehouseId}/active") + ApiResponse activeDetail(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable("warehouseId") long warehouseId); + record Warehouse(Long id, String warehouseCode, String warehouseName, String warehouseType, String countryCode) { } + record Identity(Long id, Long tenantId, String warehouseType, String status, String timezone) { } + record Address(Long warehouseId, Long tenantId, String city) { } + record Detail(Identity warehouse, List
addresses) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/FleetWarehouseDirectory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/FleetWarehouseDirectory.java new file mode 100644 index 000000000..dc23bcc97 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/FleetWarehouseDirectory.java @@ -0,0 +1,41 @@ +package com.baiyue.lastleg.fleet.driver; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; +import java.util.List; +import java.util.Set; + +@Component +public class FleetWarehouseDirectory { + private final FleetWarehouseClient client; + private final String token; + public FleetWarehouseDirectory(FleetWarehouseClient client, @Value("${baiyue.security.internal-service-token:}") String token) { + this.client = client; this.token = token; + } + + public List options(String region) { + if (!Set.of("DOMESTIC", "OVERSEAS").contains(region == null ? "" : region)) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择国内或海外区域"); + try { + var result = client.activeWarehouses(token); + if (result == null || !result.successful() || result.data() == null) throw unavailable(); + return result.data().stream().filter(row -> row != null && row.id() != null && row.id() > 0 + && row.warehouseCode() != null && !row.warehouseCode().isBlank() && row.warehouseName() != null && !row.warehouseName().isBlank() + && ("DOMESTIC".equals(region) ? "DOMESTIC".equals(row.warehouseType()) + : Set.of("OVERSEAS", "OVERSEAS_PARTNER").contains(row.warehouseType() == null ? "" : row.warehouseType()))) + .map(row -> new DriverModels.WarehouseOption(row.id().toString(), row.warehouseCode(), row.warehouseName(), region)).toList(); + } catch (ResponseStatusException e) { throw e; } + catch (RuntimeException e) { throw unavailable(); } + } + + public DriverModels.WarehouseOption required(long id, String region) { + return options(region).stream().filter(row -> row.id().equals(Long.toString(id))).findFirst() + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "所属仓库不存在、已停用或与区域不符")); + } + + private static ResponseStatusException unavailable() { + return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "仓库目录暂不可用,请重试"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/HttpDriverFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/HttpDriverFileGateway.java new file mode 100644 index 000000000..2e5dcd804 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/HttpDriverFileGateway.java @@ -0,0 +1,89 @@ +package com.baiyue.lastleg.fleet.driver; + +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.*; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; +import java.util.Objects; +import java.util.Set; + +@Component +public class HttpDriverFileGateway implements DriverFileGateway { + static final String TYPE = "FLEET_DRIVER_LICENSE"; + static final long MAX_SIZE = 10L * 1024 * 1024; + private static final Set CONTENT_TYPES = Set.of("application/pdf", "image/jpeg", "image/png"); + private static final String PATH = "/internal/file/v1/business-files/0/FLEET_DRIVER_LICENSE/{driver}"; + private final RestClient client; + private final String token; + + public HttpDriverFileGateway(RestClient.Builder builder, LastlegRestClientFactory factory, LastlegRestClientProperties properties, + @Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") String baseUrl, + @Value("${baiyue.security.internal-service-token:}") String token) { + this.client = factory.build(builder, baseUrl, properties.getCustomerVisitFile()); + this.token = token; + } + + @Override + public DriverModels.File upload(long driverId, String operatorId, MultipartFile file) { + if (file == null || file.isEmpty() || file.getSize() > MAX_SIZE || !CONTENT_TYPES.contains(Objects.toString(file.getContentType(), ""))) + throw bad("驾驶证附件须为非空的 PDF、JPG 或 PNG,最大 10MiB"); + var body = new MultipartBodyBuilder(); + try { + body.part("file", new ByteArrayResource(file.getBytes()) { + @Override public String getFilename() { return file.getOriginalFilename(); } + }).contentType(MediaType.parseMediaType(file.getContentType())); + } catch (IOException e) { throw bad("无法读取驾驶证附件"); } + Envelope result = client.post().uri(PATH, driverId).header("X-Internal-Service-Token", token) + .header("X-Operator-Id", operatorId).contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve() + .onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); }) + .body(Envelope.class); + return verified(result, driverId, null); + } + + @Override + public DriverModels.File metadata(long driverId, long fileId) { + Envelope result = client.get().uri(PATH + "/{file}", driverId, fileId).header("X-Internal-Service-Token", token).retrieve() + .onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); }) + .body(Envelope.class); + return verified(result, driverId, fileId); + } + + @Override + public byte[] download(long driverId, long fileId) { + byte[] content = client.get().uri(PATH + "/{file}/content", driverId, fileId).header("X-Internal-Service-Token", token).retrieve() + .onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); }).body(byte[].class); + if (content == null || content.length == 0 || content.length > MAX_SIZE) throw unavailable(HttpStatus.SERVICE_UNAVAILABLE); + return content; + } + + static DriverModels.File verified(Envelope result, long driverId, Long fileId) { + if (result == null) throw unavailable(HttpStatus.SERVICE_UNAVAILABLE); + if (!"200".equals(result.code())) throw unavailable(result.code() != null && result.code().startsWith("A04") + ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE); + Metadata data = result.data(); + if (data == null || data.fileId() == null || data.fileId() <= 0 || !Objects.equals(data.tenantId(), 0L) + || !TYPE.equals(data.businessType()) || !Long.toString(driverId).equals(data.businessId()) + || (fileId != null && !fileId.equals(data.fileId())) || data.fileName() == null || data.fileName().isBlank() + || !CONTENT_TYPES.contains(Objects.toString(data.contentType(), "")) || data.sizeBytes() == null + || data.sizeBytes() <= 0 || data.sizeBytes() > MAX_SIZE) + throw bad("文件不属于当前司机或不是有效驾驶证附件"); + return new DriverModels.File(data.fileId().toString(), data.fileName(), data.contentType(), data.sizeBytes()); + } + + private static ResponseStatusException unavailable(HttpStatusCode status) { + return new ResponseStatusException(status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, + "驾驶证文件不可用或归属校验失败"); + } + private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + record Envelope(String code, Metadata data) { } + record Metadata(Long fileId, Long tenantId, String businessType, String businessId, + String fileName, String contentType, Long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/PlatformDriverController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/PlatformDriverController.java new file mode 100644 index 000000000..46f8cfe81 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/PlatformDriverController.java @@ -0,0 +1,119 @@ +package com.baiyue.lastleg.fleet.driver; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; + +@RestController +@Validated +@RequestMapping("/api/platform/v1/last-leg/fleet/drivers") +@Tag(name = "Fleet drivers / 司机管理") +public class PlatformDriverController { + private final DriverService service; + public PlatformDriverController(DriverService service) { this.service = service; } + + @GetMapping + @PreAuthorize("hasAuthority('platform:overseas-master-data:driver:list')") + public ApiResponse page(@Valid DriverModels.Query query) { + return ApiResponse.success(service.page(query)); + } + + @GetMapping("/warehouse-options") + @PreAuthorize("hasAuthority('platform:overseas-master-data:driver:list')") + public ApiResponse> warehouseOptions(@RequestParam String region) { + return ApiResponse.success(service.warehouseOptions(region)); + } + + @GetMapping("/{id}") + @PreAuthorize("hasAuthority('platform:overseas-master-data:driver:list')") + public ApiResponse detail(@PathVariable @Min(1) long id, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant expectedEnd) { + return ApiResponse.success(service.detail(id, expectedEnd)); + } + + @GetMapping("/{id}/qualification") + @PreAuthorize("hasAuthority('platform:overseas-master-data:driver:list')") + public ApiResponse qualification(@PathVariable @Min(1) long id, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant expectedEnd) { + return ApiResponse.success(service.detail(id, expectedEnd).qualification()); + } + + @GetMapping("/{id}/history") + @PreAuthorize("hasAuthority('platform:overseas-master-data:driver:list')") + public ApiResponse> history(@PathVariable @Min(1) long id, + @RequestParam(defaultValue = "1") @Min(1) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(200) int size) { + return ApiResponse.success(service.history(id, page, size)); + } + + @PostMapping + @PreAuthorize("hasAuthority('platform:overseas-master-data:driver:create')") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody DriverModels.Draft draft) { + return ApiResponse.success(service.create(operator(user), draft)); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAuthority('platform:overseas-master-data:driver:edit')") + public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Min(1) long id, + @Valid @RequestBody DriverModels.Update request) { + return ApiResponse.success(service.update(id, operator(user), request)); + } + + @PatchMapping("/{id}/status") + @PreAuthorize("hasAuthority('platform:overseas-master-data:driver:status')") + public ApiResponse status(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Min(1) long id, + @Valid @RequestBody DriverModels.ChangeStatus request) { + return ApiResponse.success(service.changeStatus(id, operator(user), request)); + } + + @PostMapping("/{id}/review") + @PreAuthorize("hasAuthority('platform:overseas-master-data:driver:review')") + public ApiResponse review(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Min(1) long id, + @Valid @RequestBody DriverModels.Review request) { + return ApiResponse.success(service.review(id, operator(user), request)); + } + + @PostMapping("/{id}/submit") + @PreAuthorize("hasAuthority('platform:overseas-master-data:driver:edit')") + public ApiResponse submit(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Min(1) long id, + @Valid @RequestBody DriverModels.Submit request) { + return ApiResponse.success(service.submit(id, operator(user), request)); + } + + @PostMapping(value = "/{id}/license", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('platform:overseas-master-data:driver:upload')") + public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Min(1) long id, + @RequestParam @Min(0) long recordVersion, @RequestPart("file") MultipartFile file) { + return ApiResponse.success(service.uploadLicense(id, recordVersion, operator(user), file)); + } + + @GetMapping("/{id}/license/{fileId}") + @PreAuthorize("hasAuthority('platform:overseas-master-data:driver:list')") + public ResponseEntity download(@PathVariable @Min(1) long id, @PathVariable @Min(1) long fileId) { + var file = service.download(id, fileId); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .header("X-Content-Type-Options", "nosniff") + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(file.metadata().fileName(), StandardCharsets.UTF_8).build().toString()) + .contentType(MediaType.parseMediaType(file.metadata().contentType())).body(file.content()); + } + + private static String operator(AuthenticatedUser user) { + if (user == null || user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "请重新登录"); + return user.userId().toString(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverInspectionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverInspectionService.java new file mode 100644 index 000000000..243a9ffb7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverInspectionService.java @@ -0,0 +1,94 @@ +package com.baiyue.lastleg.fleet.exception; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.Load; +import com.baiyue.lastleg.fleet.trip.*; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.*; + +/** Internal preparation for the handover transaction. No stock, fee or departure command is called. */ +@Service +public class FleetHandoverInspectionService { + private final FleetTripMapper trips; + private final FleetTripHistoryMapper history; + private final FleetTripDispatchMapper bindings; + private final FleetDispatchGateway dispatches; + private final ObjectMapper json; + + public FleetHandoverInspectionService(FleetTripMapper trips, FleetTripHistoryMapper history, + FleetTripDispatchMapper bindings, FleetDispatchGateway dispatches, ObjectMapper json) { + this.trips = trips; this.history = history; this.bindings = bindings; this.dispatches = dispatches; this.json = json; + } + + public record Prepared(long recordVersion, FleetHandoverPolicy.Inspection cargo, + List executionEvidence) { } + + /** Caller holds a transaction; command preparation locks trip -> customer dispatch -> outbound -> deliveries. */ + @Transactional(propagation = Propagation.MANDATORY) + public Prepared prepare(String tripNo, Instant handedOverAt, Load replacementCapacity, boolean lock) { + var trip = lock ? trips.lockByNo(tripNo) : trips.findByNo(tripNo); + if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在"); + if (!"IN_TRANSIT".equals(trip.getStatus()) || trip.getDepartedAt() == null || trip.getCompletionJson() != null) + throw conflict("仅已发车且尚未结案的车次允许在途交接"); + var departures = history.selectList(new LambdaQueryWrapper() + .eq(FleetTripHistory::getTripId, trip.getId()).eq(FleetTripHistory::getAction, "DEPART")); + if (departures.size() != 1) throw conflict("车次缺少唯一原始发车快照"); + FleetTripModels.AuditSnapshot original; + try { original = json.readValue(departures.get(0).getSnapshotJson(), FleetTripModels.AuditSnapshot.class); } + catch (JsonProcessingException error) { throw new IllegalStateException("原始发车快照无法读取", error); } + if (original.trip() == null || !tripNo.equals(original.trip().tripNo()) + || !trip.getWarehouseId().toString().equals(original.trip().warehouseId()) + || !trip.getDepartedAt().toInstant(ZoneOffset.UTC).equals(original.trip().departedAt()) + || original.confirmedLoad() == null || original.confirmedLoad().cargo() == null + || original.confirmedLoad().pallets() == null || original.dispatches() == null) + throw conflict("原始发车快照不完整或与车次不一致"); + Map departedBindings = new TreeMap<>(); + for (var binding : original.dispatches()) { + if (departedBindings.putIfAbsent(binding.dispatchNo(), binding) != null) + throw conflict("原始发车快照包含重复客户排车"); + } + var current = bindings.listByTrip(trip.getId()); + if (current.size() != departedBindings.size()) throw conflict("车次成员与原始发车不一致"); + Set seen = new HashSet<>(); + for (var binding : current) { + var originalBinding = departedBindings.get(binding.getDispatchNo()); + if (!seen.add(binding.getDispatchNo()) || originalBinding == null + || !Objects.equals(originalBinding.customerId(), binding.getCustomerId()) + || !Objects.equals(originalBinding.packageCount(), binding.getPackageCount())) + throw conflict("客户排车归属或箱数与原始发车不一致"); + } + List evidence = new ArrayList<>(); + for (var binding : departedBindings.values()) { + var fact = dispatches.transitEvidence(binding.dispatchNo(), lock); + if (!Objects.equals(binding.dispatchNo(), fact.dispatchNo()) + || !Objects.equals(binding.customerId(), fact.customerId()) + || !trip.getWarehouseId().toString().equals(fact.warehouseId()) + || binding.packageCount() != fact.shippedPackages()) + throw conflict("实际发运与车次客户、仓库或箱数不一致"); + evidence.add(fact); + } + var load = original.confirmedLoad(); + var cargo = load.cargo().stream().map(row -> new FleetHandoverPolicy.Cargo(row.customerId(), row.dispatchNo(), + row.dispatchItemId(), row.warehousePalletId(), row.destinationKey(), row.packages())).toList(); + var measured = load.pallets().stream().map(row -> new FleetHandoverPolicy.Pallet(row.warehousePalletId(), + new Load(row.palletSpaces(), row.weightKg(), row.volumeCbm()))).toList(); + try { + var inspection = FleetHandoverPolicy.inspect(original.trip().departedAt(), handedOverAt, Instant.now(), cargo, + evidence.stream().flatMap(row -> row.deliveries().stream()).map(FleetTransitEvidence.Delivery::cargo).toList(), + measured, replacementCapacity); + return new Prepared(trip.getRecordVersion(), inspection, List.copyOf(evidence)); + } catch (IllegalArgumentException invalid) { throw conflict(invalid.getMessage()); } + } + + private static ResponseStatusException conflict(String message) { + return new ResponseStatusException(HttpStatus.CONFLICT, message); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverModels.java new file mode 100644 index 000000000..649d8856b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverModels.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.fleet.exception; + +import com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy; +import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.Load; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.time.Instant; +import java.util.List; + +public final class FleetHandoverModels { + private FleetHandoverModels() { } + public record Selection(@NotNull @Min(0) Long recordVersion, @NotNull @Positive Long vehicleId, + @NotNull @Positive Long driverId, @NotNull Instant expectedDriverEndAt, @NotNull Instant handedOverAt) { } + public record Confirm(@NotNull @Valid Selection selection, @NotBlank @Pattern(regexp="[0-9a-f]{64}") String cargoHash, + @NotBlank @Size(max=500) String location, @Size(max=1000) String reason, + @NotBlank @Size(min=8,max=128) String idempotencyKey) { } + public record Release(@NotNull @Min(0) Long recordVersion, + @NotBlank @Pattern(regexp="VEHICLE|DRIVER") String resourceKind, + @NotNull Instant actualEndAt, @NotBlank @Size(max=500) String location, + @Pattern(regexp="IN_SERVICE|MAINTENANCE|INACTIVE|RETURNED") String vehicleStatus, + @NotBlank @Size(min=8,max=128) String idempotencyKey) { } + public record Resource(String vehicleId, String vehiclePlate, String driverId, String driverName, String driverPhone) { } + public record Preview(String tripNo, long recordVersion, String cargoHash, Resource previous, Resource replacement, + Load capacity, FleetHandoverPolicy.Inspection cargo, DriverQualificationPolicy.Qualification driverQualification) { } + public record DeliveryReference(String customerId, String deliveryNo) { } + public record Snapshot(Resource previous, Resource replacement, Instant handedOverAt, String location, String reason, + Instant expectedDriverEndAt, Load capacity, FleetHandoverPolicy.Inspection cargo, + List executionEvidence, List carriedDeliveries, String operatorId, Instant recordedAt) { } + public record Released(String idempotencyKey, String requestHash, Instant actualEndAt, String location, + String vehicleStatus, String operatorId, Instant recordedAt) { } + public record Response(String handoverNo, String tripNo, long recordVersion, Snapshot snapshot, + Released vehicleRelease, Released driverRelease) { } + public record Query(@Min(1) Integer page, @Min(1) @Max(100) Integer size) { + public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; } + public long offset() { return (page - 1L) * size; } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java new file mode 100644 index 000000000..2e0a9966f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java @@ -0,0 +1,72 @@ +package com.baiyue.lastleg.fleet.exception; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.lastleg.fleet.trip.*; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** Warehouse-scoped projection; exposes only the selected customer dispatch, never a whole-trip snapshot. */ +@Service +@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) +public class FleetHandoverPdaQuery { + private final FleetTripDispatchMapper bindings; + private final FleetTripMapper trips; + private final FleetTripHistoryMapper history; + private final FleetDispatchGateway dispatches; + private final FleetHandoverRepository handovers; + private final ObjectMapper json; + public FleetHandoverPdaQuery(FleetTripDispatchMapper bindings, FleetTripMapper trips, FleetTripHistoryMapper history, + FleetDispatchGateway dispatches, FleetHandoverRepository handovers, ObjectMapper json) { + this.bindings = bindings; this.trips = trips; this.history = history; this.dispatches = dispatches; this.handovers = handovers; this.json = json; + } + public record Delivery(String deliveryNo, String status, int packages, int remainingPackages, Instant terminalAt, FleetTransitEvidence.Transport transport) { } + public record Handover(String handoverNo, Instant handedOverAt, String location, + FleetTransitEvidence.Transport previous, FleetTransitEvidence.Transport replacement) { } + public record Status(String dispatchNo, String tripNo, String tripStatus, String tripRecordVersion, + FleetTransitEvidence.Transport originalDeparture, List deliveries, Handover latestHandover) { } + + public Status status(long warehouseId, String dispatchNo) { + var binding = bindings.findByDispatchNo(dispatchNo); + if (binding == null) throw notFound(); + var trip = trips.findByNo(binding.getTripNo()); + // Check token-bound warehouse before reading any customer's transport or historical data. + if (trip == null || !Objects.equals(trip.getWarehouseId(), warehouseId) || !Objects.equals(binding.getTripId(), trip.getId())) throw notFound(); + if (!List.of("IN_TRANSIT", "COMPLETED").contains(trip.getStatus())) + throw new ResponseStatusException(HttpStatus.CONFLICT, "尚未发车,请按原拣货发车流程作业"); + var evidence = dispatches.transitEvidence(dispatchNo, false); + if (!Objects.equals(evidence.customerId(), binding.getCustomerId()) || !Long.toString(warehouseId).equals(evidence.warehouseId()) + || !dispatchNo.equals(evidence.dispatchNo()) || !Objects.equals(binding.getPackageCount(), evidence.shippedPackages())) throw notFound(); + var original = history.selectList(new LambdaQueryWrapper().eq(FleetTripHistory::getTripId, trip.getId()) + .eq(FleetTripHistory::getAction, "DEPART")); + if (original.size() != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "缺少唯一原始发车记录"); + try { + var departed = json.readValue(original.get(0).getSnapshotJson(), FleetTripModels.AuditSnapshot.class).trip(); + if (departed == null || !trip.getTripNo().equals(departed.tripNo()) || !Long.toString(warehouseId).equals(departed.warehouseId())) throw notFound(); + var originalTransport = new FleetTransitEvidence.Transport(departed.vehiclePlate(), departed.driverName(), departed.driverPhone()); + var rows = evidence.deliveries().stream().map(row -> new Delivery(row.cargo().deliveryNo(), row.status(), row.cargo().packages(), + row.cargo().terminalAt() != null ? 0 : row.cargo().packages() - row.cargo().returnedCargo().stream().mapToInt(FleetHandoverPolicy.ReturnedCargo::packages).sum(), + row.cargo().terminalAt(), row.transport())).toList(); + var latest = handovers.latestForCargo(trip.getId(), json.writeValueAsString(Map.of("customerId", binding.getCustomerId(), "dispatchNo", dispatchNo))); + Handover handover = null; + if (latest != null) { + var snapshot = json.readValue(latest.snapshot(), FleetHandoverModels.Snapshot.class); + handover = new Handover(latest.handoverNo(), snapshot.handedOverAt(), snapshot.location(), + transport(snapshot.previous()), transport(snapshot.replacement())); + } + return new Status(dispatchNo, trip.getTripNo(), trip.getStatus(), trip.getRecordVersion().toString(), originalTransport, rows, handover); + } catch (JsonProcessingException error) { throw new IllegalStateException("交接记录读取失败", error); } + } + private static FleetTransitEvidence.Transport transport(FleetHandoverModels.Resource resource) { + return new FleetTransitEvidence.Transport(resource.vehiclePlate(), resource.driverName(), resource.driverPhone()); + } + private static ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "当前仓库没有该排车交接记录"); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicy.java new file mode 100644 index 000000000..bef114894 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicy.java @@ -0,0 +1,119 @@ +package com.baiyue.lastleg.fleet.exception; + +import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.Load; +import java.time.Instant; +import java.util.*; + +/** Pure inspection of server-owned departure and delivery facts; never changes stock or fees. */ +public final class FleetHandoverPolicy { + private FleetHandoverPolicy() { } + + public record Cargo(String customerId, String dispatchNo, String itemId, String palletId, + String destinationKey, int packages) { } + /** terminalAt is a signed or RETURN_COMPLETED fact, not a mutable updatedAt. */ + public record Delivery(String customerId, String dispatchNo, String deliveryNo, + String destinationKey, int packages, Instant terminalAt, List returnedCargo) { } + public record ReturnedCargo(String itemId, String palletId, int packages, Instant receivedAt) { } + public record Pallet(String palletId, Load measuredLoad) { } + public record Inspection(List remainingCargo, List completedDeliveries, + Set remainingPalletIds, Load occupied) { } + private record Destination(String customerId, String dispatchNo, String key) { } + private record Item(String customerId, String dispatchNo, String itemId) { } + private record DeliveryKey(String customerId, String deliveryNo) { } + + /** + * Caller locks trip, dispatches and deliveries and derives terminalAt from trusted execution facts. + * An unresolved refusal has no terminalAt and remains aboard. A signed delivery later returned + * uses its original signature. This policy does not invent per-box partial-signature facts. + */ + public static Inspection inspect(Instant departedAt, Instant handedOverAt, Instant now, + List departureCargo, List deliveries, + List departureMeasurements, Load replacementCapacity) { + if (departedAt == null || handedOverAt == null || now == null + || handedOverAt.isBefore(departedAt) || handedOverAt.isAfter(now)) + throw invalid("交接时间须在实际发车之后且不晚于当前时间"); + if (departureCargo == null || departureCargo.isEmpty() || deliveries == null || deliveries.isEmpty()) + throw invalid("缺少原始发运或派送依据"); + Map shipped = new HashMap<>(); + Map palletDestinations = new HashMap<>(); + Set itemKeys = new HashSet<>(); + for (Cargo cargo : departureCargo) { + if (cargo == null || blank(cargo.customerId()) || blank(cargo.dispatchNo()) || blank(cargo.itemId()) + || blank(cargo.palletId()) || blank(cargo.destinationKey()) || cargo.packages() <= 0) + throw invalid("原始发运缺少客户、排车、实托、目的地或箱数"); + if (!itemKeys.add(new Item(cargo.customerId(), cargo.dispatchNo(), cargo.itemId()))) + throw invalid("原始发运货物重复"); + shipped.merge(destination(cargo), cargo.packages(), Math::addExact); + String previous = palletDestinations.putIfAbsent(cargo.palletId(), cargo.destinationKey()); + if (previous != null && !previous.equals(cargo.destinationKey())) + throw invalid("同一实托包含不同卸货目的地,请先核对原始装载"); + } + Map byDestination = new HashMap<>(); + Map returned = new HashMap<>(); + Set deliveryKeys = new HashSet<>(); + List completed = new ArrayList<>(); + for (Delivery delivery : deliveries) { + if (delivery == null || blank(delivery.customerId()) || blank(delivery.dispatchNo()) + || blank(delivery.deliveryNo()) || blank(delivery.destinationKey()) || delivery.packages() <= 0) + throw invalid("派送事实缺少业务归属或箱数"); + var key = new Destination(delivery.customerId(), delivery.dispatchNo(), delivery.destinationKey()); + if (!deliveryKeys.add(new DeliveryKey(delivery.customerId(), delivery.deliveryNo())) + || byDestination.putIfAbsent(key, delivery) != null) + throw invalid("派送事实重复或目的地拆分尚未核平"); + if (!Objects.equals(shipped.get(key), delivery.packages())) + throw invalid("派送客户、排车、目的地或箱数与原始发运不一致"); + if (delivery.returnedCargo() == null) throw invalid("缺少实际退回数量依据"); + for (ReturnedCargo receipt : delivery.returnedCargo()) { + Item item = new Item(delivery.customerId(), delivery.dispatchNo(), receipt.itemId()); + Cargo source = departureCargo.stream().filter(cargo -> item.equals(new Item(cargo.customerId(), cargo.dispatchNo(), cargo.itemId()))) + .findFirst().orElseThrow(() -> invalid("退回来源不属于原始发运明细")); + if (!destination(source).equals(key) || !Objects.equals(source.palletId(), receipt.palletId()) || receipt.packages() <= 0 + || receipt.receivedAt() == null || receipt.receivedAt().isBefore(departedAt) || receipt.receivedAt().isAfter(handedOverAt)) + throw invalid("退回来源、数量或实际时间与交接不一致"); + if (returned.merge(item, receipt.packages(), Math::addExact) > source.packages()) throw invalid("累计退回超过原发运明细数量"); + } + if (delivery.terminalAt() != null) { + if (delivery.terminalAt().isBefore(departedAt) || delivery.terminalAt().isAfter(handedOverAt)) + throw invalid("交接时间与已发生的签收或退回事实冲突,请刷新核对"); + completed.add(delivery); + } + } + if (!byDestination.keySet().equals(shipped.keySet())) throw invalid("派送事实未覆盖全部原始发运货物"); + Map measurements = new HashMap<>(); + if (departureMeasurements == null) throw invalid("缺少原始实测装载"); + for (Pallet pallet : departureMeasurements) { + if (pallet == null || blank(pallet.palletId()) || !positive(pallet.measuredLoad()) + || measurements.putIfAbsent(pallet.palletId(), pallet.measuredLoad()) != null) + throw invalid("原始实托测量缺失或重复"); + } + if (!measurements.keySet().equals(palletDestinations.keySet())) throw invalid("实测托盘与原始货物不一致"); + List remaining = departureCargo.stream() + .filter(cargo -> byDestination.get(destination(cargo)).terminalAt() == null) + .map(cargo -> new Cargo(cargo.customerId(), cargo.dispatchNo(), cargo.itemId(), cargo.palletId(), cargo.destinationKey(), + cargo.packages() - returned.getOrDefault(new Item(cargo.customerId(), cargo.dispatchNo(), cargo.itemId()), 0))) + .filter(cargo -> cargo.packages() > 0).toList(); + if (remaining.isEmpty()) throw invalid("全部货物已完成,请按结案和资源结束流程处理"); + Set palletIds = new TreeSet<>(); + remaining.forEach(cargo -> palletIds.add(cargo.palletId())); + Load occupied = new Load(0, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO); + // Keep the measured whole-pallet load when only one customer's portion has been signed. + // It is unsafe to infer residual weight/volume from package-count ratios. + for (String palletId : palletIds) occupied = occupied.plus(measurements.get(palletId)); + if (!positive(replacementCapacity)) throw invalid("请确认接替车辆托位、载重和容积"); + if (occupied.palletSpaces() > replacementCapacity.palletSpaces() + || occupied.weightKg().compareTo(replacementCapacity.weightKg()) > 0 + || occupied.volumeCbm().compareTo(replacementCapacity.volumeCbm()) > 0) + throw invalid("未完成货物超过接替车辆容量"); + return new Inspection(List.copyOf(remaining), List.copyOf(completed), + Collections.unmodifiableSet(palletIds), occupied); + } + + private static Destination destination(Cargo cargo) { + return new Destination(cargo.customerId(), cargo.dispatchNo(), cargo.destinationKey()); + } + private static boolean positive(Load load) { + return load != null && load.palletSpaces() > 0 && load.weightKg().signum() > 0 && load.volumeCbm().signum() > 0; + } + private static boolean blank(String text) { return text == null || text.isBlank(); } + private static IllegalArgumentException invalid(String message) { return new IllegalArgumentException(message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverRepository.java new file mode 100644 index 000000000..ff2429569 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverRepository.java @@ -0,0 +1,49 @@ +package com.baiyue.lastleg.fleet.exception; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; + +/** All mutations are serialized by the owning trip row lock. Stored snapshots are immutable. */ +@Repository +public class FleetHandoverRepository { + record Row(long id, long tripId, long tripVersion, String tripNo, String handoverNo, String requestKey, String requestHash, + long version, Instant handedOverAt, String snapshot, String vehicleRelease, String driverRelease) { } + private static final RowMapper ROW = (r, ignored) -> new Row(r.getLong("id"), r.getLong("trip_id"), r.getLong("trip_version"), + r.getString("trip_no"), r.getString("handover_no"), r.getString("request_key"), r.getString("request_hash"), + r.getLong("record_version"), r.getTimestamp("handed_over_at").toLocalDateTime().toInstant(ZoneOffset.UTC), + r.getString("snapshot_json"), r.getString("vehicle_release_json"), r.getString("driver_release_json")); + private final JdbcTemplate jdbc; + public FleetHandoverRepository(JdbcTemplate jdbc) { this.jdbc = jdbc; } + Row created(long tripId, String key) { + return jdbc.query("SELECT * FROM ll_fleet_trip_handover WHERE trip_id=? AND request_key=?", ROW, tripId, key).stream().findFirst().orElse(null); + } + Row find(long tripId, String number) { + return jdbc.query("SELECT * FROM ll_fleet_trip_handover WHERE trip_id=? AND handover_no=?", ROW, tripId, number).stream().findFirst().orElse(null); + } + Row latest(long tripId) { + return jdbc.query("SELECT * FROM ll_fleet_trip_handover WHERE trip_id=? ORDER BY id DESC LIMIT 1", ROW, tripId).stream().findFirst().orElse(null); + } + Row latestForCargo(long tripId, String customerDispatchJson) { + return jdbc.query("SELECT * FROM ll_fleet_trip_handover WHERE trip_id=? AND JSON_CONTAINS(snapshot_json,?,'$.cargo.remainingCargo')=1 ORDER BY trip_version DESC LIMIT 1", + ROW, tripId, customerDispatchJson).stream().findFirst().orElse(null); + } + long count(long tripId) { return jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_handover WHERE trip_id=?", Long.class, tripId); } + List page(long tripId, FleetHandoverModels.Query query) { + return jdbc.query("SELECT * FROM ll_fleet_trip_handover WHERE trip_id=? ORDER BY id DESC LIMIT ? OFFSET ?", ROW, tripId, query.size(), query.offset()); + } + int insert(Row row, String operator, Instant now) { + return jdbc.update("INSERT INTO ll_fleet_trip_handover(id,trip_id,trip_version,trip_no,handover_no,request_key,request_hash,record_version,handed_over_at,snapshot_json,created_by,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", + row.id(), row.tripId(), row.tripVersion(), row.tripNo(), row.handoverNo(), row.requestKey(), row.requestHash(), row.version(), + LocalDateTime.ofInstant(row.handedOverAt(), ZoneOffset.UTC), row.snapshot(), operator, LocalDateTime.ofInstant(now, ZoneOffset.UTC)); + } + int release(Row row, boolean vehicle, String result) { + String column = vehicle ? "vehicle_release_json" : "driver_release_json"; + return jdbc.update("UPDATE ll_fleet_trip_handover SET " + column + "=?,record_version=record_version+1 WHERE id=? AND record_version=? AND " + column + " IS NULL", + result, row.id(), row.version()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverService.java new file mode 100644 index 000000000..42b59463f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverService.java @@ -0,0 +1,208 @@ +package com.baiyue.lastleg.fleet.exception; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.fleet.driver.DriverModels; +import com.baiyue.lastleg.fleet.driver.DriverService; +import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy.Load; +import com.baiyue.lastleg.fleet.trip.*; +import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService; +import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.fleet.exception.FleetHandoverModels.*; + +@Service +@Transactional(isolation = Isolation.READ_COMMITTED) +public class FleetHandoverService { + private final FleetTripMapper trips; + private final FleetResourceMapper resources; + private final FleetTripService history; + private final FleetDispatchGateway dispatches; + private final VehicleRepository vehicles; + private final VehicleApplicationService vehicleService; + private final DriverService drivers; + private final FleetHandoverInspectionService inspection; + private final FleetHandoverRepository repository; + private final ObjectMapper json; + + public FleetHandoverService(FleetTripMapper trips, FleetResourceMapper resources, FleetTripService history, + FleetDispatchGateway dispatches, VehicleRepository vehicles, VehicleApplicationService vehicleService, + DriverService drivers, FleetHandoverInspectionService inspection, FleetHandoverRepository repository, ObjectMapper json) { + this.trips = trips; this.resources = resources; this.history = history; this.dispatches = dispatches; + this.vehicles = vehicles; this.vehicleService = vehicleService; this.drivers = drivers; + this.inspection = inspection; this.repository = repository; this.json = json; + } + + @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) + public PageResult page(String tripNo, Query query) { + var trip = required(tripNo, false); + long count = repository.count(trip.getId()); + return new PageResult<>(repository.page(trip.getId(), query).stream().map(this::response).toList(), + query.page(), query.size(), count, count / query.size() + (count % query.size() == 0 ? 0 : 1)); + } + + public Preview preview(String tripNo, Selection selection) { + var trip = required(tripNo, true); + return prepare(trip, selection).preview(); + } + + public Response confirm(String tripNo, String operator, Confirm request) { + var trip = required(tripNo, true); + String requestHash = hash(request); + var previous = repository.created(trip.getId(), request.idempotencyKey()); + if (previous != null) { + if (!previous.requestHash().equals(requestHash)) throw conflict("交接请求标识已用于不同内容"); + return response(previous); + } + var prepared = prepare(trip, request.selection()); + if (!prepared.preview().cargoHash().equals(request.cargoHash())) throw conflict("交接货物、资源或派送状态已变化,请重新核对"); + if (request.location() == null || request.location().isBlank()) throw bad("请填写实际交接地点"); + reserve("VEHICLE", request.selection().vehicleId(), trip); + reserve("DRIVER", request.selection().driverId(), trip); + var replacement = prepared.preview().replacement(); + for (var evidence : prepared.evidence()) dispatches.handover(evidence, + new FleetDispatchGateway.Assignment(replacement.vehiclePlate(), replacement.driverName(), replacement.driverPhone())); + Instant recordedAt = Instant.now(); + String reason = request.reason() == null || request.reason().isBlank() ? "在途车辆司机交接" : request.reason().trim(); + var snapshot = new Snapshot(prepared.preview().previous(), replacement, request.selection().handedOverAt(), + request.location().trim(), reason, request.selection().expectedDriverEndAt(), prepared.preview().capacity(), + prepared.preview().cargo(), prepared.evidence(), prepared.evidence().stream().flatMap(item -> item.deliveries().stream()) + .filter(item -> item.cargo().terminalAt() == null).map(item -> new DeliveryReference(item.cargo().customerId(), item.cargo().deliveryNo())).toList(), operator, recordedAt); + var row = new FleetHandoverRepository.Row(IdWorker.getId(), trip.getId(), request.selection().recordVersion() + 1, tripNo, + "HO-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(Locale.ROOT), + request.idempotencyKey(), requestHash, 0, request.selection().handedOverAt(), write(snapshot), null, null); + if (repository.insert(row, operator, recordedAt) != 1) throw conflict("交接记录保存失败"); + trip.setVehicleId(request.selection().vehicleId()); trip.setVehiclePlate(replacement.vehiclePlate()); + trip.setDriverId(request.selection().driverId()); trip.setDriverName(replacement.driverName()); trip.setDriverPhone(replacement.driverPhone()); + trip.setMaxPalletSpaces(prepared.preview().capacity().palletSpaces()); trip.setMaxLoadKg(prepared.preview().capacity().weightKg()); + trip.setMaxVolumeCbm(prepared.preview().capacity().volumeCbm()); + trip.setExpectedDriverEndAt(LocalDateTime.ofInstant(request.selection().expectedDriverEndAt(), ZoneOffset.UTC)); + // Old resources remain reserved until their own actual task ends. DEPART and load_json are never rewritten. + history.recordExecution(trip, request.selection().recordVersion(), "HANDOVER", reason, operator); + return response(row); + } + + public Response release(String tripNo, String handoverNo, String operator, Release request) { + var trip = required(tripNo, true); + var row = repository.find(trip.getId(), handoverNo); + if (row == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "交接记录不存在"); + if (!Set.of("VEHICLE", "DRIVER").contains(request.resourceKind())) throw bad("请选择车辆或司机任务"); + boolean vehicle = "VEHICLE".equals(request.resourceKind()); + String requestHash = hash(request), previous = vehicle ? row.vehicleRelease() : row.driverRelease(); + if (previous != null) { + var released = read(previous, Released.class); + if (!released.idempotencyKey().equals(request.idempotencyKey()) || !released.requestHash().equals(requestHash)) + throw conflict("原资源任务已结束,请核对原请求"); + return response(row); + } + if (row.version() != request.recordVersion()) throw conflict("交接记录已变化,请刷新"); + var snapshot = read(row.snapshot(), Snapshot.class); + String oldId = vehicle ? snapshot.previous().vehicleId() : snapshot.previous().driverId(); + String nextId = vehicle ? snapshot.replacement().vehicleId() : snapshot.replacement().driverId(); + Long currentId = vehicle ? trip.getVehicleId() : trip.getDriverId(); + if (oldId.equals(nextId) || oldId.equals(Objects.toString(currentId, null))) + throw conflict("该资源仍承担当前运输任务,不能通过旧交接结束"); + if (request.actualEndAt() == null || request.actualEndAt().isBefore(snapshot.handedOverAt()) + || request.actualEndAt().isAfter(Instant.now()) || request.location() == null || request.location().isBlank()) + throw bad("请填写不早于交接且不晚于当前的实际结束时间和地点"); + if (vehicle && (request.vehicleStatus() == null || !Set.of("IN_SERVICE", "MAINTENANCE", "INACTIVE", "RETURNED").contains(request.vehicleStatus()))) + throw bad("请确认原车辆结束后的实际状态"); + if (!vehicle && request.vehicleStatus() != null) throw bad("司机任务结束不能修改车辆状态"); + long resourceId = Long.parseLong(oldId); + if (vehicle) { + var oldVehicle = vehicles.lockById(resourceId).orElseThrow(() -> conflict("原车辆档案不存在")); + releaseResource(request.resourceKind(), resourceId, trip.getId()); + vehicleService.changeStatus(resourceId, operator, new VehicleModels.ChangeStatus(request.vehicleStatus(), oldVehicle.recordVersion())); + } else releaseResource(request.resourceKind(), resourceId, trip.getId()); + var released = new Released(request.idempotencyKey(), requestHash, request.actualEndAt(), request.location().trim(), + request.vehicleStatus(), operator, Instant.now()); + if (repository.release(row, vehicle, write(released)) != 1) throw conflict("原资源结束记录已变化"); + history.recordExecution(trip, trip.getRecordVersion(), vehicle ? "HANDOVER_RELEASE_VEHICLE" : "HANDOVER_RELEASE_DRIVER", + "结束交接前资源任务", operator); + return response(repository.find(trip.getId(), handoverNo)); + } + + private record Prepared(Preview preview, List evidence) { } + private record Basis(Selection selection, Resource previous, Resource replacement, Load capacity, + com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy.Qualification qualification, + FleetHandoverInspectionService.Prepared inspection) { } + private Prepared prepare(FleetTripEntity trip, Selection selection) { + if (selection.recordVersion() == null || trip.getRecordVersion().longValue() != selection.recordVersion()) + throw conflict("车次已变化,请刷新后重新核对"); + if (!"IN_TRANSIT".equals(trip.getStatus()) || trip.getVehicleId() == null || trip.getDriverId() == null) + throw conflict("仅已发车且资源完整的车次可交接"); + if (Objects.equals(selection.vehicleId(), trip.getVehicleId()) && Objects.equals(selection.driverId(), trip.getDriverId())) + throw bad("请选择需要接替的车辆或司机"); + if (selection.expectedDriverEndAt() == null || !selection.expectedDriverEndAt().isAfter(Instant.now()) + || selection.handedOverAt() == null || !selection.expectedDriverEndAt().isAfter(selection.handedOverAt())) + throw bad("司机预计任务结束须晚于交接和当前时间"); + var latest = repository.latest(trip.getId()); + if (latest != null && selection.handedOverAt().isBefore(read(latest.snapshot(), Snapshot.class).handedOverAt())) + throw bad("交接时间不能早于上次交接"); + requireOwned("VEHICLE", trip.getVehicleId(), trip.getId()); requireOwned("DRIVER", trip.getDriverId(), trip.getId()); + var vehicle = vehicles.lockById(selection.vehicleId()).orElseThrow(() -> bad("接替车辆不存在")); + if (!"OVERSEAS".equals(vehicle.region()) || !"IN_SERVICE".equals(vehicle.status())) throw bad("请选择在役海外车辆"); + if (vehicle.maxPalletSpaces() == null || vehicle.maxLoadKg() == null || vehicle.maxVolumeCbm() == null) + throw bad("请先确认接替车辆实际容量"); + DriverModels.Response driver = drivers.requireDispatchable(selection.driverId(), selection.expectedDriverEndAt()); + if (!"OVERSEAS".equals(driver.details().region())) throw bad("请选择海外司机"); + requireAvailable("VEHICLE", selection.vehicleId(), trip.getVehicleId(), trip.getId()); + requireAvailable("DRIVER", selection.driverId(), trip.getDriverId(), trip.getId()); + var capacity = new Load(vehicle.maxPalletSpaces(), vehicle.maxLoadKg(), vehicle.maxVolumeCbm()); + var checked = inspection.prepare(trip.getTripNo(), selection.handedOverAt(), capacity, true); + var previous = new Resource(trip.getVehicleId().toString(), trip.getVehiclePlate(), trip.getDriverId().toString(), trip.getDriverName(), trip.getDriverPhone()); + var replacement = new Resource(Long.toString(vehicle.id()), vehicle.plateNumber(), driver.id(), driver.details().name(), driver.details().phone()); + String cargoHash = hash(new Basis(selection, previous, replacement, capacity, driver.qualification(), checked)); + return new Prepared(new Preview(trip.getTripNo(), trip.getRecordVersion(), cargoHash, previous, replacement, + capacity, checked.cargo(), driver.qualification()), checked.executionEvidence()); + } + private void requireOwned(String kind, long id, long tripId) { + var owner = resources.find(kind, id); + if (owner == null || owner.tripId() != tripId) throw conflict("当前资源占用与车次不一致"); + } + private void requireAvailable(String kind, long id, long currentId, long tripId) { + var owner = resources.find(kind, id); + if (owner != null && (id != currentId || owner.tripId() != tripId)) + throw conflict("接替资源仍有未结束任务,请先核对实际任务结束"); + } + private void reserve(String kind, long id, FleetTripEntity trip) { + long current = "VEHICLE".equals(kind) ? trip.getVehicleId() : trip.getDriverId(); + if (id == current) { requireOwned(kind, id, trip.getId()); return; } + try { if (resources.reserve(kind, id, trip.getId(), trip.getTripNo()) != 1) throw conflict("接替资源占用保存失败"); } + catch (DuplicateKeyException occupied) { throw conflict("接替资源已被占用,请重新核对"); } + } + private void releaseResource(String kind, long id, long tripId) { + if (resources.release(kind, id, tripId) != 1) throw conflict("原资源占用不一致,未结束任务"); + } + private FleetTripEntity required(String tripNo, boolean lock) { + var trip = lock ? trips.lockByNo(tripNo) : trips.findByNo(tripNo); + if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在"); + return trip; + } + private Response response(FleetHandoverRepository.Row row) { + return new Response(row.handoverNo(), row.tripNo(), row.version(), read(row.snapshot(), Snapshot.class), + row.vehicleRelease() == null ? null : read(row.vehicleRelease(), Released.class), + row.driverRelease() == null ? null : read(row.driverRelease(), Released.class)); + } + private String write(Object value) { try { return json.writeValueAsString(value); } catch (JsonProcessingException error) { throw new IllegalStateException(error); } } + private T read(String value, Class type) { try { return json.readValue(value, type); } catch (JsonProcessingException error) { throw new IllegalStateException(error); } } + private String hash(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(value).getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); } + } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidence.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidence.java new file mode 100644 index 000000000..de5162f38 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidence.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.fleet.exception; + +import java.util.List; + +/** Internal port response. Identity always comes from the original customer dispatch. */ +public record FleetTransitEvidence(String dispatchNo, String customerId, String warehouseId, + int shippedPackages, List deliveries) { + public record Transport(String vehiclePlate, String driverName, String driverPhone) { } + public record Delivery(FleetHandoverPolicy.Delivery cargo, String status, long recordVersion, Transport transport) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/PlatformFleetHandoverController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/PlatformFleetHandoverController.java new file mode 100644 index 000000000..5a14d5c46 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/PlatformFleetHandoverController.java @@ -0,0 +1,44 @@ +package com.baiyue.lastleg.fleet.exception; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@Validated +@RequestMapping("/api/platform/v1/last-leg/fleet/trips/{tripNo}/handovers") +public class PlatformFleetHandoverController { + private final FleetHandoverService service; + public PlatformFleetHandoverController(FleetHandoverService service) { this.service = service; } + @GetMapping + @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse> page(@PathVariable String tripNo, @Valid FleetHandoverModels.Query query) { + return ApiResponse.success(service.page(tripNo, query)); + } + @PostMapping("/preview") + @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse preview(@PathVariable String tripNo, @Valid @RequestBody FleetHandoverModels.Selection request) { + return ApiResponse.success(service.preview(tripNo, request)); + } + @PostMapping + @PreAuthorize("hasAuthority('platform:fleet:trip:handover')") + public ApiResponse confirm(@PathVariable String tripNo, @AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody FleetHandoverModels.Confirm request) { return ApiResponse.success(service.confirm(tripNo, operator(user), request)); } + @PostMapping("/{handoverNo}/resources/release") + @PreAuthorize("hasAuthority('platform:fleet:trip:release')") + public ApiResponse release(@PathVariable String tripNo, @PathVariable String handoverNo, + @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody FleetHandoverModels.Release request) { + return ApiResponse.success(service.release(tripNo, handoverNo, operator(user), request)); + } + private static String operator(AuthenticatedUser user) { + if (user == null || user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "请重新登录"); + return user.userId().toString(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/HttpVehicleCareFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/HttpVehicleCareFileGateway.java new file mode 100644 index 000000000..ca75369ad --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/HttpVehicleCareFileGateway.java @@ -0,0 +1,71 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.*; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; +import java.io.IOException; +import java.util.Objects; +import java.util.Set; + +@Component +public class HttpVehicleCareFileGateway implements VehicleCareFileGateway { + private static final String TYPE = "FLEET_VEHICLE_RECORD_PHOTO"; + private static final String PATH = "/internal/file/v1/business-files/0/FLEET_VEHICLE_RECORD_PHOTO/{record}"; + private static final long MAX_SIZE = 10L * 1024 * 1024; + private final RestClient client; + private final String token; + public HttpVehicleCareFileGateway(RestClient.Builder builder, LastlegRestClientFactory factory, LastlegRestClientProperties properties, + @Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") String baseUrl, + @Value("${baiyue.security.internal-service-token:}") String token) { + client = factory.build(builder, baseUrl, properties.getCustomerVisitFile()); this.token = token; + } + @Override public VehicleCareModels.File upload(long recordId, String operatorId, MultipartFile file) { + if (file == null || file.isEmpty() || file.getSize() > MAX_SIZE || !image(file.getContentType())) throw bad("运维照片须为非空 JPG 或 PNG,最大 10MiB"); + var body = new MultipartBodyBuilder(); + try { body.part("file", new ByteArrayResource(file.getBytes()) { + @Override public String getFilename() { return file.getOriginalFilename(); } + }).contentType(MediaType.parseMediaType(file.getContentType())); } + catch (IOException error) { throw bad("无法读取运维照片"); } + var result = client.post().uri(PATH, recordId).header("X-Internal-Service-Token", token).header("X-Operator-Id", operatorId) + .contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve() + .onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); }).body(Envelope.class); + return verified(result, recordId, null); + } + @Override public VehicleCareModels.File metadata(long recordId, long fileId) { + return verified(client.get().uri(PATH + "/{file}", recordId, fileId).header("X-Internal-Service-Token", token).retrieve() + .onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); }).body(Envelope.class), recordId, fileId); + } + @Override public byte[] download(long recordId, long fileId) { + var response = client.get().uri(PATH + "/{file}/content", recordId, fileId).header("X-Internal-Service-Token", token).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { throw unavailable(result.getStatusCode()); }).toEntity(byte[].class); + return imageContent(response); + } + static byte[] imageContent(ResponseEntity response) { + byte[] content = response.getBody(); + if (content == null || content.length == 0 || content.length > MAX_SIZE + || !image(Objects.toString(response.getHeaders().getContentType(), ""))) throw unavailable(HttpStatus.SERVICE_UNAVAILABLE); + return content; + } + static VehicleCareModels.File verified(Envelope result, long recordId, Long fileId) { + if (result == null || !"200".equals(result.code())) throw unavailable(HttpStatus.SERVICE_UNAVAILABLE); + var data = result.data(); + if (data == null || data.fileId() == null || data.fileId() <= 0 || !Objects.equals(data.tenantId(), 0L) + || !TYPE.equals(data.businessType()) || !Long.toString(recordId).equals(data.businessId()) + || (fileId != null && !fileId.equals(data.fileId())) || data.fileName() == null || data.fileName().isBlank() + || !image(data.contentType()) || data.sizeBytes() == null || data.sizeBytes() <= 0 || data.sizeBytes() > MAX_SIZE) + throw bad("文件不属于当前运维记录或不是有效照片"); + return new VehicleCareModels.File(data.fileId().toString(), data.fileName(), data.contentType(), data.sizeBytes()); + } + private static boolean image(String type) { return Set.of("image/jpeg", "image/png").contains(Objects.toString(type, "")); } + private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + private static ResponseStatusException unavailable(HttpStatusCode status) { return new ResponseStatusException(status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, "运维照片不可用或归属校验失败"); } + record Envelope(String code, Metadata data) { } + record Metadata(Long fileId, Long tenantId, String businessType, String businessId, String fileName, String contentType, Long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/PlatformVehicleCareController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/PlatformVehicleCareController.java new file mode 100644 index 000000000..8e7d907c4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/PlatformVehicleCareController.java @@ -0,0 +1,69 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import com.baiyue.common.core.api.*; +import com.baiyue.common.security.AuthenticatedUser; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; +import java.nio.charset.StandardCharsets; + +@RestController +@Validated +@RequestMapping("/api/platform/v1/last-leg/fleet/vehicles/{vehicleId}/records") +public class PlatformVehicleCareController { + private final VehicleCareService service; + public PlatformVehicleCareController(VehicleCareService service) { this.service = service; } + @GetMapping + @PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:list')") + public ApiResponse> page(@PathVariable @Min(1) long vehicleId, @Valid VehicleCareModels.Query query) { + return ApiResponse.success(service.page(vehicleId, query)); + } + @GetMapping("/{recordNo}") + @PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:list')") + public ApiResponse detail(@PathVariable @Min(1) long vehicleId, @PathVariable String recordNo) { + return ApiResponse.success(service.detail(vehicleId, recordNo)); + } + @PostMapping + @PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:record-create')") + public ApiResponse create(@PathVariable @Min(1) long vehicleId, @AuthenticationPrincipal AuthenticatedUser user, + @RequestBody @Valid VehicleCareModels.Create request) { + return ApiResponse.success(service.create(vehicleId, operator(user), request)); + } + @PutMapping("/{recordNo}") + @PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:record-edit')") + public ApiResponse update(@PathVariable @Min(1) long vehicleId, @PathVariable String recordNo, + @AuthenticationPrincipal AuthenticatedUser user, @RequestBody @Valid VehicleCareModels.Update request) { + return ApiResponse.success(service.update(vehicleId, recordNo, operator(user), request)); + } + @PostMapping(value = "/{recordNo}/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:record-upload')") + public ApiResponse upload(@PathVariable @Min(1) long vehicleId, @PathVariable String recordNo, + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam @Min(0) long recordVersion, + @RequestParam @NotBlank @Size(max = 128) String idempotencyKey, @RequestPart("file") MultipartFile file) { + return ApiResponse.success(service.upload(vehicleId, recordNo, recordVersion, idempotencyKey, operator(user), file)); + } + @GetMapping("/{recordNo}/files/{fileId}") + @PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:list')") + public ResponseEntity download(@PathVariable @Min(1) long vehicleId, @PathVariable String recordNo, @PathVariable @Min(1) long fileId) { + var file = service.download(vehicleId, recordNo, fileId); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header("X-Content-Type-Options", "nosniff") + .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(file.metadata().fileName(), StandardCharsets.UTF_8).build().toString()) + .contentType(MediaType.parseMediaType(file.metadata().contentType())).body(file.content()); + } + @GetMapping("/{recordNo}/history") + @PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:list')") + public ApiResponse> history(@PathVariable @Min(1) long vehicleId, @PathVariable String recordNo, + @RequestParam(defaultValue = "1") @Min(1) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) { + return ApiResponse.success(service.history(vehicleId, recordNo, page, size)); + } + private static String operator(AuthenticatedUser user) { + if (user == null || user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "请重新登录"); + return user.userId().toString(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/PlatformVehicleCareFinanceController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/PlatformVehicleCareFinanceController.java new file mode 100644 index 000000000..389f2c663 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/PlatformVehicleCareFinanceController.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import java.util.List; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/fleet/vehicles/{vehicleId}/records") +public class PlatformVehicleCareFinanceController { + private final VehicleCareFinanceService service; + public PlatformVehicleCareFinanceController(VehicleCareFinanceService service) { this.service = service; } + @PostMapping("/finance-status") + @PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:list')") + public ApiResponse> statuses(@PathVariable long vehicleId, @Valid @RequestBody VehicleCareFinanceModels.Query request) { + return ApiResponse.success(service.statuses(vehicleId, request.recordNos())); + } + @PostMapping("/{recordNo}/finance") + @PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:record-finance-submit')") + public ApiResponse submit(@PathVariable long vehicleId, @PathVariable String recordNo, + @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody VehicleCareFinanceModels.Submit request) { + return ApiResponse.success(service.submit(vehicleId, recordNo, user.userId().toString(), request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareEntity.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareEntity.java new file mode 100644 index 000000000..4a9116dae --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareEntity.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("ll_fleet_vehicle_record") +public class VehicleCareEntity { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private String recordNo; + private Long vehicleId; + private String vehiclePlate; + private String recordType; + private LocalDateTime occurredAt; + private String detailsJson; + private String filesJson; + private String idempotencyKey; + private String requestHash; + private Long recordVersion; + private String createdBy; + private String updatedBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFileGateway.java new file mode 100644 index 000000000..d3987a7db --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFileGateway.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import org.springframework.web.multipart.MultipartFile; + +public interface VehicleCareFileGateway { + VehicleCareModels.File upload(long recordId, String operatorId, MultipartFile file); + VehicleCareModels.File metadata(long recordId, long fileId); + byte[] download(long recordId, long fileId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceClient.java new file mode 100644 index 000000000..a303b06ed --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceClient.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.*; +import java.util.List; + +@FeignClient(name="finance-service", contextId="vehicleCareFinance", url="${baiyue.clients.finance.url:}") +public interface VehicleCareFinanceClient { + @PostMapping("/internal/finance/v1/vehicle-costs/sources/query") + ApiResponse> query(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody VehicleCareFinanceModels.SourceQuery request); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceGateway.java new file mode 100644 index 000000000..b1684c94e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceGateway.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import com.baiyue.common.event.EventEnvelope; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +/** The fee slice owns currency conversion and the shared reliable outbox. */ +public interface VehicleCareFinanceGateway { + record Conversion(String originalCurrency, BigDecimal originalAmount, BigDecimal exchangeRate, + String exchangeRateMonth, BigDecimal settlementAmount) { } + Conversion convert(String currency, BigDecimal amount, LocalDate businessDate); + /** Must join the caller's transaction; failure must roll back the fleet source mapping too. */ + void publish(EventEnvelope event); + Map deliveryStates(List eventKeys); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceModels.java new file mode 100644 index 000000000..d5930ee6f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceModels.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +public final class VehicleCareFinanceModels { + private VehicleCareFinanceModels() { } + public record Submit(@NotNull @Min(0) Long recordVersion, @NotNull @Positive Long supplierId, + @NotNull LocalDate businessDate, @NotBlank @Size(min=8,max=128) String idempotencyKey) { } + public record Query(@NotEmpty @Size(max=100) List<@NotBlank @Size(max=64) String> recordNos) { } + public record Source(String sourceType, String sourceNo, long sourceVersion, String vehicleId, String vehiclePlate, + String tripNo, long supplierId, String feeName, LocalDate businessDate, String originalCurrency, BigDecimal originalAmount, + BigDecimal exchangeRate, String exchangeRateMonth, BigDecimal amount, String remark) { } + public record SourceKey(String sourceType, String sourceNo) { } + public record SourceQuery(List sources) { } + public record Cost(String costNo, String subjectCode, Source source, String supplierName, String currency, String status, + BigDecimal paidAmount, BigDecimal outstandingAmount, long recordVersion) { } + public record Status(String recordNo, String status, Long sourceVersion, Long submittedRecordVersion, Cost finance) { } + public record Submitted(String recordNo, long sourceVersion, long submittedRecordVersion, boolean replayed, String status) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceService.java new file mode 100644 index 000000000..76015dacb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceService.java @@ -0,0 +1,149 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.event.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.time.*; +import java.util.*; + +@Service +public class VehicleCareFinanceService { + private static final String EVENT_TYPE = EventTypes.COMPANY_VEHICLE_COST_SUBMITTED; + private final VehicleCareMapper records; + private final JdbcTemplate jdbc; + private final ObjectMapper json; + private final VehicleCareFinanceGateway accounting; + private final VehicleCareFinanceClient finance; + private final VehicleCareSupplierClient suppliers; + private final String token; + record Submission(long recordId, long sourceVersion, long recordVersion, String eventId, String requestHash, String payload) { } + public VehicleCareFinanceService(VehicleCareMapper records, JdbcTemplate jdbc, ObjectMapper json, VehicleCareFinanceGateway accounting, + VehicleCareFinanceClient finance, VehicleCareSupplierClient suppliers, + @Value("${baiyue.security.internal-service-token:}") String token) { + this.records = records; this.jdbc = jdbc; this.json = json; this.accounting = accounting; + this.finance = finance; this.suppliers = suppliers; this.token = token; + } + @Transactional(isolation=Isolation.READ_COMMITTED) + public VehicleCareFinanceModels.Submitted submit(long vehicleId, String number, String operator, VehicleCareFinanceModels.Submit command) { + var row = records.lock(vehicleId, number); requireCostRecord(row); + String hash = hash(command); + var prior = jdbc.query("SELECT * FROM ll_fleet_finance_source WHERE record_id=? AND operator_id=? AND request_key=?", + (r, i) -> submission(r), row.getId(), operator, command.idempotencyKey()); + if (!prior.isEmpty()) { + if (!hash.equals(prior.get(0).requestHash())) throw conflict("财务提交标识已用于其他内容"); + return new VehicleCareFinanceModels.Submitted(number, prior.get(0).sourceVersion(), prior.get(0).recordVersion(), true, "PENDING_FINANCE"); + } + if (!row.getRecordVersion().equals(command.recordVersion())) throw conflict("运维记录已变化,请重新核对后提交"); + var latest = latest(row.getId()); + var current = queryFinance(List.of(row)).get(number); // Fail closed if Finance cannot establish current facts. + if (latest != null) { + if (current == null || current.source().sourceVersion() != latest.sourceVersion()) throw conflict("上次提交尚未完成财务接收,请稍后核对"); + if (!"VOIDED".equals(current.status())) throw conflict("费用已提交,重新登记须先在财务作废;已付款费用不可改写"); + } else if (current != null) throw conflict("财务来源已有记录但本地提交关系不一致,请核对"); + var details = draft(row); var incident = details.incident(); + if (incident == null || incident.reportedCost() == null || incident.reportedCost().signum() <= 0) throw bad("请先保存正数申报费用;未填写或明确零费用不生成财务应付"); + requireSupplier(command.supplierId()); + var converted = accounting.convert(incident.currency(), incident.reportedCost(), command.businessDate()); + if (converted.settlementAmount().signum() <= 0 || converted.settlementAmount().precision() - converted.settlementAmount().scale() > 15) + throw bad("折算金额必须为正数且在财务支持范围内"); + var source = new VehicleCareFinanceModels.Source(row.getRecordType(), row.getRecordNo(), latest == null ? 1 : latest.sourceVersion() + 1, + row.getVehicleId().toString(), row.getVehiclePlate(), null, command.supplierId(), incident.item(), command.businessDate(), + converted.originalCurrency(), converted.originalAmount(), converted.exchangeRate(), converted.exchangeRateMonth(), converted.settlementAmount(), details.remark()); + if (source.feeName() == null || source.feeName().length() > 200) throw bad("提交财务的维修或救援项目须在200字以内"); + var payload = json.valueToTree(source); String eventId = UUID.randomUUID().toString(); + String eventKey = EVENT_TYPE + ':' + row.getRecordType() + ':' + number + ':' + source.sourceVersion(); + var envelope = new EventEnvelope(eventId, EVENT_TYPE, 1, Instant.now(), "0", "COMPANY_VEHICLE_COST", + row.getRecordType() + ':' + number, source.sourceVersion(), null, UUID.randomUUID().toString(), eventKey, null, + payload, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))).requireValid(); + jdbc.update("INSERT INTO ll_fleet_finance_source(id,record_id,source_version,record_version,operator_id,request_key,request_hash,event_id,payload_json,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)", + IdWorker.getId(), row.getId(), source.sourceVersion(), row.getRecordVersion(), operator, command.idempotencyKey(), hash, eventId, write(source), LocalDateTime.now(ZoneOffset.UTC)); + accounting.publish(envelope); + return new VehicleCareFinanceModels.Submitted(number, source.sourceVersion(), row.getRecordVersion(), false, "PENDING_FINANCE"); + } + public List statuses(long vehicleId, List numbers) { + if (numbers == null || numbers.isEmpty() || numbers.size() > 100 || numbers.stream().anyMatch(n -> n == null || n.isBlank())) throw bad("财务状态查询须提供1至100个有效记录号"); + var recordsByNo = records.findFinanceRecords(vehicleId, List.copyOf(new LinkedHashSet<>(numbers))); + if (recordsByNo.size() != new HashSet<>(numbers).size()) throw missing(); + var latestById = latest(recordsByNo.stream().map(VehicleCareEntity::getId).toList()); + var remote = latestById.isEmpty() ? Map.of() : queryFinance(recordsByNo.stream().filter(r -> latestById.containsKey(r.getId())).toList()); + var deliveryKeys = recordsByNo.stream().filter(r -> latestById.containsKey(r.getId())) + .map(r -> EVENT_TYPE + ':' + r.getRecordType() + ':' + r.getRecordNo() + ':' + latestById.get(r.getId()).sourceVersion()).toList(); + var deliveries = accounting.deliveryStates(deliveryKeys); + return recordsByNo.stream().map(row -> { + var local = latestById.get(row.getId()); if (local == null) return new VehicleCareFinanceModels.Status(row.getRecordNo(), "UNLINKED", null, null, null); + var cost = remote.get(row.getRecordNo()); + if (cost == null || cost.source().sourceVersion() < local.sourceVersion()) { + var delivery = deliveries.get(EVENT_TYPE + ':' + row.getRecordType() + ':' + row.getRecordNo() + ':' + local.sourceVersion()); + if (delivery == null) throw unavailable("财务投递关系缺失,状态尚未确认"); + return new VehicleCareFinanceModels.Status(row.getRecordNo(), "DEAD".equals(delivery) ? "FINANCE_ATTENTION" : "PENDING_FINANCE", local.sourceVersion(), local.recordVersion(), null); + } + if (cost.source().sourceVersion() != local.sourceVersion()) throw conflict("财务来源版本与本地提交不一致"); + if (!hash(cost.source()).equals(hash(read(local.payload(), VehicleCareFinanceModels.Source.class)))) throw conflict("财务来源内容与本地提交快照不一致"); + return new VehicleCareFinanceModels.Status(row.getRecordNo(), cost.status(), local.sourceVersion(), local.recordVersion(), cost); + }).toList(); + } + /** Called under the existing record lock; ordinary processing updates do not depend on Finance. */ + public void guardFinancialEdit(VehicleCareEntity row, VehicleCareModels.Draft next) { + if (!Set.of("REPAIR", "RESCUE").contains(row.getRecordType())) return; + var before = draft(row); + if (Objects.equals(before.incident().item(), next.incident().item()) + && Objects.equals(before.incident().currency(), next.incident().currency()) + && equalAmount(before.incident().reportedCost(), next.incident().reportedCost())) return; + var latest = latest(row.getId()); if (latest == null) return; + var remote = queryFinance(List.of(row)).get(row.getRecordNo()); + if (remote == null || remote.source().sourceVersion() != latest.sourceVersion()) throw conflict("财务接收状态未确认,暂不能更改费用字段"); + if (!"VOIDED".equals(remote.status())) throw conflict("已提交费用须先在财务作废,已付款费用禁止改写"); + } + private Map queryFinance(List rows) { + requireToken(); + try { + var keys = rows.stream().map(r -> new VehicleCareFinanceModels.SourceKey(r.getRecordType(), r.getRecordNo())).toList(); + var response = finance.query(token, new VehicleCareFinanceModels.SourceQuery(keys)); + if (response == null || !response.successful() || response.data() == null) throw unavailable("财务状态读取失败,未确认入账或付款"); + var result = new HashMap(); + for (var cost : response.data()) { + if (cost == null || cost.source() == null || !"PLATFORM_DEFAULT".equals(cost.subjectCode()) || !"CNY".equals(cost.currency()) + || !Set.of("PENDING","APPROVED","PARTIALLY_PAID","PAID","VOIDED").contains(Objects.toString(cost.status(), "")) + || !keys.contains(new VehicleCareFinanceModels.SourceKey(cost.source().sourceType(), cost.source().sourceNo())) + || rows.stream().noneMatch(r -> r.getRecordNo().equals(cost.source().sourceNo()) && r.getVehicleId().toString().equals(cost.source().vehicleId())) + || cost.paidAmount() == null || cost.paidAmount().signum() < 0 + || result.put(cost.source().sourceNo(), cost) != null) throw unavailable("财务返回的来源归属或状态无效"); + } + return result; + } catch (feign.FeignException error) { throw unavailable("Finance暂不可用,请稍后核对"); } + } + private void requireSupplier(long id) { + requireToken(); + try { var r = suppliers.get(token, id, true); + if (r == null || !r.successful() || r.data() == null || r.data().supplierId() != id || !"ACTIVE".equals(r.data().status())) throw bad("供应商不存在或未启用"); + } catch (feign.FeignException error) { throw unavailable("供应商主数据暂不可用"); } + } + private Submission latest(long recordId) { return latest(List.of(recordId)).get(recordId); } + private Map latest(List ids) { + if (ids.isEmpty()) return Map.of(); + var rows = jdbc.query("SELECT s.* FROM ll_fleet_finance_source s JOIN (SELECT record_id,MAX(source_version) source_version FROM ll_fleet_finance_source WHERE record_id IN (" + placeholders(ids.size()) + ") GROUP BY record_id) v ON v.record_id=s.record_id AND v.source_version=s.source_version", + (r, i) -> submission(r), ids.toArray()); + var map = new HashMap(); rows.forEach(r -> map.put(r.recordId(), r)); return map; + } + private static Submission submission(java.sql.ResultSet r) throws java.sql.SQLException { return new Submission(r.getLong("record_id"), r.getLong("source_version"), r.getLong("record_version"), r.getString("event_id"), r.getString("request_hash"), r.getString("payload_json")); } + private static String placeholders(int count) { return String.join(",", Collections.nCopies(count, "?")); } + private VehicleCareModels.Draft draft(VehicleCareEntity row) { return read(row.getDetailsJson(), VehicleCareModels.Draft.class); } + private static boolean equalAmount(BigDecimal a, BigDecimal b) { return a == null ? b == null : b != null && a.compareTo(b) == 0; } + private String hash(Object value) { return EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(json.valueToTree(value))); } + private String write(Object value) { try { return json.writeValueAsString(value); } catch (Exception e) { throw new IllegalStateException("车辆财务来源无法保存", e); } } + private T read(String value, Class type) { try { return json.readValue(value, type); } catch (Exception e) { throw new IllegalStateException("车辆财务来源无法读取", e); } } + private static void requireCostRecord(VehicleCareEntity row) { if (row == null || !Set.of("REPAIR","RESCUE").contains(row.getRecordType())) throw missing(); } + private void requireToken() { if (token == null || token.isBlank()) throw unavailable("内部服务身份未配置"); } + private static ResponseStatusException bad(String text) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, text); } + private static ResponseStatusException conflict(String text) { return new ResponseStatusException(HttpStatus.CONFLICT, text); } + private static ResponseStatusException unavailable(String text) { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, text); } + private static ResponseStatusException missing() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "当前车辆维修救援记录不存在"); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareHistory.java new file mode 100644 index 000000000..0072fd23c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareHistory.java @@ -0,0 +1,18 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("ll_fleet_vehicle_record_history") +public class VehicleCareHistory { + @TableId(type = IdType.ASSIGN_ID) private Long id; + private Long recordId; + private Long recordVersion; + private String action; + private String reason; + private String operatorId; + private LocalDateTime occurredAt; + private String snapshotJson; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareHistoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareHistoryMapper.java new file mode 100644 index 000000000..ad48ace9a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareHistoryMapper.java @@ -0,0 +1,7 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface VehicleCareHistoryMapper extends BaseMapper { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareMapper.java new file mode 100644 index 000000000..57e08c11a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareMapper.java @@ -0,0 +1,18 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.*; + +@Mapper +public interface VehicleCareMapper extends BaseMapper { + @Select({""}) + java.util.List findFinanceRecords(@Param("vehicleId") long vehicleId, @Param("numbers") java.util.List numbers); + @Select("SELECT * FROM ll_fleet_vehicle_record WHERE vehicle_id=#{vehicleId} AND record_no=#{recordNo}") + VehicleCareEntity find(@Param("vehicleId") long vehicleId, @Param("recordNo") String recordNo); + @Select("SELECT * FROM ll_fleet_vehicle_record WHERE vehicle_id=#{vehicleId} AND record_no=#{recordNo} FOR UPDATE") + VehicleCareEntity lock(@Param("vehicleId") long vehicleId, @Param("recordNo") String recordNo); + @Select("SELECT * FROM ll_fleet_vehicle_record WHERE vehicle_id=#{vehicleId} AND created_by=#{operator} AND idempotency_key=#{key} FOR UPDATE") + VehicleCareEntity lockCreated(@Param("vehicleId") long vehicleId, @Param("operator") String operator, @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareModels.java new file mode 100644 index 000000000..68dbdeb93 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareModels.java @@ -0,0 +1,42 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +public final class VehicleCareModels { + private VehicleCareModels() { } + public record Query(@Min(1) Integer page, @Min(1) @Max(100) Integer size, + @Pattern(regexp = "MAINTENANCE|FUEL|REPAIR|BREAKDOWN|RESCUE|EXCEPTION") String recordType) { + public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; } + } + public record Draft(@NotBlank @Pattern(regexp = "MAINTENANCE|FUEL|REPAIR|BREAKDOWN|RESCUE|EXCEPTION") String recordType, + @NotNull Instant occurredAt, + @Pattern(regexp = "KM|MI") String distanceUnit, + @DecimalMin("0") @Digits(integer = 10, fraction = 3) BigDecimal odometer, + @DecimalMin("0") @Digits(integer = 10, fraction = 3) BigDecimal lastServiceOdometer, + @DecimalMin("0") @Digits(integer = 10, fraction = 3) BigDecimal nextServiceOdometer, + @DecimalMin(value = "0", inclusive = false) @Digits(integer = 8, fraction = 3) BigDecimal fuelQuantity, + @Pattern(regexp = "L|US_GAL|IMP_GAL") String fuelUnit, + @Pattern(regexp = "PENDING|VERIFIED") String verificationStatus, + @Size(max = 1000) String remark, @Valid Incident incident) { } + public record Incident(@NotBlank @Size(max = 2000) String description, + @Size(max = 256) String item, @Size(max = 512) String place, + @Size(max = 128) String handler, + @NotBlank @Pattern(regexp = "PENDING|IN_PROGRESS|COMPLETED") String processingStatus, + @Pattern(regexp = "ROUTE|TIMEOUT|DELIVERY|VEHICLE_BREAKDOWN") String exceptionType, + @DecimalMin("0") @Digits(integer = 12, fraction = 2) BigDecimal reportedCost, + @Pattern(regexp = "[A-Z]{3}") String currency) { } + public record Create(@NotBlank @Size(max = 128) String idempotencyKey, @NotNull @Valid Draft details) { } + public record Update(@NotNull @Min(0) Long recordVersion, @NotBlank @Size(max = 1000) String reason, + @NotNull @Valid Draft details) { } + public record File(String fileId, String fileName, String contentType, long sizeBytes) { } + public record Attached(String uploadKey, String sha256, File file) { } + public record Response(String recordNo, String vehicleId, String vehiclePlate, Draft details, + List files, long recordVersion, String updatedBy, Instant updatedAt, String paymentStatus) { } + public record History(long recordVersion, String action, String reason, String operatorId, + Instant occurredAt, Response snapshot) { } + public record Download(File metadata, byte[] content) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareService.java new file mode 100644 index 000000000..93ec5c040 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareService.java @@ -0,0 +1,183 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.time.*; +import java.util.*; + +@Service +public class VehicleCareService { + private final VehicleCareMapper records; + private final VehicleCareHistoryMapper histories; + private final VehicleRepository vehicles; + private final VehicleCareFileGateway files; + private final ObjectMapper json; + private final VehicleCareFinanceService finance; + public VehicleCareService(VehicleCareMapper records, VehicleCareHistoryMapper histories, + VehicleRepository vehicles, VehicleCareFileGateway files, ObjectMapper json, VehicleCareFinanceService finance) { + this.records = records; this.histories = histories; this.vehicles = vehicles; this.files = files; this.json = json; this.finance = finance; + } + public PageResult page(long vehicleId, VehicleCareModels.Query query) { + vehicles.findById(vehicleId).orElseThrow(VehicleCareService::missing); + var page = records.selectPage(new Page<>(query.page(), query.size()), new LambdaQueryWrapper() + .eq(VehicleCareEntity::getVehicleId, vehicleId) + .eq(query.recordType() != null, VehicleCareEntity::getRecordType, query.recordType()) + .orderByDesc(VehicleCareEntity::getOccurredAt, VehicleCareEntity::getId)); + return new PageResult<>(page.getRecords().stream().map(this::response).toList(), page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); + } + public VehicleCareModels.Response detail(long vehicleId, String number) { return response(required(vehicleId, number, false)); } + @Transactional + public VehicleCareModels.Response create(long vehicleId, String operator, VehicleCareModels.Create request) { + // Serialize creation per vehicle; no empty-range locks or duplicate creation after a lost response. + var vehicle = vehicles.lockById(vehicleId).orElseThrow(VehicleCareService::missing); + String fingerprint = digest(write(request.details()).getBytes(StandardCharsets.UTF_8)); + var prior = records.lockCreated(vehicleId, operator, request.idempotencyKey()); + if (prior != null) { + if (!prior.getRequestHash().equals(fingerprint)) throw conflict("请求标识已用于其他运维记录内容"); + return response(prior); + } + validate(request.details()); + var row = new VehicleCareEntity(); row.setId(IdWorker.getId()); + row.setRecordNo("VR-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(Locale.ROOT)); + row.setVehicleId(vehicleId); row.setVehiclePlate(vehicle.plateNumber()); row.setRecordType(request.details().recordType()); + row.setOccurredAt(utc(request.details().occurredAt())); row.setDetailsJson(write(request.details())); row.setFilesJson("[]"); + row.setIdempotencyKey(request.idempotencyKey()); row.setRequestHash(fingerprint); row.setRecordVersion(0L); + row.setCreatedBy(operator); row.setUpdatedBy(operator); row.setCreatedAt(utc(Instant.now())); row.setUpdatedAt(row.getCreatedAt()); + if (records.insert(row) != 1) throw conflict("运维记录保存失败"); + record(row, "CREATE", null, operator); + return response(row); + } + @Transactional + public VehicleCareModels.Response update(long vehicleId, String number, String operator, VehicleCareModels.Update request) { + var row = required(vehicleId, number, true); version(row, request.recordVersion()); validate(request.details()); + if (!row.getRecordType().equals(request.details().recordType())) throw conflict("已保存记录不能改变运维类型"); + finance.guardFinancialEdit(row, request.details()); + if (request.reason() == null || request.reason().isBlank()) throw bad("请填写更正原因"); + row.setOccurredAt(utc(request.details().occurredAt())); row.setDetailsJson(write(request.details())); + persist(row, request.recordVersion(), "UPDATE", request.reason(), operator); + return response(row); + } + @Transactional + public VehicleCareModels.Response upload(long vehicleId, String number, long expectedVersion, String uploadKey, String operator, MultipartFile file) { + var row = required(vehicleId, number, true); + if (uploadKey == null || uploadKey.isBlank() || uploadKey.length() > 128) throw bad("附件请求标识无效"); + if (file == null || file.isEmpty() || file.getSize() > 10L * 1024 * 1024 + || !Set.of("image/jpeg", "image/png").contains(Objects.toString(file.getContentType(), ""))) throw bad("请选择非空 JPG 或 PNG 图片,最大 10MiB"); + String fingerprint; + try { fingerprint = digest(file.getBytes()); } catch (IOException error) { throw bad("无法读取附件"); } + var attached = new ArrayList<>(attached(row)); + var replay = attached.stream().filter(item -> item.uploadKey().equals(uploadKey)).findFirst(); + if (replay.isPresent()) { + if (!replay.get().sha256().equals(fingerprint)) throw conflict("附件请求标识已用于不同图片"); + return response(row); + } + version(row, expectedVersion); + if (attached.size() >= 20) throw bad("每条记录最多保存 20 张图片"); + var uploaded = files.upload(row.getId(), operator, file); + attached.add(new VehicleCareModels.Attached(uploadKey, fingerprint, uploaded)); row.setFilesJson(write(attached)); + persist(row, expectedVersion, "UPLOAD", uploaded.fileName(), operator); + return response(row); + } + public VehicleCareModels.Download download(long vehicleId, String number, long fileId) { + var row = required(vehicleId, number, false); + if (attached(row).stream().noneMatch(item -> item.file().fileId().equals(Long.toString(fileId)))) throw missing(); + var metadata = files.metadata(row.getId(), fileId); + return new VehicleCareModels.Download(metadata, files.download(row.getId(), fileId)); + } + public PageResult history(long vehicleId, String number, int page, int size) { + var row = required(vehicleId, number, false); + var result = histories.selectPage(new Page<>(page, size), new LambdaQueryWrapper() + .eq(VehicleCareHistory::getRecordId, row.getId()).orderByDesc(VehicleCareHistory::getRecordVersion)); + return new PageResult<>(result.getRecords().stream().map(entry -> new VehicleCareModels.History(entry.getRecordVersion(), + entry.getAction(), entry.getReason(), entry.getOperatorId(), entry.getOccurredAt().toInstant(ZoneOffset.UTC), + read(entry.getSnapshotJson(), VehicleCareModels.Response.class))).toList(), result.getCurrent(), result.getSize(), result.getTotal(), result.getPages()); + } + private void persist(VehicleCareEntity row, long expected, String action, String reason, String operator) { + row.setRecordVersion(expected + 1); row.setUpdatedBy(operator); row.setUpdatedAt(utc(Instant.now())); + if (records.update(row, new LambdaUpdateWrapper().eq(VehicleCareEntity::getId, row.getId()) + .eq(VehicleCareEntity::getRecordVersion, expected)) != 1) throw conflict("记录已变化,请刷新后重试"); + record(row, action, reason, operator); + } + private void record(VehicleCareEntity row, String action, String reason, String operator) { + var history = new VehicleCareHistory(); history.setId(IdWorker.getId()); history.setRecordId(row.getId()); + history.setRecordVersion(row.getRecordVersion()); history.setAction(action); history.setReason(reason); + history.setOperatorId(operator); history.setOccurredAt(row.getUpdatedAt()); history.setSnapshotJson(write(response(row))); + if (histories.insert(history) != 1) throw conflict("记录历史保存失败"); + } + private VehicleCareModels.Response response(VehicleCareEntity row) { + return new VehicleCareModels.Response(row.getRecordNo(), row.getVehicleId().toString(), row.getVehiclePlate(), + read(row.getDetailsJson(), VehicleCareModels.Draft.class), attached(row).stream().map(VehicleCareModels.Attached::file).toList(), + row.getRecordVersion(), row.getUpdatedBy(), row.getUpdatedAt().toInstant(ZoneOffset.UTC), + null); // Current financial facts are queried in a separate bounded batch, never frozen into operational history. + } + private VehicleCareEntity required(long vehicleId, String number, boolean lock) { + var row = lock ? records.lock(vehicleId, number) : records.find(vehicleId, number); + if (row == null) throw missing(); return row; + } + private static void version(VehicleCareEntity row, long expected) { if (row.getRecordVersion() != expected) throw conflict("记录已变化,请刷新后重试"); } + static void validate(VehicleCareModels.Draft value) { + if (value.occurredAt() == null || value.occurredAt().isAfter(Instant.now())) throw bad("记录时间不能晚于当前时间"); + if (value.occurredAt().atOffset(ZoneOffset.UTC).getYear() < 1900) throw bad("记录时间须为 1900 年及以后"); + if (Set.of("REPAIR", "BREAKDOWN", "RESCUE", "EXCEPTION").contains(Objects.toString(value.recordType(), ""))) { + validateIncident(value); return; + } + if (value.incident() != null) throw bad("保养加油不能混入维修、故障、救援或异常字段"); + if (value.odometer() == null || value.odometer().signum() < 0 || !Set.of("KM", "MI").contains(Objects.toString(value.distanceUnit(), ""))) throw bad("请填写有效里程及单位"); + if (!Set.of("PENDING", "VERIFIED").contains(Objects.toString(value.verificationStatus(), ""))) throw bad("核验状态无效"); + if ("MAINTENANCE".equals(value.recordType())) { + if (value.lastServiceOdometer() == null || value.nextServiceOdometer() == null || value.lastServiceOdometer().signum() < 0 + || value.lastServiceOdometer().compareTo(value.odometer()) > 0 || value.nextServiceOdometer().compareTo(value.lastServiceOdometer()) <= 0) + throw bad("上次保养里程须不大于记录时里程,下次保养里程须大于上次保养里程"); + if (value.fuelQuantity() != null || value.fuelUnit() != null) throw bad("保养记录不能包含加油字段"); + } else if ("FUEL".equals(value.recordType())) { + if (value.fuelQuantity() == null || value.fuelQuantity().signum() <= 0 || value.fuelUnit() == null + || !Set.of("L", "US_GAL", "IMP_GAL").contains(value.fuelUnit())) throw bad("请填写正数加油量并明确升、美制加仑或英制加仑"); + if (value.lastServiceOdometer() != null || value.nextServiceOdometer() != null) throw bad("加油记录不能包含保养里程字段"); + } else throw bad("记录类型无效"); + } + private static void validateIncident(VehicleCareModels.Draft value) { + if (value.distanceUnit() != null || value.odometer() != null || value.lastServiceOdometer() != null || value.nextServiceOdometer() != null + || value.fuelQuantity() != null || value.fuelUnit() != null || value.verificationStatus() != null) throw bad("处理记录不能混入保养、加油或核验字段"); + var incident = value.incident(); + if (incident == null || blank(incident.description())) throw bad("请填写事件描述"); + if (!Set.of("PENDING", "IN_PROGRESS", "COMPLETED").contains(Objects.toString(incident.processingStatus(), ""))) throw bad("处理状态无效"); + if (!"PENDING".equals(incident.processingStatus()) && blank(incident.handler())) throw bad("处理中或已完成记录须填写处理人"); + boolean costRecord = Set.of("REPAIR", "RESCUE").contains(value.recordType()); + if (costRecord && (blank(incident.item()) || blank(incident.place()))) throw bad("请填写维修或救援项目及地点"); + if (!costRecord && (incident.item() != null || incident.place() != null || incident.reportedCost() != null || incident.currency() != null)) + throw bad("故障和异常记录不接受维修救援费用字段"); + if ("EXCEPTION".equals(value.recordType())) { + if (!Set.of("ROUTE", "TIMEOUT", "DELIVERY", "VEHICLE_BREAKDOWN").contains(Objects.toString(incident.exceptionType(), ""))) throw bad("请选择异常类型"); + } else if (incident.exceptionType() != null) throw bad("非异常记录不能设置异常类型"); + if (incident.reportedCost() != null) { + if (incident.reportedCost().signum() < 0 || blank(incident.currency())) throw bad("费用须为非负数并填写币种"); + try { Currency.getInstance(incident.currency()); } catch (IllegalArgumentException error) { throw bad("费用币种无效"); } + } else if (incident.currency() != null) throw bad("未登记费用时不能填写币种"); + } + private static boolean blank(String value) { return value == null || value.isBlank(); } + private List attached(VehicleCareEntity row) { + try { return json.readValue(row.getFilesJson(), new TypeReference<>() { }); } catch (JsonProcessingException error) { throw new IllegalStateException(error); } + } + private String write(Object value) { try { return json.writeValueAsString(value); } catch (JsonProcessingException e) { throw new IllegalStateException(e); } } + private T read(String value, Class type) { try { return json.readValue(value, type); } catch (JsonProcessingException e) { throw new IllegalStateException(e); } } + private static String digest(byte[] value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value)); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } } + private static LocalDateTime utc(Instant instant) { return LocalDateTime.ofInstant(instant, ZoneOffset.UTC); } + private static ResponseStatusException missing() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "车辆或当前车辆的记录不存在"); } + private static ResponseStatusException bad(String reason) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, reason); } + private static ResponseStatusException conflict(String reason) { return new ResponseStatusException(HttpStatus.CONFLICT, reason); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareSupplierClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareSupplierClient.java new file mode 100644 index 000000000..c6c7f7734 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareSupplierClient.java @@ -0,0 +1,13 @@ +package com.baiyue.lastleg.fleet.maintenance; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.*; + +@FeignClient(name="warehouse-service", contextId="vehicleCareSupplier", url="${baiyue.clients.warehouse.url:}") +public interface VehicleCareSupplierClient { + @GetMapping("/internal/warehouse/v1/suppliers/{supplierId}") + ApiResponse get(@RequestHeader("X-Internal-Service-Token") String token, @PathVariable long supplierId, + @RequestParam boolean requireActive); + record Supplier(long supplierId, String name, String category, String status) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateModels.java new file mode 100644 index 000000000..4f3efa6c9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateModels.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.fleet.quote; + +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.LocalDate; + +public final class FleetQuoteCandidateModels { + private FleetQuoteCandidateModels() { } + /** The booking owner resolves this from its actual customer, route, currency and agreed service date. */ + public record Context(@NotNull @Positive Long customerId, + @NotBlank @Size(max=128) String originCity, @NotBlank @Size(max=64) String destinationCode, + @NotBlank @Pattern(regexp="CNY|USD") String currency, @NotNull LocalDate effectiveOn, + @NotBlank @Pattern(regexp="ORDER|PALLET") String priceBasis) { } + public record Query(@NotNull @Positive Long customerId, + @NotBlank @Size(max=128) String originCity, @NotBlank @Size(max=64) String destinationCode, + @NotBlank @Pattern(regexp="CNY|USD") String currency, @NotNull LocalDate effectiveOn, + @NotBlank @Pattern(regexp="ORDER|PALLET") String priceBasis, + @Min(1) Integer page,@Min(1) @Max(100) Integer size) { + public Query { page=page==null?1:page;size=size==null?20:size; } + public Context context(){return new Context(customerId,originCity,destinationCode,currency,effectiveOn,priceBasis);} + } + public record QuoteRef(@NotBlank @Size(max=64) String quoteNo,@NotNull @Min(0) Long recordVersion, + @NotBlank @Pattern(regexp="FULL_DELIVERY|LOOSE_DELIVERY") String priceItem) { } + /** Quoted price is a source fact, distinct from the booking owner's negotiated price/quantity/amount. */ + public record SourceSnapshot(String quoteNo,long recordVersion,String priceItem,String customerId,String customerName, + String originCity,String destinationCode,BigDecimal distanceMiles,String currency,String priceBasis, + BigDecimal quotedUnitPrice,LocalDate validFrom,LocalDate validTo,LocalDate effectiveOn) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateRepository.java new file mode 100644 index 000000000..48feedf7a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateRepository.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.fleet.quote; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** Read-only projection of the existing quote library; no second quote persistence model or writes. */ +@Repository +public class FleetQuoteCandidateRepository { + record QuoteRow(String quoteNo,String partyName,String status,long version,String details) { } + private static final RowMapper ROW=(r,i)->new QuoteRow(r.getString("quote_no"),r.getString("party_name"),r.getString("status"),r.getLong("record_version"),r.getString("details_json")); + private final JdbcTemplate jdbc; + public FleetQuoteCandidateRepository(JdbcTemplate jdbc){this.jdbc=jdbc;} + List candidates(FleetQuoteCandidateModels.Context context) { + return jdbc.query(""" + SELECT quote_no,party_name,status,record_version,details_json FROM ll_fleet_quote + WHERE kind='SALE' AND status='ACTIVE' AND party_id=? AND LOWER(TRIM(origin_city))=? + AND destination_code=? AND valid_from<=? AND valid_to>=? + ORDER BY valid_from DESC,quote_no DESC LIMIT 5001 + """,ROW,context.customerId(),context.originCity(),context.destinationCode(),context.effectiveOn(),context.effectiveOn()); + } + QuoteRow lock(String quoteNo) { + return jdbc.query("SELECT quote_no,party_name,status,record_version,details_json FROM ll_fleet_quote WHERE quote_no=? FOR UPDATE",ROW,quoteNo).stream().findFirst().orElse(null); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateService.java new file mode 100644 index 000000000..b0a31a79b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateService.java @@ -0,0 +1,70 @@ +package com.baiyue.lastleg.fleet.quote; + +import com.baiyue.common.core.api.PageResult; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.util.*; + +@Service +public class FleetQuoteCandidateService { + private final FleetQuoteCandidateRepository repository; + private final FleetQuoteDirectory directory; + private final ObjectMapper json; + public FleetQuoteCandidateService(FleetQuoteCandidateRepository repository,FleetQuoteDirectory directory,ObjectMapper json){this.repository=repository;this.directory=directory;this.json=json;} + + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public PageResult candidates(FleetQuoteCandidateModels.Query query) { + var context=normalize(query.context());directory.name("SALE",context.customerId()); + var rows=repository.candidates(context); + if(rows.size()>5000)throw error(HttpStatus.BAD_REQUEST,"当前路线有效报价过多,请先整理报价库"); + var candidates=new ArrayList(); + for(var row:rows) { + var draft=read(row.details()); + if(matches(row,draft,context) && price(draft,context.priceBasis())!=null)candidates.add(snapshot(row,draft,context)); + } + long total=candidates.size();int start=(int)Math.min(total,((long)query.page()-1)*query.size()); + return new PageResult<>(List.copyOf(candidates.subList(start,Math.min(candidates.size(),start+query.size()))),query.page(),query.size(),total,(total+query.size()-1)/query.size()); + } + + /** Called only inside the booking owner's write transaction, after its permission and scope checks. + * The quote row lock lasts until that transaction commits; caller persists this returned snapshot. + * No public write endpoint, no booking writes and no negotiated-price calculation live here. + */ + @Transactional(propagation=Propagation.MANDATORY) + public FleetQuoteCandidateModels.SourceSnapshot lockAndResolve(FleetQuoteCandidateModels.Context requested,FleetQuoteCandidateModels.QuoteRef reference) { + var context=normalize(requested); + if(reference==null || reference.quoteNo()==null || reference.quoteNo().isBlank() || reference.recordVersion()==null || reference.recordVersion()<0 + || !item(context.priceBasis()).equals(reference.priceItem()))throw error(HttpStatus.BAD_REQUEST,"报价引用或计价项目无效"); + var row=repository.lock(reference.quoteNo()); + if(row==null)throw error(HttpStatus.NOT_FOUND,"报价不存在"); + var draft=read(row.details()); + if(row.version()!=reference.recordVersion() || !matches(row,draft,context))throw error(HttpStatus.CONFLICT,"报价版本、客户、路线、币种或有效期已不匹配,请重新选择"); + if(price(draft,context.priceBasis())==null)throw error(HttpStatus.CONFLICT,"所选项目尚未报价,不能作为零价使用"); + directory.name("SALE",context.customerId()); + return snapshot(row,draft,context); + } + private static FleetQuoteCandidateModels.Context normalize(FleetQuoteCandidateModels.Context context) { + if(context==null || context.customerId()==null || context.customerId()<=0 || context.originCity()==null || context.originCity().isBlank() + || context.destinationCode()==null || context.destinationCode().isBlank() || context.effectiveOn()==null + || !Set.of("USD","CNY").contains(Objects.toString(context.currency(),"")) || !Set.of("ORDER","PALLET").contains(Objects.toString(context.priceBasis(),""))) + throw error(HttpStatus.BAD_REQUEST,"请选择明确客户、路线、币种、服务日期及计价单位"); + return new FleetQuoteCandidateModels.Context(context.customerId(),context.originCity().trim().toLowerCase(Locale.ROOT),context.destinationCode().trim().toUpperCase(Locale.ROOT),context.currency(),context.effectiveOn(),context.priceBasis()); + } + private static boolean matches(FleetQuoteCandidateRepository.QuoteRow row,FleetQuoteModels.Draft d,FleetQuoteCandidateModels.Context c) { + return "ACTIVE".equals(row.status()) && "SALE".equals(d.kind()) && c.customerId().equals(d.partyId()) + && c.originCity().equals(d.originCity().trim().toLowerCase(Locale.ROOT)) && c.destinationCode().equals(d.destinationCode().trim().toUpperCase(Locale.ROOT)) + && c.currency().equals(d.currency()) && !c.effectiveOn().isBefore(d.validFrom()) && !c.effectiveOn().isAfter(d.validTo()); + } + private static String item(String basis){return "PALLET".equals(basis)?"LOOSE_DELIVERY":"FULL_DELIVERY";} + private static BigDecimal price(FleetQuoteModels.Draft d,String basis){return "PALLET".equals(basis)?d.looseDeliveryPrice():d.fullDeliveryPrice();} + private static FleetQuoteCandidateModels.SourceSnapshot snapshot(FleetQuoteCandidateRepository.QuoteRow row,FleetQuoteModels.Draft d,FleetQuoteCandidateModels.Context c) { + return new FleetQuoteCandidateModels.SourceSnapshot(row.quoteNo(),row.version(),item(c.priceBasis()),d.partyId().toString(),row.partyName(),d.originCity(),d.destinationCode(),d.distanceMiles(),d.currency(),c.priceBasis(),price(d,c.priceBasis()),d.validFrom(),d.validTo(),c.effectiveOn()); + } + private FleetQuoteModels.Draft read(String value){try{return json.readValue(value,FleetQuoteModels.Draft.class);}catch(JsonProcessingException e){throw new IllegalStateException("报价来源快照无法读取",e);}} + private static ResponseStatusException error(HttpStatus status,String message){return new ResponseStatusException(status,message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteDirectory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteDirectory.java new file mode 100644 index 000000000..174978e3a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteDirectory.java @@ -0,0 +1,29 @@ +package com.baiyue.lastleg.fleet.quote; + +import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; + +@Component +public class FleetQuoteDirectory { + private final FleetCustomerDirectory customers; + private final FleetQuoteSupplierClient suppliers; + private final String token; + public FleetQuoteDirectory(FleetCustomerDirectory customers,FleetQuoteSupplierClient suppliers,@Value("${baiyue.security.internal-service-token}") String token) { + this.customers=customers;this.suppliers=suppliers;this.token=token; + } + public String name(String kind,long id) { + if("SALE".equals(kind)) return customers.required(id).name(); + try { + var result=suppliers.get(token,id,true,"TRUCKING"); + if(result==null || !result.successful() || result.data()==null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"供应商目录暂不可用"); + var row=result.data(); + if(row.supplierId()!=id || !"ACTIVE".equals(row.status()) || !"TRUCKING".equals(row.category()) || !"OVERSEAS".equals(row.truckingScope())) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请选择有效的海外车队供应商"); + return row.name(); + } catch(ResponseStatusException e) { throw e; } + catch(RuntimeException e) { throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"供应商目录暂不可用,请重试"); } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteModels.java new file mode 100644 index 000000000..222ae6e60 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteModels.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.fleet.quote; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.*; +import java.util.List; + +public final class FleetQuoteModels { + private FleetQuoteModels() { } + public record Draft(@NotBlank @Pattern(regexp="PURCHASE|SALE") String kind, @NotNull @Positive Long partyId, + @NotBlank @Size(max=128) String originCity, @NotBlank @Size(max=64) String destinationCode, + @NotNull @DecimalMin("0") @Digits(integer=7,fraction=1) BigDecimal distanceMiles, + @NotBlank @Pattern(regexp="USD|CNY") String currency, + @DecimalMin("0") @Digits(integer=10,fraction=2) BigDecimal selfPickupPrice, + @DecimalMin("0") @Digits(integer=10,fraction=2) BigDecimal fullDeliveryPrice, + @DecimalMin("0") @Digits(integer=10,fraction=2) BigDecimal looseDeliveryPrice, + @NotNull LocalDate validFrom, @NotNull LocalDate validTo, + @Size(max=1000) String remark) { } + public record Create(@NotBlank @Size(min=8,max=128) String idempotencyKey, @NotNull @Valid Draft details) { } + public record Update(@NotNull @Min(0) Long recordVersion, @NotNull @Valid Draft details) { } + public record Status(@NotNull @Min(0) Long recordVersion, @NotBlank @Pattern(regexp="ACTIVE|INACTIVE") String status) { } + public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size, + @Pattern(regexp="PURCHASE|SALE") String kind, @Size(max=128) String keyword, + @Pattern(regexp="ACTIVE|INACTIVE") String status, LocalDate effectiveOn) { + public Query { page=page==null?1:page; size=size==null?20:size; } + } + public record Quote(String quoteNo, Draft details, String partyName, String status, long recordVersion, Instant updatedAt) { } + public record History(long recordVersion, String action, Instant occurredAt, Quote snapshot) { } + public record Import(@NotBlank @Size(min=8,max=100) String idempotencyKey, @NotEmpty @Size(max=200) List<@Valid @NotNull Draft> rows) { } + public record ImportResult(int importedRows, List records) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteRepository.java new file mode 100644 index 000000000..181ada62e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteRepository.java @@ -0,0 +1,39 @@ +package com.baiyue.lastleg.fleet.quote; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.*; + +@Repository +public class FleetQuoteRepository { + record Row(long id,String quoteNo,String details,String partyName,String status,long version,String createHash,LocalDateTime updatedAt) { } + record Audit(long version,String action,LocalDateTime occurredAt,String snapshot) { } + private final JdbcTemplate jdbc; + public FleetQuoteRepository(JdbcTemplate jdbc) { this.jdbc=jdbc; } + private static final RowMapper MAPPER=(r,i)->new Row(r.getLong("id"),r.getString("quote_no"),r.getString("details_json"),r.getString("party_name"),r.getString("status"),r.getLong("record_version"),r.getString("create_hash"),r.getTimestamp("updated_at").toLocalDateTime()); + Row find(String no,boolean lock) { return jdbc.query("SELECT * FROM ll_fleet_quote WHERE quote_no=?"+(lock?" FOR UPDATE":""),MAPPER,no).stream().findFirst().orElse(null); } + Row replay(String operator,String key) { return jdbc.query("SELECT * FROM ll_fleet_quote WHERE created_by=? AND create_key=?",MAPPER,operator,key).stream().findFirst().orElse(null); } + String creationSnapshot(long id) { return jdbc.queryForObject("SELECT snapshot_json FROM ll_fleet_quote_history WHERE quote_id=? AND record_version=0",String.class,id); } + List list(FleetQuoteModels.Query q) { + StringBuilder sql=new StringBuilder("SELECT * FROM ll_fleet_quote WHERE status<>'DELETED'"); var args=new ArrayList(); + if(q.kind()!=null&&!q.kind().isBlank()) {sql.append(" AND kind=?");args.add(q.kind());} + if(q.status()!=null&&!q.status().isBlank()) {sql.append(" AND status=?");args.add(q.status());} + if(q.effectiveOn()!=null) {sql.append(" AND valid_from<=? AND valid_to>=? AND status='ACTIVE'");args.add(q.effectiveOn());args.add(q.effectiveOn());} + if(q.keyword()!=null&&!q.keyword().isBlank()) {sql.append(" AND (origin_city LIKE ? OR destination_code LIKE ? OR party_name LIKE ? OR quote_no LIKE ?)");for(int i=0;i<4;i++) args.add("%"+q.keyword().trim()+"%");} + sql.append(" ORDER BY updated_at DESC,id DESC LIMIT 5001"); return jdbc.query(sql.toString(),MAPPER,args.toArray()); + } + void insert(Row r,FleetQuoteModels.Draft d,String natural,String operator,String key) { + jdbc.update("INSERT INTO ll_fleet_quote(id,quote_no,kind,party_id,party_name,origin_city,destination_code,valid_from,valid_to,status,natural_key,details_json,record_version,create_key,create_hash,created_by,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + r.id(),r.quoteNo(),d.kind(),d.partyId(),r.partyName(),d.originCity(),d.destinationCode(),d.validFrom(),d.validTo(),r.status(),natural,r.details(),r.version(),key,r.createHash(),operator,r.updatedAt(),r.updatedAt()); + } + void update(Row r,FleetQuoteModels.Draft d,String natural) { + jdbc.update("UPDATE ll_fleet_quote SET kind=?,party_id=?,party_name=?,origin_city=?,destination_code=?,valid_from=?,valid_to=?,status=?,natural_key=?,details_json=?,record_version=?,updated_at=? WHERE id=?", + d.kind(),d.partyId(),r.partyName(),d.originCity(),d.destinationCode(),d.validFrom(),d.validTo(),r.status(),natural,r.details(),r.version(),r.updatedAt(),r.id()); + } + void audit(long id,Row r,String action,String operator,String snapshot) { + jdbc.update("INSERT INTO ll_fleet_quote_history(id,quote_id,record_version,action,operator_id,occurred_at,snapshot_json) VALUES(?,?,?,?,?,?,?)",id,r.id(),r.version(),action,operator,r.updatedAt(),snapshot); + } + List history(long id) {return jdbc.query("SELECT * FROM ll_fleet_quote_history WHERE quote_id=? ORDER BY record_version DESC LIMIT 200",(r,i)->new Audit(r.getLong("record_version"),r.getString("action"),r.getTimestamp("occurred_at").toLocalDateTime(),r.getString("snapshot_json")),id);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteService.java new file mode 100644 index 000000000..86f480fe3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteService.java @@ -0,0 +1,87 @@ +package com.baiyue.lastleg.fleet.quote; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.PageResult; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.time.*; +import java.util.*; + +@Service +public class FleetQuoteService { + private final FleetQuoteRepository repository; private final FleetQuoteDirectory directory; private final ObjectMapper json; + public FleetQuoteService(FleetQuoteRepository repository,FleetQuoteDirectory directory,ObjectMapper json) {this.repository=repository;this.directory=directory;this.json=json;} + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public PageResult page(FleetQuoteModels.Query q) { + var rows=list(q);long total=rows.size();int start=(int)Math.min(total,((long)q.page()-1)*q.size()); + return new PageResult<>(rows.subList(start,Math.min(rows.size(),start+q.size())),q.page(),q.size(),total,(total+q.size()-1)/q.size()); + } + private List list(FleetQuoteModels.Query q) { + var rows=repository.list(q);if(rows.size()>5000)throw bad("超过5000条报价,请缩小筛选范围");return rows.stream().map(this::response).toList(); + } + @Transactional(readOnly=true) public FleetQuoteModels.Quote detail(String no) {return response(required(no,false));} + @Transactional(readOnly=true) public List history(String no) { + return repository.history(required(no,false).id()).stream().map(r->new FleetQuoteModels.History(r.version(),r.action(),r.occurredAt().toInstant(ZoneOffset.UTC),read(r.snapshot(),FleetQuoteModels.Quote.class))).toList(); + } + @Transactional public FleetQuoteModels.Quote create(String operator,FleetQuoteModels.Create request) {return createOne(operator,request,"");} + private FleetQuoteModels.Quote createOne(String operator,FleetQuoteModels.Create request,String batchHash) { + var draft=normalize(request.details());String hash=hash(batchHash+write(draft));var existing=repository.replay(operator,request.idempotencyKey()); + if(existing!=null) {if(!existing.createHash().equals(hash))throw conflict("该请求键已用于不同报价");return read(repository.creationSnapshot(existing.id()),FleetQuoteModels.Quote.class);} + String name=directory.name(draft.kind(),draft.partyId());long id=IdWorker.getId(); + var row=new FleetQuoteRepository.Row(id,"FQ"+id,write(draft),name,"ACTIVE",0,hash,LocalDateTime.now(ZoneOffset.UTC)); + try {repository.insert(row,draft,natural(draft),operator,request.idempotencyKey());} + catch(DuplicateKeyException e) {throw conflict("报价重复或请求正在处理,请核对报价库后使用原请求重试");} + audit(row,"CREATE",operator);return response(row); + } + @Transactional public FleetQuoteModels.Quote update(String no,String operator,FleetQuoteModels.Update request) { + var old=required(no,true);version(old,request.recordVersion());var draft=normalize(request.details()); + if(!draft.kind().equals(read(old.details(),FleetQuoteModels.Draft.class).kind())) throw bad("报价用途不能变更,请另建采购或销售报价"); + var row=new FleetQuoteRepository.Row(old.id(),no,write(draft),directory.name(draft.kind(),draft.partyId()),old.status(),old.version()+1,old.createHash(),LocalDateTime.now(ZoneOffset.UTC)); + try {repository.update(row,draft,natural(draft));}catch(DuplicateKeyException e){throw conflict("同一报价组合已存在");} + audit(row,"UPDATE",operator);return response(row); + } + @Transactional public FleetQuoteModels.Quote status(String no,String operator,FleetQuoteModels.Status request) {return changeStatus(no,operator,request.recordVersion(),request.status());} + @Transactional public FleetQuoteModels.Quote delete(String no,String operator,long version) {return changeStatus(no,operator,version,"DELETED");} + private FleetQuoteModels.Quote changeStatus(String no,String operator,long expected,String status) { + var old=required(no,true);version(old,expected);var draft=read(old.details(),FleetQuoteModels.Draft.class); + if("ACTIVE".equals(status))directory.name(draft.kind(),draft.partyId()); + var row=new FleetQuoteRepository.Row(old.id(),no,old.details(),old.partyName(),status,old.version()+1,old.createHash(),LocalDateTime.now(ZoneOffset.UTC)); + repository.update(row,draft,"DELETED".equals(status)?hash("DELETED:"+no):natural(draft));audit(row,status,operator);return response(row); + } + @Transactional public FleetQuoteModels.ImportResult importRows(String operator,FleetQuoteModels.Import request) { + var rows=new ArrayList(); + String batchHash=hash(write(request.rows().stream().map(FleetQuoteService::normalize).toList())); + for(int i=0;iT read(String value,Class type){try{return json.readValue(value,type);}catch(JsonProcessingException e){throw new IllegalStateException("报价快照无法读取",e);}} + private static String hash(String s){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(s.getBytes(StandardCharsets.UTF_8)));}catch(NoSuchAlgorithmException e){throw new IllegalStateException(e);}} + private static String cell(Object o){String s=o==null?"":o.toString();if(s.matches("(?s)^[\\s]*[=+@-].*"))s="'"+s;return "\""+s.replace("\"","\"\"")+"\"";} + private static ResponseStatusException bad(String s){return new ResponseStatusException(HttpStatus.BAD_REQUEST,s);} + private static ResponseStatusException conflict(String s){return new ResponseStatusException(HttpStatus.CONFLICT,s);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteSupplierClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteSupplierClient.java new file mode 100644 index 000000000..a7a469fa3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteSupplierClient.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.fleet.quote; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.*; +import java.util.List; + +@FeignClient(name="warehouse-service",contextId="fleetQuoteSupplierClient",url="${baiyue.clients.warehouse.url:}") +public interface FleetQuoteSupplierClient { + @GetMapping("/internal/warehouse/v1/suppliers/{supplierId}") + ApiResponse get(@RequestHeader("X-Internal-Service-Token") String token,@PathVariable("supplierId") long supplierId, + @RequestParam("requireActive") boolean requireActive,@RequestParam("category") String category); + record Supplier(long supplierId,String name,String category,String status,List carrierCodes,String truckingScope) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/PlatformFleetQuoteCandidateController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/PlatformFleetQuoteCandidateController.java new file mode 100644 index 000000000..dffeffd21 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/PlatformFleetQuoteCandidateController.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.fleet.quote; + +import com.baiyue.common.core.api.*; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@RestController @Validated +@RequestMapping("/api/platform/v1/last-leg/fleet/quotes/sale-candidates") +public class PlatformFleetQuoteCandidateController { + private final FleetQuoteCandidateService service; + public PlatformFleetQuoteCandidateController(FleetQuoteCandidateService service){this.service=service;} + @GetMapping @PreAuthorize("hasAuthority('platform:fleet:quote:list')") + public ApiResponse> candidates(@Valid FleetQuoteCandidateModels.Query query){return ApiResponse.success(service.candidates(query));} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/PlatformFleetQuoteController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/PlatformFleetQuoteController.java new file mode 100644 index 000000000..fcb1bac40 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/PlatformFleetQuoteController.java @@ -0,0 +1,41 @@ +package com.baiyue.lastleg.fleet.quote; + +import com.baiyue.common.core.api.*; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import java.util.List; + +@RestController @Validated @RequestMapping("/api/platform/v1/last-leg/fleet/quotes") +public class PlatformFleetQuoteController { + private final FleetQuoteService service; private final FleetCustomerDirectory customers; + public PlatformFleetQuoteController(FleetQuoteService service,FleetCustomerDirectory customers){this.service=service;this.customers=customers;} + @GetMapping @PreAuthorize("hasAuthority('platform:fleet:quote:list')") + public ApiResponse> page(@Valid FleetQuoteModels.Query query){return ApiResponse.success(service.page(query));} + @GetMapping("/customer-options") @PreAuthorize("hasAuthority('platform:fleet:quote:list')") + public ApiResponse> customers(@RequestParam(required=false) @Size(max=128) String keyword){return ApiResponse.success(customers.options(keyword));} + @GetMapping("/{quoteNo}") @PreAuthorize("hasAuthority('platform:fleet:quote:list')") + public ApiResponse detail(@PathVariable String quoteNo){return ApiResponse.success(service.detail(quoteNo));} + @GetMapping("/{quoteNo}/history") @PreAuthorize("hasAuthority('platform:fleet:quote:list')") + public ApiResponse> history(@PathVariable String quoteNo){return ApiResponse.success(service.history(quoteNo));} + @PostMapping @PreAuthorize("hasAuthority('platform:fleet:quote:create')") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody FleetQuoteModels.Create request){return ApiResponse.success(service.create(operator(user),request));} + @PutMapping("/{quoteNo}") @PreAuthorize("hasAuthority('platform:fleet:quote:edit')") + public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String quoteNo,@Valid @RequestBody FleetQuoteModels.Update request){return ApiResponse.success(service.update(quoteNo,operator(user),request));} + @PatchMapping("/{quoteNo}/status") @PreAuthorize("hasAuthority('platform:fleet:quote:status')") + public ApiResponse status(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String quoteNo,@Valid @RequestBody FleetQuoteModels.Status request){return ApiResponse.success(service.status(quoteNo,operator(user),request));} + @DeleteMapping("/{quoteNo}") @PreAuthorize("hasAuthority('platform:fleet:quote:delete')") + public ApiResponse delete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String quoteNo,@RequestParam @Min(0) long recordVersion){return ApiResponse.success(service.delete(quoteNo,operator(user),recordVersion));} + @PostMapping("/import") @PreAuthorize("hasAuthority('platform:fleet:quote:import')") + public ApiResponse importRows(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody FleetQuoteModels.Import request){return ApiResponse.success(service.importRows(operator(user),request));} + @GetMapping(value="/export",produces="text/csv") @PreAuthorize("hasAuthority('platform:fleet:quote:export')") + public ResponseEntity export(@Valid FleetQuoteModels.Query query){return ResponseEntity.ok().contentType(MediaType.parseMediaType("text/csv;charset=UTF-8")).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=fleet-quotes.csv").body(service.exportCsv(query));} + private static String operator(AuthenticatedUser user){if(user==null||user.userId()==null||user.tenantId()==null||user.tenantId()!=0L)throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台人员可维护报价");return user.userId().toString();} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/HttpTripOperationsEvidenceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/HttpTripOperationsEvidenceGateway.java new file mode 100644 index 000000000..4e6d26004 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/HttpTripOperationsEvidenceGateway.java @@ -0,0 +1,71 @@ +package com.baiyue.lastleg.fleet.reporting; + +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.*; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; +import java.io.IOException; +import java.util.Objects; +import java.util.Set; + +@Component +public class HttpTripOperationsEvidenceGateway implements TripOperationsEvidenceGateway { + private static final String TYPE = "FLEET_TRIP_OPERATION_EVIDENCE"; + private static final String PATH = "/internal/file/v1/business-files/0/FLEET_TRIP_OPERATION_EVIDENCE/{trip}"; + private static final long MAX_SIZE = 10L * 1024 * 1024; + private final RestClient client; + private final String token; + public HttpTripOperationsEvidenceGateway(RestClient.Builder builder, LastlegRestClientFactory factory, LastlegRestClientProperties properties, + @Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") String baseUrl, + @Value("${baiyue.security.internal-service-token:}") String token) { + client = factory.build(builder, baseUrl, properties.getCustomerVisitFile()); this.token = token; + } + @Override public TripOperationsRecordModels.EvidenceFile upload(long tripId, String operatorId, MultipartFile file) { + if (file == null || file.isEmpty() || file.getSize() > MAX_SIZE || !image(file.getContentType())) throw bad("实测凭证须为非空 PDF、JPG 或 PNG,最大 10MiB"); + var body = new MultipartBodyBuilder(); + try { body.part("file", new ByteArrayResource(file.getBytes()) { + @Override public String getFilename() { return file.getOriginalFilename(); } + }).contentType(MediaType.parseMediaType(file.getContentType())); } + catch (IOException error) { throw bad("无法读取实测凭证"); } + var result = client.post().uri(PATH, tripId).header("X-Internal-Service-Token", token).header("X-Operator-Id", operatorId) + .contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve() + .onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); }).body(Envelope.class); + return verified(result, tripId, null); + } + @Override public TripOperationsRecordModels.EvidenceFile metadata(long tripId, long fileId) { + return verified(client.get().uri(PATH + "/{file}", tripId, fileId).header("X-Internal-Service-Token", token).retrieve() + .onStatus(HttpStatusCode::isError, (request, response) -> { throw unavailable(response.getStatusCode()); }).body(Envelope.class), tripId, fileId); + } + @Override public byte[] download(long tripId, long fileId) { + var response = client.get().uri(PATH + "/{file}/content", tripId, fileId).header("X-Internal-Service-Token", token).retrieve() + .onStatus(HttpStatusCode::isError, (request, result) -> { throw unavailable(result.getStatusCode()); }).toEntity(byte[].class); + return imageContent(response); + } + static byte[] imageContent(ResponseEntity response) { + byte[] content = response.getBody(); + if (content == null || content.length == 0 || content.length > MAX_SIZE + || !image(Objects.toString(response.getHeaders().getContentType(), ""))) throw unavailable(HttpStatus.SERVICE_UNAVAILABLE); + return content; + } + static TripOperationsRecordModels.EvidenceFile verified(Envelope result, long tripId, Long fileId) { + if (result == null || !"200".equals(result.code())) throw unavailable(HttpStatus.SERVICE_UNAVAILABLE); + var data = result.data(); + if (data == null || data.fileId() == null || data.fileId() <= 0 || !Objects.equals(data.tenantId(), 0L) + || !TYPE.equals(data.businessType()) || !Long.toString(tripId).equals(data.businessId()) + || (fileId != null && !fileId.equals(data.fileId())) || data.fileName() == null || data.fileName().isBlank() + || !image(data.contentType()) || data.sizeBytes() == null || data.sizeBytes() <= 0 || data.sizeBytes() > MAX_SIZE) + throw bad("文件不属于当前车次或不是有效凭证"); + return new TripOperationsRecordModels.EvidenceFile(data.fileId().toString(), data.fileName(), data.contentType(), data.sizeBytes()); + } + private static boolean image(String type) { return Set.of("application/pdf", "image/jpeg", "image/png").contains(Objects.toString(type, "")); } + private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + private static ResponseStatusException unavailable(HttpStatusCode status) { return new ResponseStatusException(status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE, "实测凭证不可用或归属校验失败"); } + record Envelope(String code, Metadata data) { } + record Metadata(Long fileId, Long tenantId, String businessType, String businessId, String fileName, String contentType, Long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsController.java new file mode 100644 index 000000000..b0e86d9ae --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsController.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.fleet.reporting; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import jakarta.validation.Valid; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@RestController @Validated +@RequestMapping("/api/platform/v1/last-leg/fleet/operations") +public class PlatformTripOperationsController { + private final TripOperationsService service; + private final TripOperationsFinanceService finance; + public PlatformTripOperationsController(TripOperationsService service,TripOperationsFinanceService finance) { this.service=service;this.finance=finance; } + @GetMapping @PreAuthorize("hasAuthority('platform:fleet:operations:list')") + public ApiResponse report(@AuthenticationPrincipal AuthenticatedUser user,@Valid TripOperationsModels.Query query) { return ApiResponse.success(service.report(query,canReadCosts(user))); } + @GetMapping(value="/export",produces="text/csv") @PreAuthorize("hasAuthority('platform:fleet:operations:export')") + public ResponseEntity export(@AuthenticationPrincipal AuthenticatedUser user,@Valid TripOperationsModels.Query query) { + return ResponseEntity.ok().contentType(MediaType.parseMediaType("text/csv;charset=UTF-8")) + .header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=trip-operations.csv").body(service.exportCsv(query,canReadCosts(user))); + } + @GetMapping("/{tripNo}/finance") + @PreAuthorize("hasAuthority('platform:fleet:operations:list') and hasAuthority('platform:finance:customer-arap:list')") + public ApiResponse finance(@PathVariable String tripNo,@AuthenticationPrincipal AuthenticatedUser user) { + return ApiResponse.success(finance.read(tripNo,user!=null&&user.permissions().contains("platform:finance:receipt:view"))); + } + private static boolean canReadCosts(AuthenticatedUser user){return user!=null&&user.permissions().contains("platform:fleet:trip:list");} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsRecordController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsRecordController.java new file mode 100644 index 000000000..4f1eb6bb8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsRecordController.java @@ -0,0 +1,47 @@ +package com.baiyue.lastleg.fleet.reporting; + +import com.baiyue.common.core.api.*; +import com.baiyue.common.security.AuthenticatedUser; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.http.ResponseEntity; +import org.springframework.http.MediaType; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ContentDisposition; + +@RestController @Validated +@RequestMapping("/api/platform/v1/last-leg/fleet/operations/{tripNo}/record") +public class PlatformTripOperationsRecordController { + private final TripOperationsRecordService service; + public PlatformTripOperationsRecordController(TripOperationsRecordService service){this.service=service;} + @PostMapping(value="/files",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:fleet:operations:edit')") + public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@RequestPart("file") MultipartFile file){ + return ApiResponse.success(service.upload(tripNo,operator(user),file)); + } + @GetMapping("/files/{fileId}/content") @PreAuthorize("hasAuthority('platform:fleet:operations:list')") + public ResponseEntity download(@PathVariable String tripNo,@PathVariable @Positive long fileId){ + var value=service.download(tripNo,fileId); + return ResponseEntity.ok().contentType(MediaType.parseMediaType(value.file().contentType())) + .header(HttpHeaders.CACHE_CONTROL,"no-store").header("X-Content-Type-Options","nosniff") + .header(HttpHeaders.CONTENT_DISPOSITION,ContentDisposition.inline().filename(value.file().fileName(),java.nio.charset.StandardCharsets.UTF_8).build().toString()).body(value.content()); + } + @GetMapping @PreAuthorize("hasAuthority('platform:fleet:operations:list')") + public ApiResponse get(@PathVariable String tripNo){return ApiResponse.success(service.get(tripNo));} + @GetMapping("/history") @PreAuthorize("hasAuthority('platform:fleet:operations:list')") + public ApiResponse> history(@PathVariable String tripNo,@RequestParam(defaultValue="1") @Min(1) int page,@RequestParam(defaultValue="20") @Min(1) @Max(100) int size){return ApiResponse.success(service.history(tripNo,page,size));} + @PutMapping @PreAuthorize("hasAuthority('platform:fleet:operations:edit')") + public ApiResponse save(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@Valid @RequestBody TripOperationsRecordModels.Save request){ + return ApiResponse.success(service.save(tripNo,operator(user),request)); + } + private static String operator(AuthenticatedUser user){ + if(user==null||user.userId()==null||user.tenantId()==null||user.tenantId()!=0L)throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台人员可维护实测记录"); + return user.userId().toString(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripFinanceClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripFinanceClient.java new file mode 100644 index 000000000..97e7cebba --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripFinanceClient.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.fleet.reporting; + +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.*; +import java.math.BigDecimal; +import java.util.List; + +@FeignClient(name="finance-service",contextId="tripFinanceClient",url="${baiyue.clients.finance.url:}") +public interface TripFinanceClient { + @PostMapping("/internal/finance/v1/trip-receivables/query") + ApiResponse query(@RequestHeader("X-Internal-Service-Token") String token,@RequestBody Query query); + record Business(long customerId,String deliveryNo) { } + record Query(List businesses,boolean receiptsVisible) { } + record Money(String currency,BigDecimal receivableAmount,BigDecimal receivedAmount) { } + record Source(String customerId,String businessNo,String sourceNo,String feeItemName,int sourceVersion,String currency,BigDecimal amount,String status) { } + record Document(String billNo,String currency,BigDecimal receivedAmount,String allocationStatus,String reason) { } + record Result(String status,List totals,List sources,List documents,String reason) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsCharts.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsCharts.java new file mode 100644 index 000000000..01b0d6991 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsCharts.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.fleet.reporting; + +import java.math.*; +import java.time.ZoneOffset; +import java.util.*; + +/** Every chart uses the exact same filtered departures as the table and summary. */ +final class TripOperationsCharts { + private TripOperationsCharts() { } + static TripOperationsModels.Charts from(List rows) { + var pallets=new TreeMap();var destinations=new TreeMap();var labels=new HashMap(); + var amounts=new TreeMap>();var rateAmounts=new TreeMap>();var quantities=new TreeMap>(); + for(var row:rows) { + String day=row.departedAt().atOffset(ZoneOffset.UTC).toLocalDate().toString(); + pallets.merge(day,BigDecimal.valueOf(row.physicalPallets()),BigDecimal::add); + for(var destination:row.destinations()) {destinations.merge(destination.key(),BigDecimal.ONE,BigDecimal::add);labels.put(destination.key(),destination.label());} + for(var money:row.externalAgreedAmounts())amounts.computeIfAbsent(money.currency(),ignored->new TreeMap<>()).merge(day,money.amount(),BigDecimal::add); + for(var rate:row.externalPalletRates()) { + rateAmounts.computeIfAbsent(rate.currency(),ignored->new TreeMap<>()).merge(day,rate.agreedAmount(),BigDecimal::add); + quantities.computeIfAbsent(rate.currency(),ignored->new TreeMap<>()).merge(day,BigDecimal.valueOf(rate.pricedPalletSpaces()),BigDecimal::add); + } + } + var average=new TreeMap>(); + rateAmounts.forEach((currency,days)->days.forEach((day,amount)->average.computeIfAbsent(currency,ignored->new TreeMap<>()).put(day, + amount.divide(quantities.get(currency).get(day),2,RoundingMode.HALF_UP)))); + var destinationPoints=destinations.entrySet().stream().map(e->new TripOperationsModels.Point(labels.get(e.getKey()),e.getValue())).toList(); + return new TripOperationsModels.Charts(List.of(new TripOperationsModels.Series("每日实运托盘","托",points(pallets))), + List.of(new TripOperationsModels.Series("目的地涉及车次","次",destinationPoints)),series(amounts,false),series(average,true)); + } + private static List points(Map values){return values.entrySet().stream().map(e->new TripOperationsModels.Point(e.getKey(),e.getValue())).toList();} + private static List series(Map> values,boolean average){return values.entrySet().stream().map(e->new TripOperationsModels.Series(e.getKey(),e.getKey()+(average?"/标准托位":""),points(e.getValue()))).toList();} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsEvidenceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsEvidenceGateway.java new file mode 100644 index 000000000..81abe6d1b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsEvidenceGateway.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.fleet.reporting; + +import org.springframework.web.multipart.MultipartFile; + +public interface TripOperationsEvidenceGateway { + TripOperationsRecordModels.EvidenceFile upload(long tripId,String operator,MultipartFile file); + TripOperationsRecordModels.EvidenceFile metadata(long tripId,long fileId); + byte[] download(long tripId,long fileId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsFinanceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsFinanceService.java new file mode 100644 index 000000000..bab41f757 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsFinanceService.java @@ -0,0 +1,85 @@ +package com.baiyue.lastleg.fleet.reporting; + +import com.baiyue.lastleg.fleet.trip.FleetTripModels; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import java.util.*; + +@Service +public class TripOperationsFinanceService { + private final JdbcTemplate jdbc;private final ObjectMapper json;private final TripFinanceClient finance;private final String token; + public TripOperationsFinanceService(JdbcTemplate jdbc,ObjectMapper json,TripFinanceClient finance, + @Value("${baiyue.security.internal-service-token}") String token) {this.jdbc=jdbc;this.json=json;this.finance=finance;this.token=token;} + public record Result(String tripNo,List dispatchNos,boolean receiptsVisible,TripFinanceClient.Result finance,String attributionStatus,String attributionReason) { } + record Binding(String customerId,String dispatchNo) { } + record Ownership(Binding binding,long tripCount,String tripNo) { } + record Delivery(Binding binding,String deliveryNo) { } + @Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) + public Result read(String tripNo,boolean receiptsVisible) { + var snapshots=jdbc.query(""" + SELECT h.snapshot_json FROM ll_fleet_trip t JOIN ll_fleet_trip_history h ON h.trip_id=t.id AND h.action='DEPART' + WHERE t.trip_no=? AND t.departed_at IS NOT NULL ORDER BY h.record_version LIMIT 1 + """,(r,i)->r.getString(1),tripNo); + if(snapshots.size()!=1)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"实际发车记录不存在"); + FleetTripModels.AuditSnapshot snapshot; + try {snapshot=json.readValue(snapshots.get(0),FleetTripModels.AuditSnapshot.class);} + catch(Exception e){throw new IllegalStateException("实际发车财务归属快照无法读取",e);} + if(snapshot.trip()==null||!tripNo.equals(snapshot.trip().tripNo())||snapshot.confirmedLoad()==null||snapshot.confirmedLoad().cargo()==null) + throw new IllegalStateException("实际发车快照缺少车次或货物归属"); + var bindings=snapshot.confirmedLoad().cargo().stream().map(c->new Binding(c.customerId(),c.dispatchNo())).distinct().toList(); + if(bindings.size()>200)throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"车次来源超过200组,请核对来源"); + var businesses=new ArrayList();boolean pending=bindings.isEmpty(); + if(!bindings.isEmpty()) { + var args=new ArrayList();var ownerPairs=new ArrayList();var deliveryPairs=new ArrayList(); + for(var binding:bindings) { + ownerPairs.add("(c.customer_id=? AND c.dispatch_no=?)"); + deliveryPairs.add("(customer_id=? AND dispatch_no=?)"); + args.add(binding.customerId());args.add(binding.dispatchNo()); + } + // One scan of the global immutable DEPART set, including outside report filters. + var owners=jdbc.query(""" + SELECT c.customer_id,c.dispatch_no,COUNT(DISTINCT t.trip_no) trip_count,MAX(t.trip_no) trip_no FROM ll_fleet_trip t + JOIN ll_fleet_trip_history h ON h.trip_id=t.id AND h.action='DEPART' + JOIN JSON_TABLE(h.snapshot_json,'$.confirmedLoad.cargo[*]' COLUMNS( + customer_id VARCHAR(32) PATH '$.customerId',dispatch_no VARCHAR(64) PATH '$.dispatchNo')) c + WHERE t.departed_at IS NOT NULL AND ( + """+String.join(" OR ",ownerPairs)+") GROUP BY c.customer_id,c.dispatch_no", + (r,i)->new Ownership(new Binding(r.getString("customer_id"),r.getString("dispatch_no")),r.getLong("trip_count"),r.getString("trip_no")),args.toArray()); + var deliveries=jdbc.query("SELECT customer_id,dispatch_no,delivery_no FROM ll_delivery_order WHERE (" + +String.join(" OR ",deliveryPairs)+") ORDER BY customer_id,dispatch_no,delivery_no LIMIT 201", + (r,i)->new Delivery(new Binding(r.getString("customer_id"),r.getString("dispatch_no")),r.getString("delivery_no")),args.toArray()); + if(deliveries.size()>200)throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"车次派送来源超过200条,请核对来源"); + var ownersByBinding=new HashMap(); + for(var owner:owners)if(ownersByBinding.put(owner.binding(),owner)!=null)throw new IllegalStateException("车次来源归属汇总重复"); + var deliveriesByBinding=new HashMap>(); + for(var delivery:deliveries)deliveriesByBinding.computeIfAbsent(delivery.binding(),k->new ArrayList<>()).add(delivery.deliveryNo()); + for(var binding:bindings) { + var owner=ownersByBinding.get(binding); + var numbers=deliveriesByBinding.getOrDefault(binding,List.of()); + if(owner==null||owner.tripCount()!=1||!tripNo.equals(owner.tripNo())||numbers.isEmpty()){pending=true;continue;} + for(var no:numbers)businesses.add(new TripFinanceClient.Business(Long.parseLong(binding.customerId()),no)); + } + } + if(businesses.size()>200)throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"车次派送来源超过200条,请核对来源"); + TripFinanceClient.Result result; + try { + var response=finance.query(token,new TripFinanceClient.Query(businesses.stream().distinct().toList(),receiptsVisible)); + if(response==null||!response.successful()||response.data()==null)throw new IllegalStateException("财务响应缺失"); + result=response.data();validate(result,businesses,receiptsVisible); + }catch(Exception e){throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"Finance车次应收/实收暂不可查询",e);} + return new Result(tripNo,bindings.stream().map(Binding::dispatchNo).distinct().toList(),receiptsVisible,result, + pending?"PENDING_ALLOCATION":"ATTRIBUTED",pending?"部分来源无法唯一归属本车次,完整金额待分配":"按不可变发车客户及排车来源核对派送费用"); + } + static void validate(TripFinanceClient.Result value,List businesses,boolean receiptsVisible) { + if(value.totals()==null||value.sources()==null||value.documents()==null||!Set.of("AVAILABLE","PENDING_ALLOCATION").contains(value.status()))throw new IllegalStateException("财务归属响应无效"); + var currencies=new HashSet(); + for(var money:value.totals())if(money.currency()==null||!money.currency().matches("[A-Z]{3}")||!currencies.add(money.currency())||money.receivableAmount()==null||money.receivableAmount().signum()<0||money.receivedAmount()!=null&&money.receivedAmount().signum()<0||!receiptsVisible&&money.receivedAmount()!=null)throw new IllegalStateException("财务金额响应无效"); + for(var source:value.sources())if(businesses.stream().noneMatch(b->Long.toString(b.customerId()).equals(source.customerId())&&b.deliveryNo().equals(source.businessNo()))||source.sourceVersion()<1||source.amount()==null||source.amount().signum()<0)throw new IllegalStateException("财务来源超出车次"); + if(!receiptsVisible&&!value.documents().isEmpty())throw new IllegalStateException("无实收权限却返回收款来源"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsModels.java new file mode 100644 index 000000000..7111a9304 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsModels.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.fleet.reporting; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.fleet.trip.FleetTripCostModels; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +public final class TripOperationsModels { + private TripOperationsModels() { } + public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size, + @NotNull Instant from, @NotNull Instant to, @Positive Long warehouseId, + @Size(max=128) String keyword, @Pattern(regexp="OWN|EXTERNAL") String sourceType, + @Pattern(regexp="IN_TRANSIT|COMPLETED") String status) { + public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; } + } + public record Money(String currency, BigDecimal amount) { } + public record Destination(String key,String label) { } + public record PalletRate(String currency,long pricedPalletSpaces,BigDecimal agreedAmount) { } + public record Point(String label,BigDecimal value) { } + public record Series(String label,String unit,List points) { } + public record Charts(List physicalPallets,List destinationTrips,List externalAgreedAmounts,List externalPalletRates) { } + public record Row(String tripNo, String status, String vehiclePlate, String driverName, + String warehouseName, Instant departedAt, Instant completedAt, List destinations, + long physicalPallets, long palletSpaces, long ownPackages, long externalPackages, + List externalAgreedAmounts, List externalPalletRates, TripOperationsRecordModels.Details measured, + String financeStatus, String costStatus, FleetTripCostModels.Summary costs) { + Row withCosts(FleetTripCostModels.Summary value, String state) { + return new Row(tripNo,status,vehiclePlate,driverName,warehouseName,departedAt,completedAt,destinations,physicalPallets,palletSpaces,ownPackages,externalPackages,externalAgreedAmounts,externalPalletRates,measured,financeStatus,state,value); + } + } + public record CostTotal(String currency,BigDecimal directCost,BigDecimal ownCost,BigDecimal externalCost) { } + public record CostSummary(boolean visible,Long unrecordedTrips,Long unallocatedTrips,List totals) { } + public record Summary(long trips, long inTransit, long completed, long physicalPallets, long palletSpaces, + long ownPackages, long externalPackages, List externalAgreedAmounts,CostSummary costs) { } + public record Result(PageResult page, Summary summary, Charts charts) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordModels.java new file mode 100644 index 000000000..b252e4443 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordModels.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.fleet.reporting; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +public final class TripOperationsRecordModels { + private TripOperationsRecordModels() { } + public record Details( + @DecimalMin("0") @Digits(integer=7, fraction=1) BigDecimal actualDistanceMiles, + @DecimalMin("0") @Digits(integer=7, fraction=1) BigDecimal actualFuelLitres, + @Size(max=1000) String remark, List evidenceFiles) { + public Details { evidenceFiles=evidenceFiles==null?List.of():List.copyOf(evidenceFiles); } + } + public record EvidenceFile(String fileId,String fileName,String contentType,long sizeBytes) { } + public record Input(@DecimalMin("0") @Digits(integer=7,fraction=1) BigDecimal actualDistanceMiles, + @DecimalMin("0") @Digits(integer=7,fraction=1) BigDecimal actualFuelLitres, + @Size(max=1000) String remark,@Size(max=20) List<@NotNull @Positive Long> evidenceFileIds) { + public Input { evidenceFileIds=evidenceFileIds==null?List.of():List.copyOf(evidenceFileIds); } + } + public record Save(@NotNull @Min(0) Long recordVersion, + @NotBlank @Size(min=8,max=128) String idempotencyKey, + @NotNull @Valid Input details, @Size(max=1000) String reason) { } + public record Record(String tripNo, long recordVersion, Details details, Instant updatedAt) { } + public record History(long recordVersion, String reason, Instant occurredAt, Details details) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordRepository.java new file mode 100644 index 000000000..d2f4b46ad --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordRepository.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.fleet.reporting; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public class TripOperationsRecordRepository { + record Snapshot(long version,String details,String reason,String requestHash,LocalDateTime occurredAt) { } + private final JdbcTemplate jdbc; + private static final RowMapper MAPPER=(r,i)->new Snapshot(r.getLong("record_version"),r.getString("details_json"),r.getString("reason"),r.getString("request_hash"),r.getTimestamp("occurred_at").toLocalDateTime()); + public TripOperationsRecordRepository(JdbcTemplate jdbc) { this.jdbc=jdbc; } + boolean hasDeparted(String tripNo,boolean lock) { + return jdbc.query("SELECT departed_at FROM ll_fleet_trip WHERE trip_no=?"+(lock?" FOR UPDATE":""), + (r,i)->r.getTimestamp("departed_at")!=null,tripNo).stream().findFirst().orElse(false); + } + Snapshot latest(String tripNo) { + return jdbc.query("SELECT * FROM ll_fleet_trip_operations_record WHERE trip_no=? ORDER BY record_version DESC LIMIT 1",MAPPER,tripNo).stream().findFirst().orElse(null); + } + Snapshot replay(String tripNo,String operator,String key) { + return jdbc.query("SELECT * FROM ll_fleet_trip_operations_record WHERE trip_no=? AND operator_id=? AND request_key=?",MAPPER,tripNo,operator,key).stream().findFirst().orElse(null); + } + void append(long id,String tripNo,String operator,String key,Snapshot row) { + jdbc.update("INSERT INTO ll_fleet_trip_operations_record(id,trip_no,record_version,details_json,reason,operator_id,request_key,request_hash,occurred_at) VALUES(?,?,?,?,?,?,?,?,?)", + id,tripNo,row.version(),row.details(),row.reason(),operator,key,row.requestHash(),row.occurredAt()); + } + long count(String tripNo) { return jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_operations_record WHERE trip_no=?",Long.class,tripNo); } + List history(String tripNo,int page,int size) { + return jdbc.query("SELECT * FROM ll_fleet_trip_operations_record WHERE trip_no=? ORDER BY record_version DESC LIMIT ? OFFSET ?",MAPPER,tripNo,size,((long)page-1)*size); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordService.java new file mode 100644 index 000000000..dfe7f7873 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordService.java @@ -0,0 +1,93 @@ +package com.baiyue.lastleg.fleet.reporting; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.PageResult; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.time.*; +import java.util.HexFormat; +import java.util.List; +import java.util.HashSet; +import com.baiyue.lastleg.fleet.trip.FleetTripMapper; +import org.springframework.web.multipart.MultipartFile; + +/** Append-only measured operating facts; never updates execution, vehicle odometers or costs. */ +@Service +public class TripOperationsRecordService { + private final TripOperationsRecordRepository repository; + private final ObjectMapper json; + private final FleetTripMapper trips; + private final TripOperationsEvidenceGateway files; + public TripOperationsRecordService(TripOperationsRecordRepository repository,ObjectMapper json,FleetTripMapper trips,TripOperationsEvidenceGateway files) { this.repository=repository;this.json=json;this.trips=trips;this.files=files; } + @Transactional(readOnly=true) + public TripOperationsRecordModels.EvidenceFile upload(String tripNo,String operator,MultipartFile file) { + return files.upload(evidenceTripId(tripNo),operator,file); + } + public record Download(TripOperationsRecordModels.EvidenceFile file,byte[] content) { } + @Transactional(readOnly=true) + public Download download(String tripNo,long fileId) { + long tripId=evidenceTripId(tripNo); + boolean referenced=false; + long total=repository.count(tripNo); + for(int page=1;(long)(page-1)*100read(row.details()).evidenceFiles().stream().anyMatch(file->Long.toString(fileId).equals(file.fileId()))); + } + if(!referenced)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"凭证尚未保存到本车实测记录或历史版本"); + return new Download(files.metadata(tripId,fileId),files.download(tripId,fileId)); + } + private long evidenceTripId(String tripNo) { + var trip=trips.findByNo(tripNo); + if(trip==null || trip.getDepartedAt()==null)throw conflict("仅实际已发车的车次可维护实测凭证"); + return trip.getId(); + } + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public TripOperationsRecordModels.Record get(String tripNo) { + requireDeparture(tripNo,false);return response(tripNo,repository.latest(tripNo)); + } + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public PageResult history(String tripNo,int page,int size) { + requireDeparture(tripNo,false);long total=repository.count(tripNo); + var rows=repository.history(tripNo,page,size).stream().map(r->new TripOperationsRecordModels.History(r.version(),r.reason(),r.occurredAt().toInstant(ZoneOffset.UTC),read(r.details()))).toList(); + return new PageResult<>(rows,page,size,total,(total+size-1)/size); + } + @Transactional(isolation=Isolation.READ_COMMITTED) + public TripOperationsRecordModels.Record save(String tripNo,String operator,TripOperationsRecordModels.Save request) { + requireDeparture(tripNo,true); + var d=request.details(); + if(d.evidenceFileIds().size()>20 || new HashSet<>(d.evidenceFileIds()).size()!=d.evidenceFileIds().size() + || d.evidenceFileIds().stream().anyMatch(id->id==null||id<=0))throw conflict("每次最多绑定20个不同的实测凭证"); + var input=new TripOperationsRecordModels.Input(decimal(d.actualDistanceMiles()),decimal(d.actualFuelLitres()),trim(d.remark()),d.evidenceFileIds()); + String reason=trim(request.reason());if(reason==null)reason="补充或更正出车实测记录"; + String hash=hash(request.recordVersion()+":"+write(input)+":"+reason); + var replay=repository.replay(tripNo,operator,request.idempotencyKey()); + if(replay!=null) {if(!replay.requestHash().equals(hash))throw conflict("原请求键已用于其他实测内容");return response(tripNo,replay);} + var previous=repository.latest(tripNo);long version=previous==null?0:previous.version(); + if(version!=request.recordVersion())throw conflict("实测记录已变化,请重新加载后更正"); + long tripId=input.evidenceFileIds().isEmpty()?0:evidenceTripId(tripNo); + var evidence=input.evidenceFileIds().stream().map(fileId->files.metadata(tripId,fileId)).toList(); + var details=new TripOperationsRecordModels.Details(input.actualDistanceMiles(),input.actualFuelLitres(),input.remark(),evidence); + String contents=write(details); + if(previous!=null && previous.details().equals(contents))return response(tripNo,previous); + if(previous==null && details.actualDistanceMiles()==null && details.actualFuelLitres()==null && details.remark()==null && details.evidenceFiles().isEmpty())return response(tripNo,null); + var row=new TripOperationsRecordRepository.Snapshot(version+1,contents,reason,hash,LocalDateTime.now(ZoneOffset.UTC)); + repository.append(IdWorker.getId(),tripNo,operator,request.idempotencyKey(),row);return response(tripNo,row); + } + private void requireDeparture(String tripNo,boolean lock) {if(!repository.hasDeparted(tripNo,lock))throw new ResponseStatusException(HttpStatus.CONFLICT,"仅实际已发车的车次可维护实测记录");} + private TripOperationsRecordModels.Record response(String tripNo,TripOperationsRecordRepository.Snapshot row) { + return row==null?new TripOperationsRecordModels.Record(tripNo,0,new TripOperationsRecordModels.Details(null,null,null,List.of()),null) + :new TripOperationsRecordModels.Record(tripNo,row.version(),read(row.details()),row.occurredAt().toInstant(ZoneOffset.UTC)); + } + private TripOperationsRecordModels.Details read(String value) {try{return json.readValue(value,TripOperationsRecordModels.Details.class);}catch(JsonProcessingException e){throw new IllegalStateException("实测记录无法读取",e);}} + private String write(Object value) {try{return json.writeValueAsString(value);}catch(JsonProcessingException e){throw new IllegalStateException(e);}} + private static String trim(String s){return s==null||s.isBlank()?null:s.trim();} + private static BigDecimal decimal(BigDecimal value){return value==null?null:value.stripTrailingZeros();} + private static String hash(String s){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(s.getBytes(StandardCharsets.UTF_8)));}catch(NoSuchAlgorithmException e){throw new IllegalStateException(e);}} + private static ResponseStatusException conflict(String s){return new ResponseStatusException(HttpStatus.CONFLICT,s);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRepository.java new file mode 100644 index 000000000..762fc6041 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRepository.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.fleet.reporting; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import java.time.*; +import java.util.*; + +@Repository +public class TripOperationsRepository { + record Departure(String tripNo, String status, Instant completedAt, String snapshot, String measured) { } + private final JdbcTemplate jdbc; + public TripOperationsRepository(JdbcTemplate jdbc) { this.jdbc = jdbc; } + List departures(TripOperationsModels.Query query) { + StringBuilder sql = new StringBuilder(""" + SELECT t.trip_no,t.status,t.completed_at, + (SELECT h.snapshot_json FROM ll_fleet_trip_history h WHERE h.trip_id=t.id AND h.action='DEPART' + ORDER BY h.record_version LIMIT 1) AS snapshot_json, + (SELECT r.details_json FROM ll_fleet_trip_operations_record r WHERE r.trip_no=t.trip_no + ORDER BY r.record_version DESC LIMIT 1) AS measured_json + FROM ll_fleet_trip t WHERE t.departed_at >= ? AND t.departed_at < ? + """); + var args = new ArrayList(); + args.add(LocalDateTime.ofInstant(query.from(), ZoneOffset.UTC)); args.add(LocalDateTime.ofInstant(query.to(), ZoneOffset.UTC)); + if (query.warehouseId() != null) { sql.append(" AND t.warehouse_id=?"); args.add(query.warehouseId()); } + if (query.status() != null && !query.status().isBlank()) { sql.append(" AND t.status=?"); args.add(query.status()); } + // Search by stable business number; vehicle/driver search is applied to the immutable departure snapshot. + sql.append(" ORDER BY t.departed_at DESC,t.id DESC LIMIT 5001"); + return jdbc.query(sql.toString(), (r, i) -> new Departure(r.getString("trip_no"), r.getString("status"), + r.getTimestamp("completed_at") == null ? null : r.getTimestamp("completed_at").toLocalDateTime().toInstant(ZoneOffset.UTC), + r.getString("snapshot_json"),r.getString("measured_json")), args.toArray()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsService.java new file mode 100644 index 000000000..13deda172 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsService.java @@ -0,0 +1,136 @@ +package com.baiyue.lastleg.fleet.reporting; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.fleet.trip.*; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.*; + +@Service +@Transactional(readOnly=true, isolation=Isolation.REPEATABLE_READ) +public class TripOperationsService { + private final TripOperationsRepository repository; + private final ObjectMapper json; + private final FleetTripCostService costs; + public TripOperationsService(TripOperationsRepository repository, ObjectMapper json,FleetTripCostService costs) { this.repository=repository; this.json=json;this.costs=costs; } + public TripOperationsModels.Result report(TripOperationsModels.Query query,boolean costVisible) { + var rows = rows(query,costVisible); long total = rows.size(); + int start = (int) Math.min(total, ((long) query.page()-1)*query.size()); + return new TripOperationsModels.Result(new PageResult<>(rows.subList(start, Math.min(rows.size(),start+query.size())), + query.page(),query.size(),total,(total+query.size()-1)/query.size()), summary(rows,costVisible),TripOperationsCharts.from(rows)); + } + List rows(TripOperationsModels.Query query,boolean costVisible) { + if (query.from() == null || query.to() == null || !query.to().isAfter(query.from()) + || Duration.between(query.from(),query.to()).compareTo(Duration.ofDays(366))>0) throw bad("请选择不超过366天的实际发车时间范围"); + var facts=repository.departures(query); + if (facts.size()>5000) throw bad("范围内超过5000车次,请缩小时间或仓库范围后查询或导出"); + String keyword=query.keyword()==null?"":query.keyword().trim().toLowerCase(Locale.ROOT); + var rows=facts.stream().map(this::project) + .filter(r -> keyword.isEmpty() || contains(r.tripNo(),keyword) || contains(r.vehiclePlate(),keyword) || contains(r.driverName(),keyword) || contains(r.warehouseName(),keyword) || r.destinations().stream().anyMatch(s -> contains(s.label(),keyword))) + .filter(r -> query.sourceType()==null || query.sourceType().isBlank() || ("OWN".equals(query.sourceType())?r.ownPackages()>0:r.externalPackages()>0)).toList(); + if(!costVisible)return rows.stream().map(r->r.withCosts(null,"FORBIDDEN")).toList(); + var result=new ArrayList(); + for(int start=0;start(); var money=new TreeMap(); var bookingNumbers=new HashSet(); + var rates=new ArrayList(); + if (snapshot.externalBookings()!=null) for (var booking:snapshot.externalBookings()) { + if (Set.of("DRAFT","CANCELLED").contains(booking.status())) continue; + if (!"BOUND".equals(booking.status()) || !bookingNumbers.add(booking.bookingNo())) throw new IllegalStateException("发车外接记录未完成关联或重复"); + if (booking.dispatchNo()==null || booking.agreedAmount()==null || booking.cargo()==null || booking.cargo().currency()==null) + throw new IllegalStateException("外接发车历史缺少约定报价或排车归属"); + if(booking.receipt()==null || booking.receipt().source()==null || booking.receipt().source().pallets()==null) + throw new IllegalStateException("外接发车历史缺少实收来源"); + long received=0; + for(var p:booking.receipt().source().pallets()) { + externalByPallet.merge(booking.dispatchNo()+":"+booking.customerId()+":"+p.warehousePalletId(),(long)p.packages(),Long::sum); + received+=p.packages(); + } + if(received!=booking.receipt().source().receivedPackages())throw new IllegalStateException("外接实收来源数量未核平"); + money.merge(booking.cargo().currency(),booking.agreedAmount(),BigDecimal::add); + if("PALLET".equals(booking.cargo().priceBasis())) { + if(booking.cargo().palletSpaces()==null || booking.cargo().palletSpaces()<1)throw new IllegalStateException("按托报价缺少确认计价托位"); + rates.add(new TripOperationsModels.PalletRate(booking.cargo().currency(),booking.cargo().palletSpaces(),booking.agreedAmount())); + } + } + var pallets=new HashSet(); long spaces=0; + for(var pallet:load.pallets()) { + if(!pallets.add(pallet.warehousePalletId())) throw new IllegalStateException("发车实物托盘重复"); + spaces+=pallet.palletSpaces(); + } + long own=0,external=0; var destinations=new TreeMap(); var seen=new HashSet(); + var shippedByPallet=new HashMap(); + for(var cargo:load.cargo()) { + if (!pallets.contains(cargo.warehousePalletId()) || !seen.add(cargo.dispatchNo()+":"+cargo.dispatchItemId())) + throw new IllegalStateException("发车装载货物未核平或重复"); + shippedByPallet.merge(cargo.dispatchNo()+":"+cargo.customerId()+":"+cargo.warehousePalletId(),(long)cargo.packages(),Long::sum); + if(cargo.destinationKey()==null || cargo.destinationLabel()==null)throw new IllegalStateException("发车货物缺少目的地依据"); + destinations.put(cargo.destinationKey(),cargo.destinationLabel()); + } + for(var entry:externalByPallet.entrySet()) if(entry.getValue()>shippedByPallet.getOrDefault(entry.getKey(),0L))throw new IllegalStateException("外接实收超过实际发车货量"); + external=externalByPallet.values().stream().mapToLong(Long::longValue).sum(); + own=shippedByPallet.values().stream().mapToLong(Long::longValue).sum()-external; + return new TripOperationsModels.Row(fact.tripNo(),fact.status(),trip.vehiclePlate(),trip.driverName(),trip.warehouseName(),trip.departedAt(),fact.completedAt(), + destinations.entrySet().stream().map(e->new TripOperationsModels.Destination(e.getKey(),e.getValue())).toList(),pallets.size(),spaces,own,external,amounts(money),List.copyOf(rates), + fact.measured()==null?null:json.readValue(fact.measured(),TripOperationsRecordModels.Details.class),"NOT_QUERIED","NOT_QUERIED",null); + } catch(JsonProcessingException e) { throw new IllegalStateException("发车历史无法读取,请核对车次"+fact.tripNo(),e); } + } + static TripOperationsModels.Summary summary(List rows,boolean costVisible) { + var money=new TreeMap(); + rows.forEach(row -> row.externalAgreedAmounts().forEach(m -> money.merge(m.currency(),m.amount(),BigDecimal::add))); + return new TripOperationsModels.Summary(rows.size(),rows.stream().filter(r -> "IN_TRANSIT".equals(r.status())).count(),rows.stream().filter(r -> "COMPLETED".equals(r.status())).count(), + rows.stream().mapToLong(TripOperationsModels.Row::physicalPallets).sum(),rows.stream().mapToLong(TripOperationsModels.Row::palletSpaces).sum(), + rows.stream().mapToLong(TripOperationsModels.Row::ownPackages).sum(),rows.stream().mapToLong(TripOperationsModels.Row::externalPackages).sum(),amounts(money),costSummary(rows,costVisible)); + } + private static TripOperationsModels.CostSummary costSummary(List rows,boolean visible) { + if(!visible)return new TripOperationsModels.CostSummary(false,null,null,List.of()); + var totals=new TreeMap();long unrecorded=0,unallocated=0; + for(var row:rows) { + if(row.costs().totals().isEmpty())unrecorded++; + else if(row.costs().allocationBlockReason()!=null)unallocated++; + for(var total:row.costs().totals()) { + var old=totals.get(total.currency()); + totals.put(total.currency(),old==null?new TripOperationsModels.CostTotal(total.currency(),total.directCost(),total.ownCost(),total.externalCost()) + :new TripOperationsModels.CostTotal(total.currency(),old.directCost().add(total.directCost()),addKnown(old.ownCost(),total.ownCost()),addKnown(old.externalCost(),total.externalCost()))); + } + } + return new TripOperationsModels.CostSummary(true,unrecorded,unallocated,List.copyOf(totals.values())); + } + private static BigDecimal addKnown(BigDecimal a,BigDecimal b){return a==null||b==null?null:a.add(b);} + public byte[] exportCsv(TripOperationsModels.Query query,boolean costVisible) { + var csv=new StringBuilder("\uFEFF车次号,状态,车牌,司机,起运仓,实际发车UTC,货物结案UTC,目的地,实物托盘数,标准托位,自有箱数,外接箱数,外接约定金额(按币种),实际里程(mile),实际油耗(L),实测备注,应收与实收,直接成本\r\n"); + for(var r:rows(query,costVisible)) csv.append(String.join(",",cell(r.tripNo()),cell(r.status()),cell(r.vehiclePlate()),cell(r.driverName()),cell(r.warehouseName()),cell(r.departedAt()),cell(r.completedAt()), + cell(r.destinations().stream().map(TripOperationsModels.Destination::label).collect(java.util.stream.Collectors.joining(" / "))),cell(r.physicalPallets()),cell(r.palletSpaces()),cell(r.ownPackages()),cell(r.externalPackages()), + cell(r.externalAgreedAmounts().stream().map(m -> m.currency()+" "+m.amount().toPlainString()).collect(java.util.stream.Collectors.joining(" / "))), + cell(r.measured()==null?null:r.measured().actualDistanceMiles()),cell(r.measured()==null?null:r.measured().actualFuelLitres()),cell(r.measured()==null?null:r.measured().remark()),cell("未查询财务"),cell(costText(r)))).append("\r\n"); + return csv.toString().getBytes(StandardCharsets.UTF_8); + } + private static String costText(TripOperationsModels.Row row){return row.costs()==null?"无成本读取权限":row.costs().totals().isEmpty()?"未登记成本":row.costs().totals().stream().map(t->t.currency()+" "+t.directCost().toPlainString()).collect(java.util.stream.Collectors.joining(" / "));} + private static List amounts(Map money) { return money.entrySet().stream().map(e -> new TripOperationsModels.Money(e.getKey(),e.getValue())).toList(); } + private static boolean contains(String value,String key) { return value!=null && value.toLowerCase(Locale.ROOT).contains(key); } + static String cell(Object value) { String s=value==null?"":value.toString(); if(s.matches("(?s)^[\\s]*[=+@-].*")) s="'"+s; return "\""+s.replace("\"","\"\"")+"\""; } + private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST,message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingEntity.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingEntity.java new file mode 100644 index 000000000..fc212727d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingEntity.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data @TableName("ll_fleet_external_booking") +public class FleetBookingEntity { + @TableId(type=IdType.INPUT) private Long id; + private String bookingNo; + private Long tripId; + private String tripNo; + private String customerId; + private String customerCode; + private String customerName; + private String status; + private String cargoJson; + @TableField(updateStrategy=FieldStrategy.ALWAYS) private String quoteSourceJson; + private String destinationKey; + private String destinationLabel; + @TableField(updateStrategy=FieldStrategy.ALWAYS) private BigDecimal agreedAmount; + private String idempotencyKey; + private String requestHash; + private String confirmKey; + private String confirmHash; + private String cancelKey; + private String cancelHash; + private String incomingNo; + @TableField(updateStrategy=FieldStrategy.ALWAYS) private String dispatchNo; + private String receiptJson; + private String incomingKey; + private String incomingHash; + private String receiptKey; + private String receiptHash; + private String bindingKey; + private String bindingHash; + private Long recordVersion; + private String createdBy; + private LocalDateTime createdAt; + private String updatedBy; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingFreight.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingFreight.java new file mode 100644 index 000000000..9183386d7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingFreight.java @@ -0,0 +1,90 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.*; + +/** Confirmed commercial facts only; delivery owns fee publication in its transaction. */ +@Component +public class FleetBookingFreight { + private final FleetBookingMapper bookings; + private final FleetTripDispatchMapper bindings; + private final FleetBookingReservations reservations; + private final ObjectMapper json; + public FleetBookingFreight(FleetBookingMapper bookings, FleetTripDispatchMapper bindings, + FleetBookingReservations reservations, ObjectMapper json) { + this.bookings = bookings; this.bindings = bindings; this.reservations = reservations; this.json = json; + } + public record Quote(String bookingNo, long recordVersion, String incomingNo, String inboundCode, + String customerName, String destinationKey, String currency, BigDecimal amount, + BigDecimal quantity, String quantityUnit, BigDecimal unitPrice) { } + public record Preview(String snapshotHash, boolean requiresDeliveryFee, List quotes, + @com.fasterxml.jackson.annotation.JsonIgnore List priorInboundCodes) { } + + /** Caller serializes transfer with the dispatch lock. Never lock the parent trip here. */ + public Preview preview(long dispatchId, String dispatchNo, String customerId, + List sources) { + var quotes = new ArrayList(); + var priorInboundCodes = new TreeSet(); + var grouped = new TreeMap>(); + boolean requiresFee = false; + for (var source : sources) { + if (source.inboundCode() == null || source.inboundCode().isBlank()) requiresFee = true; + else grouped.computeIfAbsent(source.inboundCode(), key -> new ArrayList<>()).add(source); + } + for (var entry : grouped.entrySet()) { + var booking = bookings.incomingForFreight(entry.getKey()); + if (booking == null) { requiresFee = true; continue; } + if (!customerId.equals(booking.getCustomerId())) throw conflict("来货来源与排车客户不一致"); + var accepted = reservations.receipt(booking); + if (accepted != null && accepted.source().inboundCode() != null && !accepted.source().inboundCode().isBlank()) + priorInboundCodes.add(accepted.source().inboundCode()); + if ("CANCELLED".equals(booking.getStatus())) { requiresFee = true; continue; } + var binding = bindings.findByDispatchNo(dispatchNo); + if (!"BOUND".equals(booking.getStatus()) || !customerId.equals(booking.getCustomerId()) + || !dispatchNo.equals(booking.getDispatchNo()) || binding == null + || !Objects.equals(binding.getDispatchId(), dispatchId) + || !Objects.equals(binding.getTripId(), booking.getTripId())) + throw conflict("外接货须先在承诺车次中完成实收确认和排车关联"); + if (accepted == null) throw conflict("外接货缺少实收与报价确认"); + if (accepted.source().inboundCode() == null || accepted.source().inboundCode().isBlank()) + throw conflict("外接实收缺少实际入仓码,请先核对来货来源"); + var actual = new TreeMap(); + for (var source : entry.getValue()) { + if (source.warehousePalletId() == null || !Objects.equals(booking.getDestinationKey(), source.destinationKey())) + throw conflict("外接发运来源或目的地已变化,请重新核对"); + actual.merge(source.warehousePalletId(), source.packages(), Math::addExact); + } + var confirmed = new TreeMap(); + for (var pallet : accepted.source().pallets()) confirmed.merge(pallet.warehousePalletId(), pallet.packages(), Math::addExact); + if (!actual.equals(confirmed)) throw conflict("外接发运数量与已确认实收不一致"); + var cargo = reservations.cargo(booking); + BigDecimal quantity = "PALLET".equals(cargo.priceBasis()) ? BigDecimal.valueOf(cargo.palletSpaces()) : BigDecimal.ONE; + if (booking.getAgreedAmount() == null || booking.getAgreedAmount().signum() < 0 || cargo.unitPrice() == null + || quantity.multiply(cargo.unitPrice()).compareTo(booking.getAgreedAmount()) != 0) + throw conflict("外接约定运费与实收计价依据不一致"); + if (!Set.of("CNY", "USD").contains(cargo.currency())) throw conflict("外接运费入账仅支持CNY或USD,请先更正约定币种"); + quotes.add(new Quote(booking.getBookingNo(), booking.getRecordVersion(), booking.getIncomingNo(), + accepted.source().inboundCode(), booking.getCustomerName(), booking.getDestinationKey(), cargo.currency(), + booking.getAgreedAmount(), quantity, "PALLET".equals(cargo.priceBasis()) ? "托位" : "单", cargo.unitPrice())); + } + if (sources.isEmpty()) requiresFee = true; + try { + String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest( + json.writeValueAsString(List.of(dispatchNo, customerId, sources.stream().sorted(Comparator + .comparing(FleetDispatchGateway.SourceCargo::inboundCode, Comparator.nullsFirst(String::compareTo)) + .thenComparing(FleetDispatchGateway.SourceCargo::warehousePalletId, Comparator.nullsFirst(String::compareTo)) + .thenComparing(FleetDispatchGateway.SourceCargo::destinationKey, Comparator.nullsFirst(String::compareTo)) + .thenComparingInt(FleetDispatchGateway.SourceCargo::packages)).toList(), quotes)).getBytes(StandardCharsets.UTF_8))); + return new Preview(hash, requiresFee, List.copyOf(quotes), List.copyOf(priorInboundCodes)); + } catch (java.security.NoSuchAlgorithmException | com.fasterxml.jackson.core.JsonProcessingException error) { + throw new IllegalStateException(error); + } + } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingIncomingGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingIncomingGateway.java new file mode 100644 index 000000000..35b41658f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingIncomingGateway.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.fleet.trip; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +/** The transfer slice owns coming-goods documents and physical receiving facts. */ +public interface FleetBookingIncomingGateway { + record Incoming(String customerId,String warehouseId,String warehouseName,String bookingNo,FleetBookingModels.Cargo cargo, + LocalDateTime estimatedArrivalAt,String deliveryInstructions,String privateAddress,String postalCode,String recipientName,String recipientPhone) { } + record Pallet(String warehousePalletId,String palletNo,int packages,BigDecimal weightKg,BigDecimal volumeCbm) { } + record Evidence(String incomingNo,String inboundCode,String warehouseId,String destinationKey,String status, + String receiptHash,List pallets,int receivedPackages) { } + Evidence create(String operator, Incoming incoming); + Evidence inspect(String customerId,String incomingNo,boolean lock); + Evidence confirm(String customerId,String incomingNo,String operator,String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingMapper.java new file mode 100644 index 000000000..b4e577fc6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingMapper.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.*; +import java.util.List; + +@Mapper +public interface FleetBookingMapper extends BaseMapper { + @Select("SELECT * FROM ll_fleet_external_booking WHERE incoming_no=#{incomingNo} AND status <> 'CANCELLED'") + FleetBookingEntity incoming(@Param("incomingNo") String incomingNo); + @Select("SELECT * FROM ll_fleet_external_booking WHERE incoming_no=#{incomingNo}") + FleetBookingEntity incomingForFreight(@Param("incomingNo") String incomingNo); + @Select("SELECT * FROM ll_fleet_external_booking WHERE trip_id=#{tripId} ORDER BY id") + List list(@Param("tripId") long tripId); + @Select("SELECT * FROM ll_fleet_external_booking WHERE trip_id=#{tripId} AND created_by=#{operator} AND idempotency_key=#{key}") + FleetBookingEntity created(@Param("tripId") long tripId,@Param("operator") String operator,@Param("key") String key); + @Select("SELECT * FROM ll_fleet_external_booking WHERE trip_id=#{tripId} AND booking_no=#{number} FOR UPDATE") + FleetBookingEntity lock(@Param("tripId") long tripId,@Param("number") String number); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingModels.java new file mode 100644 index 000000000..1016e380a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingModels.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +public final class FleetBookingModels { + private FleetBookingModels() { } + public record Cargo(@NotBlank @Size(max=160) String destinationKey, @Size(max=255) String cargoName, + @Min(1) int packageCount, @Min(1) Integer palletSpaces, @DecimalMin("0.001") @Digits(integer=9, fraction=3) BigDecimal weightKg, + @DecimalMin("0.001") @Digits(integer=9, fraction=3) BigDecimal volumeCbm, + @Pattern(regexp="PALLET|ORDER") String priceBasis, @DecimalMin("0.00") @Digits(integer=10, fraction=2) BigDecimal unitPrice, + @Size(max=3) String currency, @Size(max=128) String contactName, @Size(max=128) String contactPhone, @Size(max=1000) String remark) { } + public record Create(@NotNull @Min(0) Long tripRecordVersion, @NotNull @Positive Long customerId, + @NotNull @Valid Cargo cargo, @NotBlank @Size(min=8,max=128) String idempotencyKey, @Valid FleetQuoteCandidateModels.QuoteRef quoteRef) { } + public record Update(@NotNull @Min(0) Long tripRecordVersion, @NotNull @Min(0) Long recordVersion, @NotNull @Valid Cargo cargo, @Valid FleetQuoteCandidateModels.QuoteRef quoteRef) { } + public record QuoteCandidatesQuery(@NotNull @Positive Long customerId,@NotBlank @Size(max=160) String destinationKey, + @NotBlank @Pattern(regexp="CNY|USD") String currency,@NotBlank @Pattern(regexp="PALLET|ORDER") String priceBasis, + @Min(1) Integer page,@Min(1) @Max(100) Integer size) { + public QuoteCandidatesQuery {page=page==null?1:page;size=size==null?20:size;} + } + public record Command(@NotNull @Min(0) Long tripRecordVersion, @NotNull @Min(0) Long recordVersion, + @NotBlank @Size(min=8,max=128) String idempotencyKey, @Size(max=1000) String reason) { } + public record StartIncoming(@NotNull @Min(0) Long tripRecordVersion,@NotNull @Min(0) Long recordVersion, + Instant estimatedArrivalAt,@Size(max=1000) String deliveryInstructions,@Size(max=1000) String privateAddress, + @Size(max=32) String postalCode,@Size(max=128) String recipientName,@Size(max=64) String recipientPhone, + @NotBlank @Size(min=8,max=128) String idempotencyKey) { } + public record PalletSpaces(@NotBlank String warehousePalletId,@Min(1) int palletSpaces) { } + public record AcceptReceipt(@NotNull @Min(0) Long tripRecordVersion,@NotNull @Min(0) Long recordVersion, + @NotBlank String receiptHash,@NotEmpty List<@Valid PalletSpaces> pallets, + @NotNull @DecimalMin("0.00") @Digits(integer=10,fraction=2) BigDecimal unitPrice, + @NotBlank @Size(min=8,max=128) String idempotencyKey) { } + public record BindDispatch(@NotNull @Min(0) Long tripRecordVersion,@NotNull @Min(0) Long recordVersion, + @NotBlank @Size(max=64) String dispatchNo,@NotBlank @Size(min=8,max=128) String idempotencyKey) { } + public record IncomingPreview(long tripRecordVersion,long recordVersion,FleetBookingIncomingGateway.Evidence incoming) { } + public record AcceptedReceipt(FleetBookingIncomingGateway.Evidence source,List pallets,String operatorId,Instant confirmedAt) { } + public record Response(String bookingNo, String tripNo, String customerId, String customerCode, String customerName, + String status, Cargo cargo, String destinationLabel, BigDecimal agreedAmount, long recordVersion, Instant updatedAt, String incomingNo, String dispatchNo, AcceptedReceipt receipt, + FleetQuoteCandidateModels.SourceSnapshot quoteSource) { } + public record Result(long tripRecordVersion, Response booking, boolean replayed) { } + public record ListResult(long tripRecordVersion, List records) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingQuoteContext.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingQuoteContext.java new file mode 100644 index 000000000..0682f0f7a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingQuoteContext.java @@ -0,0 +1,62 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.lastleg.fleet.driver.FleetWarehouseClient; +import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; +import java.time.DateTimeException; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.Locale; +import java.util.Set; +import java.util.TreeSet; + +/** Resolve quote eligibility from the trip and authoritative warehouse facts, never browser hints. */ +@Component +public class FleetBookingQuoteContext { + private final FleetWarehouseClient warehouses; + private final FleetBookingReservations reservations; + private final String token; + public FleetBookingQuoteContext(FleetWarehouseClient warehouses, FleetBookingReservations reservations, + @Value("${baiyue.security.internal-service-token:}") String token) { + this.warehouses = warehouses; this.reservations = reservations; this.token = token; + } + + public FleetQuoteCandidateModels.Context resolve(FleetTripEntity trip, long customerId, + String destinationKey, String currency, String priceBasis) { + if (customerId <= 0 || trip.getWarehouseId() == null || trip.getPlannedDepartureAt() == null + || FleetBookingReservations.index(reservations.stops(trip), destinationKey) < 1) + throw conflict("请选择本车已有卸货站和委托客户"); + if (destinationKey == null || !destinationKey.startsWith("FBA:") || destinationKey.length() <= 4) + throw conflict("该卸货站没有报价库目的仓码,请手填本次成交价"); + if (!Set.of("CNY", "USD").contains(currency == null ? "" : currency) + || !Set.of("PALLET", "ORDER").contains(priceBasis == null ? "" : priceBasis)) + throw conflict("请选择报价币种及计价方式"); + FleetWarehouseClient.Detail detail; + try { + var response = warehouses.activeDetail(token, trip.getWarehouseId()); + if (response == null || !response.successful() || response.data() == null) throw unavailable(); + detail = response.data(); + } catch (ResponseStatusException e) { throw e; } + catch (RuntimeException e) { throw unavailable(); } + var warehouse = detail.warehouse(); + if (warehouse == null || !trip.getWarehouseId().equals(warehouse.id()) || !Long.valueOf(0).equals(warehouse.tenantId()) + || !"ACTIVE".equals(warehouse.status()) || !Set.of("OVERSEAS", "OVERSEAS_PARTNER").contains(warehouse.warehouseType() == null ? "" : warehouse.warehouseType())) + throw conflict("起运仓资料已变化,请重新核对"); + var cities = new TreeSet(); + if (detail.addresses() != null) for (var address : detail.addresses()) { + if (address != null && trip.getWarehouseId().equals(address.warehouseId()) && Long.valueOf(0).equals(address.tenantId()) + && address.city() != null && !address.city().isBlank()) cities.add(address.city().trim().toLowerCase(Locale.ROOT)); + } + if (cities.size() != 1) throw conflict("起运仓城市尚未明确,请完善仓库资料或手填本次成交价"); + ZoneId zone; + try { zone = ZoneId.of(warehouse.timezone() == null ? "" : warehouse.timezone().trim()); } + catch (DateTimeException e) { throw conflict("起运仓时区尚未明确,请完善仓库资料或手填本次成交价"); } + var date = trip.getPlannedDepartureAt().toInstant(ZoneOffset.UTC).atZone(zone).toLocalDate(); + return new FleetQuoteCandidateModels.Context(customerId, cities.first(), destinationKey.substring(4), currency, date, priceBasis); + } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException unavailable() { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "起运仓资料暂不可用,请重试"); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java new file mode 100644 index 000000000..8f534866d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java @@ -0,0 +1,89 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy; +import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; +import java.time.ZoneOffset; +import java.util.*; + +/** Read under the parent trip lock whenever changing reservations or capacity. */ +@Component +public class FleetBookingReservations { + private final FleetBookingMapper bookings; + private final ObjectMapper json; + public FleetBookingReservations(FleetBookingMapper bookings, ObjectMapper json) { this.bookings=bookings; this.json=json; } + public boolean hasConfirmed(long tripId) { return bookings.list(tripId).stream().anyMatch(row -> unbound(row)); } + public List responses(long tripId) { return bookings.list(tripId).stream().map(this::response).toList(); } + public FleetBookingModels.Cargo cargo(FleetBookingEntity row) { return read(row.getCargoJson(), FleetBookingModels.Cargo.class); } + public FleetBookingModels.Response response(FleetBookingEntity row) { + return new FleetBookingModels.Response(row.getBookingNo(),row.getTripNo(),row.getCustomerId(),row.getCustomerCode(),row.getCustomerName(), + row.getStatus(),cargo(row),row.getDestinationLabel(),row.getAgreedAmount(),row.getRecordVersion(),row.getUpdatedAt().toInstant(ZoneOffset.UTC),row.getIncomingNo(),row.getDispatchNo(),receipt(row),quoteSource(row)); + } + public FleetQuoteCandidateModels.SourceSnapshot quoteSource(FleetBookingEntity row){return row.getQuoteSourceJson()==null?null:read(row.getQuoteSourceJson(),FleetQuoteCandidateModels.SourceSnapshot.class);} + public List segments(FleetTripEntity trip, FleetExecutionModels.PlannedLoad own, + Long excludedId, FleetBookingEntity addition) { + var stops=stops(trip); var loads=new ArrayList(); + for (var pallet:own.pallets()) loads.add(new TripCapacityPolicy.Booking("PALLET:"+pallet.warehousePalletId(),0,pallet.deliveryIndex(), + new TripCapacityPolicy.Load(pallet.palletSpaces(),pallet.weightKg(),pallet.volumeCbm()))); + for(var row:bookings.list(trip.getId())) if(unbound(row) && !Objects.equals(row.getId(),excludedId)) add(loads,stops,row); + if(addition!=null) add(loads,stops,addition); + try { return TripCapacityPolicy.calculate(stops.size(),new TripCapacityPolicy.Load(trip.getMaxPalletSpaces(),trip.getMaxLoadKg(),trip.getMaxVolumeCbm()),loads); } + catch(IllegalArgumentException|ArithmeticException error){throw conflict(error.getMessage());} + } + public void requireReassignmentFits(FleetTripEntity trip, Integer spaces, java.math.BigDecimal weight, java.math.BigDecimal volume) { + if(!hasConfirmed(trip.getId())) return; + if(trip.getPlannedLoadJson()==null) throw conflict("已有外接承运,请先重新核对计划容量再改派"); + var own=read(trip.getPlannedLoadJson(),FleetExecutionModels.PlannedLoad.class); + for(var segment:segments(trip,own,null,null)) if(segment.occupied().palletSpaces()>spaces + ||segment.occupied().weightKg().compareTo(weight)>0||segment.occupied().volumeCbm().compareTo(volume)>0) + throw conflict("替换车辆无法容纳自有货及已确认外接预订"); + } + public FleetBookingModels.AcceptedReceipt receipt(FleetBookingEntity row){return row.getReceiptJson()==null?null:read(row.getReceiptJson(),FleetBookingModels.AcceptedReceipt.class);} + private static boolean unbound(FleetBookingEntity row){return Set.of("CONFIRMED","RECEIVED").contains(row.getStatus());} + public void bindIncoming(FleetTripEntity trip,FleetDispatchGateway.Snapshot dispatch,List sources,String operator){ + var bySource=sources.stream().filter(row->row.inboundCode()!=null).collect(java.util.stream.Collectors.groupingBy(FleetDispatchGateway.SourceCargo::inboundCode)); + for(var entry:bySource.entrySet()){ + var row=bookings.incoming(entry.getKey());if(row==null)continue; + if(!Objects.equals(row.getTripId(),trip.getId()))throw conflict("此外接货已承诺给其他车次,请先调整原预订"); + if(!Objects.equals(row.getCustomerId(),dispatch.customerId())||!"RECEIVED".equals(row.getStatus()))throw conflict("外接货须先确认实收货量及运费,再关联排车"); + var accepted=receipt(row);if(accepted==null)throw conflict("外接货缺少实收确认"); + var expected=new TreeMap(); + for(var pallet:accepted.source().pallets())expected.merge(pallet.warehousePalletId(),pallet.packages(),Math::addExact); + var selected=new TreeMap(); + for(var item:entry.getValue()){ + if(item.warehousePalletId()==null||!Objects.equals(item.destinationKey(),row.getDestinationKey()))throw conflict("排车货物托盘或目的地与外接实收不一致"); + selected.merge(item.warehousePalletId(),item.packages(),Math::addExact); + } + if(!selected.equals(expected))throw conflict("当前排车未完整包含此外接预订的实收货物"); + row.setStatus("BOUND");row.setDispatchNo(dispatch.dispatchNo());touch(row,operator); + } + } + public void unbindIncoming(FleetTripEntity trip,String dispatchNo,String operator){ + for(var row:bookings.list(trip.getId()))if("BOUND".equals(row.getStatus())&&Objects.equals(row.getDispatchNo(),dispatchNo)){ + row.setStatus("RECEIVED");row.setDispatchNo(null);touch(row,operator); + } + } + private void touch(FleetBookingEntity row,String operator){row.setRecordVersion(row.getRecordVersion()+1);row.setUpdatedBy(operator);row.setUpdatedAt(java.time.LocalDateTime.now(ZoneOffset.UTC));if(bookings.updateById(row)!=1)throw conflict("外接关联已变化");} + public void requireRoute(FleetTripEntity trip,List stops) { + for(var row:bookings.list(trip.getId())) if((unbound(row)||"BOUND".equals(row.getStatus())) && index(stops,row.getDestinationKey())<1) + throw conflict("路线仍须包含已确认外接预订的卸货站"); + } + private void add(List loads,List stops,FleetBookingEntity row){ + var cargo=cargo(row); int index=index(stops,row.getDestinationKey()); + if(index<1) throw conflict("外接预订卸货站不在当前路线"); + if(cargo.palletSpaces()==null||cargo.weightKg()==null||cargo.volumeCbm()==null) throw conflict("确认承运前请补齐预计托位、重量和体积"); + try {loads.add(new TripCapacityPolicy.Booking(row.getBookingNo(),0,index,new TripCapacityPolicy.Load(cargo.palletSpaces(),cargo.weightKg(),cargo.volumeCbm())));} + catch(IllegalArgumentException error){throw conflict(error.getMessage());} + } + public List stops(FleetTripEntity trip){ + try{return json.readValue(trip.getStopsJson(),new TypeReference<>(){});}catch(JsonProcessingException error){throw new IllegalStateException(error);} + } + public static int index(List stops,String key){for(int i=1;iT read(String value,Class type){try{return json.readValue(value,type);}catch(JsonProcessingException error){throw new IllegalStateException(error);}} + private static ResponseStatusException conflict(String reason){return new ResponseStatusException(HttpStatus.CONFLICT,reason);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java new file mode 100644 index 000000000..5efcb2111 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java @@ -0,0 +1,203 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels; +import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateService; +import org.springframework.security.core.context.SecurityContextHolder; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Currency; +import java.util.HexFormat; +import java.util.Objects; + +@Service +public class FleetBookingService { + private final FleetTripMapper trips; + private final FleetBookingMapper bookings; + private final FleetBookingReservations reservations; + private final FleetTripService history; + private final FleetTripExecutionService execution; + private final FleetCustomerDirectory customers; + private final ObjectMapper json; + private final FleetBookingIncomingGateway incoming; + private final FleetBookingQuoteContext quoteContext; + private final FleetQuoteCandidateService quotes; + public FleetBookingService(FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingReservations reservations, + FleetTripService history,FleetTripExecutionService execution,FleetCustomerDirectory customers,ObjectMapper json,FleetBookingIncomingGateway incoming, + FleetBookingQuoteContext quoteContext,FleetQuoteCandidateService quotes){ + this.trips=trips;this.bookings=bookings;this.reservations=reservations;this.history=history;this.execution=execution;this.customers=customers;this.json=json;this.incoming=incoming; + this.quoteContext=quoteContext;this.quotes=quotes; + } + @Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) + public FleetBookingModels.ListResult list(String tripNo){var trip=trip(tripNo,false);return new FleetBookingModels.ListResult(trip.getRecordVersion(),reservations.responses(trip.getId()));} + @Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) + public PageResult quoteCandidates(String tripNo,FleetBookingModels.QuoteCandidatesQuery query){ + var context=quoteContext.resolve(trip(tripNo,false),query.customerId(),query.destinationKey(),query.currency(),query.priceBasis()); + return quotes.candidates(new FleetQuoteCandidateModels.Query(context.customerId(),context.originCity(),context.destinationCode(),context.currency(),context.effectiveOn(),context.priceBasis(),query.page(),query.size())); + } + @Transactional + public FleetBookingModels.Result create(String tripNo,String operator,FleetBookingModels.Create request){ + var trip=trip(tripNo,true);String hash=hash(request); + var existing=bookings.created(trip.getId(),operator,request.idempotencyKey()); + if(existing!=null){if(!hash.equals(existing.getRequestHash()))throw conflict("同一登记请求不能变更客户或货物内容");return result(trip,existing,true);} + requireTrip(trip,request.tripRecordVersion());var customer=customers.required(request.customerId()); + var row=new FleetBookingEntity();row.setId(IdWorker.getId());row.setBookingNo("FB-"+row.getId());row.setTripId(trip.getId());row.setTripNo(tripNo); + row.setCustomerId(customer.id());row.setCustomerCode(customer.code());row.setCustomerName(customer.name());row.setStatus("DRAFT"); + apply(trip,row,request.cargo());applyQuote(trip,row,request.quoteRef());row.setIdempotencyKey(request.idempotencyKey());row.setRequestHash(hash);row.setRecordVersion(0L); + row.setCreatedBy(operator);row.setCreatedAt(now());row.setUpdatedBy(operator);row.setUpdatedAt(row.getCreatedAt()); + bookings.insert(row);history.recordExecution(trip,request.tripRecordVersion(),"BOOKING_CREATE","登记外接询价",operator); + return result(trip,row,false); + } + @Transactional + public FleetBookingModels.Result update(String tripNo,String bookingNo,String operator,FleetBookingModels.Update request){ + var trip=trip(tripNo,true);requireTrip(trip,request.tripRecordVersion());var row=booking(trip,bookingNo);requireVersion(row,request.recordVersion()); + if(!"DRAFT".equals(row.getStatus()))throw conflict("已确认承运的货物请先取消预订后重新登记,原记录保留"); + apply(trip,row,request.cargo());applyQuote(trip,row,request.quoteRef());save(trip,row,operator,"BOOKING_EDIT","更新外接询价");return result(trip,row,false); + } + @Transactional + public FleetBookingModels.Result confirm(String tripNo,String bookingNo,String operator,FleetBookingModels.Command request){ + var trip=trip(tripNo,true);var row=booking(trip,bookingNo);String hash=hash(request); + if(Objects.equals(row.getConfirmKey(),request.idempotencyKey())){if(!hash.equals(row.getConfirmHash()))throw conflict("承运确认请求内容不一致");return result(trip,row,true);} + requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion()); + if(!"DRAFT".equals(row.getStatus()))throw conflict("仅询价中的外接货可以确认承运"); + customers.required(Long.parseLong(row.getCustomerId()));var cargo=reservations.cargo(row); + if(row.getAgreedAmount()==null||cargo.currency()==null)throw conflict("确认承运前请填写约定单价和币种"); + if(!java.util.Set.of("CNY","USD").contains(cargo.currency()))throw conflict("首版运费入账支持CNY或USD,请调整约定币种"); + var source=reservations.quoteSource(row); + if(source!=null)row.setQuoteSourceJson(write(resolveQuote(trip,row,new FleetQuoteCandidateModels.QuoteRef(source.quoteNo(),source.recordVersion(),source.priceItem())))); + var plan=execution.capacityPlan(tripNo); + if(!plan.planValid()||plan.blockReason()!=null)throw conflict("请先核对本车自有货计划容量,再确认外接承运"); + reservations.segments(trip,plan.capacityPlan(),null,row); + row.setStatus("CONFIRMED");row.setConfirmKey(request.idempotencyKey());row.setConfirmHash(hash); + save(trip,row,operator,"BOOKING_CONFIRM",reason(request.reason(),"确认外接承运并预留容量"));return result(trip,row,false); + } + @Transactional + public FleetBookingModels.Result cancel(String tripNo,String bookingNo,String operator,FleetBookingModels.Command request){ + var trip=trip(tripNo,true);var row=booking(trip,bookingNo);String hash=hash(request); + if(Objects.equals(row.getCancelKey(),request.idempotencyKey())){if(!hash.equals(row.getCancelHash()))throw conflict("取消请求内容不一致");return result(trip,row,true);} + requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion()); + if(!java.util.Set.of("DRAFT","CONFIRMED","RECEIVED").contains(row.getStatus()))throw conflict("请先解除尚未开工的排车关联,再取消预订;已拣货须走现场撤回流程"); + row.setStatus("CANCELLED");row.setCancelKey(request.idempotencyKey());row.setCancelHash(hash); + save(trip,row,operator,"BOOKING_CANCEL",reason(request.reason(),"取消未执行外接预订并释放容量"));return result(trip,row,false); + } + @Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) + public FleetBookingModels.IncomingPreview incoming(String tripNo,String bookingNo){ + var trip=trip(tripNo,false);var row=bookings.list(trip.getId()).stream().filter(value->value.getBookingNo().equals(bookingNo)).findFirst() + .orElseThrow(()->new ResponseStatusException(HttpStatus.NOT_FOUND,"外接预订不存在")); + return new FleetBookingModels.IncomingPreview(trip.getRecordVersion(),row.getRecordVersion(),row.getIncomingNo()==null?null:incoming.inspect(row.getCustomerId(),row.getIncomingNo(),false)); + } + @Transactional + public FleetBookingModels.Result startIncoming(String tripNo,String bookingNo,String operator,FleetBookingModels.StartIncoming request){ + var trip=trip(tripNo,true);var row=booking(trip,bookingNo);String hash=hash(request); + if(Objects.equals(row.getIncomingKey(),request.idempotencyKey())){if(!hash.equals(row.getIncomingHash()))throw conflict("来货登记请求内容不一致");return result(trip,row,true);} + requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion()); + if(!"CONFIRMED".equals(row.getStatus())||row.getIncomingNo()!=null)throw conflict("仅已确认且尚未登记来货的预订允许此操作"); + customers.required(Long.parseLong(row.getCustomerId())); + var arrival=request.estimatedArrivalAt()==null?trip.getPlannedDepartureAt().toInstant(ZoneOffset.UTC).minusSeconds(7200):request.estimatedArrivalAt(); + var evidence=incoming.create(operator,new FleetBookingIncomingGateway.Incoming(row.getCustomerId(),trip.getWarehouseId().toString(),trip.getWarehouseName(),row.getBookingNo(),reservations.cargo(row), + LocalDateTime.ofInstant(arrival,java.time.ZoneId.systemDefault()),request.deliveryInstructions(),request.privateAddress(),request.postalCode(),request.recipientName(),request.recipientPhone())); + requireEvidence(trip,row,evidence); + row.setIncomingNo(evidence.incomingNo());row.setIncomingKey(request.idempotencyKey());row.setIncomingHash(hash); + save(trip,row,operator,"BOOKING_INCOMING","登记外接来货 "+evidence.incomingNo());return result(trip,row,false); + } + @Transactional + public FleetBookingModels.Result acceptReceipt(String tripNo,String bookingNo,String operator,FleetBookingModels.AcceptReceipt request){ + var trip=trip(tripNo,true);var row=booking(trip,bookingNo);String hash=hash(request); + if(Objects.equals(row.getReceiptKey(),request.idempotencyKey())){if(!hash.equals(row.getReceiptHash()))throw conflict("实收确认请求内容不一致");return result(trip,row,true);} + requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion()); + if(!"CONFIRMED".equals(row.getStatus())||row.getIncomingNo()==null)throw conflict("请先登记来货并完成仓库实际收货"); + var evidence=incoming.inspect(row.getCustomerId(),row.getIncomingNo(),true);requireEvidence(trip,row,evidence); + if(!java.util.Set.of("WAIT_CUSTOMER_CONFIRMATION","READY_FOR_DISPATCH").contains(evidence.status())||evidence.pallets().isEmpty())throw conflict("仓库尚未完成实收及测量"); + if(!Objects.equals(evidence.receiptHash(),request.receiptHash()))throw conflict("实收数据已变化,请刷新后重新核对"); + var spaces=new java.util.HashMap(); + for(var entry:request.pallets())if(entry.palletSpaces()<1||spaces.put(entry.warehousePalletId(),entry.palletSpaces())!=null)throw conflict("请为每个实际托盘填写一次占用托位"); + var physical=evidence.pallets().stream().map(FleetBookingIncomingGateway.Pallet::warehousePalletId).collect(java.util.stream.Collectors.toSet()); + if(!spaces.keySet().equals(physical)||physical.contains(null))throw conflict("托位确认须与实际收货托盘一致"); + BigDecimal weight=BigDecimal.ZERO,volume=BigDecimal.ZERO;int count=0; + for(var pallet:evidence.pallets()){ + if(pallet.packages()<=0||pallet.weightKg()==null||pallet.volumeCbm()==null||pallet.weightKg().signum()<=0||pallet.volumeCbm().signum()<=0)throw conflict("实际托盘缺少有效箱数、重量或体积"); + weight=weight.add(pallet.weightKg());volume=volume.add(pallet.volumeCbm());count=Math.addExact(count,pallet.packages()); + } + int totalSpaces=spaces.values().stream().reduce(0,Math::addExact);var old=reservations.cargo(row); + apply(trip,row,new FleetBookingModels.Cargo(old.destinationKey(),old.cargoName(),count,totalSpaces,weight,volume,old.priceBasis(),request.unitPrice(),old.currency(),old.contactName(),old.contactPhone(),old.remark())); + var plan=execution.capacityPlan(tripNo); + if(!plan.planValid()||plan.blockReason()!=null)throw conflict("请先重新核对自有货容量计划,再确认外接实收"); + reservations.segments(trip,plan.capacityPlan(),row.getId(),row); + var confirmed=incoming.confirm(row.getCustomerId(),row.getIncomingNo(),operator,"FLEET:"+bookingNo+":RECEIPT"); + if(!"READY_FOR_DISPATCH".equals(confirmed.status())||!Objects.equals(confirmed.receiptHash(),evidence.receiptHash()))throw conflict("来货实收确认未完成或内容发生变化,请重新核对"); + row.setReceiptJson(write(new FleetBookingModels.AcceptedReceipt(confirmed,java.util.List.copyOf(request.pallets()),operator,java.time.Instant.now()))); + row.setStatus("RECEIVED");row.setReceiptKey(request.idempotencyKey());row.setReceiptHash(hash); + save(trip,row,operator,"BOOKING_RECEIPT","确认外接实收货量及约定运费 "+row.getBookingNo());return result(trip,row,false); + } + @Transactional + public FleetBookingModels.Result bindDispatch(String tripNo,String bookingNo,String operator,FleetBookingModels.BindDispatch request){ + var trip=trip(tripNo,true);var row=booking(trip,bookingNo);String hash=hash(request); + if(Objects.equals(row.getBindingKey(),request.idempotencyKey())){if(!hash.equals(row.getBindingHash()))throw conflict("关联发运请求内容不一致");return result(trip,row,true);} + requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion()); + if(!"RECEIVED".equals(row.getStatus()))throw conflict("请先确认外接实收货量和运费"); + row.setBindingKey(request.idempotencyKey());row.setBindingHash(hash);bookings.updateById(row); + var linked=history.bindDispatch(tripNo,operator,new FleetTripModels.BindingCommand(request.tripRecordVersion(),request.dispatchNo(),"关联外接预订 "+bookingNo)); + var result=booking(trip,bookingNo); + if(!"BOUND".equals(result.getStatus())||!Objects.equals(result.getDispatchNo(),request.dispatchNo().trim()))throw conflict("所选排车未包含此外接来货单的实际货物"); + return new FleetBookingModels.Result(linked.trip().recordVersion(),reservations.response(result),false); + } + private static void requireEvidence(FleetTripEntity trip,FleetBookingEntity row,FleetBookingIncomingGateway.Evidence evidence){ + if(evidence==null||evidence.incomingNo()==null||!Objects.equals(trip.getWarehouseId().toString(),evidence.warehouseId())||!Objects.equals(row.getDestinationKey(),evidence.destinationKey()))throw conflict("来货单仓库或目的地与外接承运约定不一致"); + if(row.getIncomingNo()!=null&&!Objects.equals(row.getIncomingNo(),evidence.incomingNo()))throw conflict("来货单引用不一致"); + } + private FleetQuoteCandidateModels.SourceSnapshot resolveQuote(FleetTripEntity trip,FleetBookingEntity row,FleetQuoteCandidateModels.QuoteRef reference){ + var cargo=reservations.cargo(row); + return quotes.lockAndResolve(quoteContext.resolve(trip,Long.parseLong(row.getCustomerId()),cargo.destinationKey(),cargo.currency(),cargo.priceBasis()),reference); + } + private void applyQuote(FleetTripEntity trip,FleetBookingEntity row,FleetQuoteCandidateModels.QuoteRef reference){ + if(reference!=null || row.getQuoteSourceJson()!=null)requireQuotePermission(); + row.setQuoteSourceJson(reference==null?null:write(resolveQuote(trip,row,reference))); + } + private static void requireQuotePermission(){ + var authentication=SecurityContextHolder.getContext().getAuthentication(); + if(authentication==null || authentication.getAuthorities().stream().noneMatch(a->"platform:fleet:quote:list".equals(a.getAuthority()))) + throw new ResponseStatusException(HttpStatus.FORBIDDEN,"引用或清除报价来源需要报价读取权限"); + } + private void apply(FleetTripEntity trip,FleetBookingEntity row,FleetBookingModels.Cargo input){ + var stops=reservations.stops(trip);int index=FleetBookingReservations.index(stops,input.destinationKey()); + if(index<1)throw conflict("请选择本车已有卸货站,外接货首版从本起运仓装车"); + String basis=input.priceBasis()==null?"PALLET":input.priceBasis();String currency=trim(input.currency()); + if(!basis.equals("PALLET")&&!basis.equals("ORDER"))throw bad("请选择按托或整单报价"); + if(currency!=null){currency=currency.toUpperCase(java.util.Locale.ROOT);try{Currency.getInstance(currency);}catch(IllegalArgumentException error){throw bad("币种无效");}} + var cargo=new FleetBookingModels.Cargo(input.destinationKey(),trim(input.cargoName()),input.packageCount(),input.palletSpaces(),input.weightKg(),input.volumeCbm(), + basis,input.unitPrice(),currency,trim(input.contactName()),trim(input.contactPhone()),trim(input.remark())); + row.setCargoJson(write(cargo));row.setDestinationKey(cargo.destinationKey());row.setDestinationLabel(stops.get(index).name()); + BigDecimal amount=null; + if(input.unitPrice()!=null && (basis.equals("ORDER")||input.palletSpaces()!=null)) amount=input.unitPrice().multiply(BigDecimal.valueOf(basis.equals("ORDER")?1:input.palletSpaces())).setScale(2); + if(amount!=null && amount.precision()>16)throw bad("约定金额超出支持范围"); + row.setAgreedAmount(amount); + } + private void save(FleetTripEntity trip,FleetBookingEntity row,String operator,String action,String reason){ + row.setRecordVersion(row.getRecordVersion()+1);row.setUpdatedBy(operator);row.setUpdatedAt(now()); + if(bookings.updateById(row)!=1)throw conflict("外接预订已变化,请刷新"); + history.recordExecution(trip,trip.getRecordVersion(),action,reason,operator); + } + private FleetBookingModels.Result result(FleetTripEntity trip,FleetBookingEntity row,boolean replayed){return new FleetBookingModels.Result(trip.getRecordVersion(),reservations.response(row),replayed);} + private FleetTripEntity trip(String number,boolean lock){var value=lock?trips.lockByNo(number):trips.findByNo(number);if(value==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"车次不存在");return value;} + private FleetBookingEntity booking(FleetTripEntity trip,String number){var row=bookings.lock(trip.getId(),number);if(row==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"当前车次无此外接预订");return row;} + private static void requireTrip(FleetTripEntity trip,long version){if(!"PLANNED".equals(trip.getStatus()))throw conflict("仅计划中的车次允许管理外接预订");if(trip.getRecordVersion()!=version)throw conflict("车次已被修改,请刷新");} + private static void requireVersion(FleetBookingEntity row,long version){if(row.getRecordVersion()!=version)throw conflict("外接预订已被修改,请刷新");} + private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception error){throw new IllegalStateException(error);}} + private String hash(Object value){try{ObjectNode node=json.valueToTree(value);node.remove("idempotencyKey");node.remove("tripRecordVersion");return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(node).getBytes(StandardCharsets.UTF_8)));}catch(Exception error){throw new IllegalStateException(error);}} + private static LocalDateTime now(){return LocalDateTime.now(ZoneOffset.UTC);} + private static String trim(String value){return value==null||value.isBlank()?null:value.trim();} + private static String reason(String value,String fallback){return trim(value)==null?fallback:trim(value);} + private static ResponseStatusException conflict(String value){return new ResponseStatusException(HttpStatus.CONFLICT,value);} + private static ResponseStatusException bad(String value){return new ResponseStatusException(HttpStatus.BAD_REQUEST,value);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCompletionModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCompletionModels.java new file mode 100644 index 000000000..e3b6ef2f8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCompletionModels.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.fleet.trip; + +import jakarta.validation.constraints.*; +import java.time.Instant; +import java.util.List; + +public final class FleetCompletionModels { + private FleetCompletionModels() { } + public record Outcome(String dispatchNo, String deliveryNo, String customerId, String destination, + String status, int packages, Instant terminalAt, int signedPackages, int returnedPackages, int remainingPackages) { } + public record DispatchCompletion(String dispatchNo, String warehouseId, List outcomes, String blockReason) { } + public record Inspection(String tripNo, long recordVersion, boolean canComplete, String blockReason, List outcomes) { } + public record Complete(@NotNull @Min(0) Long recordVersion, @NotBlank @Size(min = 8, max = 128) String idempotencyKey, + @NotBlank @Size(max = 1000) String reason) { } + public record Completed(String idempotencyKey, String requestHash, Instant completedAt, Instant recordedAt, + String operatorId, List outcomes) { } + public record Release(@NotNull @Min(0) Long recordVersion, @NotBlank @Pattern(regexp = "VEHICLE|DRIVER") String resourceKind, + @NotNull @PastOrPresent Instant actualEndAt, @NotBlank @Size(max = 500) String endLocation, + @Pattern(regexp = "IN_SERVICE|MAINTENANCE|INACTIVE|RETURNED") String vehicleStatus, + @NotBlank @Size(min = 8, max = 128) String idempotencyKey, @NotBlank @Size(max = 1000) String reason) { } + public record Released(String idempotencyKey, String requestHash, Instant actualEndAt, String endLocation, + String vehicleStatus, String operatorId, Instant recordedAt) { } + public record ReleaseInfo(Instant actualEndAt, String endLocation, String vehicleStatus) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchBindingGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchBindingGuard.java new file mode 100644 index 000000000..9b4516de6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchBindingGuard.java @@ -0,0 +1,50 @@ +package com.baiyue.lastleg.fleet.trip; + +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +@Service +public class FleetDispatchBindingGuard { + private final FleetTripDispatchMapper bindings; + private final com.fasterxml.jackson.databind.ObjectMapper json; + public FleetDispatchBindingGuard(FleetTripDispatchMapper bindings, com.fasterxml.jackson.databind.ObjectMapper json) { this.bindings = bindings; this.json = json; } + + /** Dispatch -> task -> binding locks; never acquire the parent trip lock from a PDA pick. */ + @Transactional(propagation = Propagation.MANDATORY) + public void requirePickingApproved(long dispatchId) { + var binding = bindings.lockByDispatch(dispatchId); + if (binding == null) throw new ResponseStatusException(HttpStatus.CONFLICT, "请先关联车次并通过调度审核"); + var review = FleetTripReview.read(json, binding.getReviewJson()); + if (!"APPROVED".equals(review.status())) throw new ResponseStatusException(HttpStatus.CONFLICT, + "REJECTED".equals(review.status()) ? "调度审核已打回:" + review.reason() : "请先通过调度审核再拣货"); + } + + @Transactional(propagation = Propagation.MANDATORY) + public void requireMatchingAssignment(long dispatchId, FleetDispatchGateway.Assignment actual, FleetDispatchGateway.Assignment requested) { + var binding = bindings.lockByDispatch(dispatchId); + if (binding != null && (changed(requested.vehiclePlate(), actual.vehiclePlate()) || changed(requested.driverName(), actual.driverName()) + || changed(requested.driverPhone(), actual.driverPhone()))) + throw new ResponseStatusException(HttpStatus.CONFLICT, "车辆和司机由车次 " + binding.getTripNo() + " 统一维护"); + } + private static boolean changed(String requested, String actual) { + return requested != null && !requested.isBlank() && !requested.trim().equals(actual); + } + + /** Must be called after locking the customer dispatch, which serializes binding and mutations. */ + @Transactional(propagation = Propagation.MANDATORY) + public void requireUnbound(long dispatchId) { + var binding = bindings.lockByDispatch(dispatchId); + if (binding != null) throw new ResponseStatusException(HttpStatus.CONFLICT, + "排车单已关联车次 " + binding.getTripNo() + ",请先在车次中解除关联"); + } + + @Transactional(propagation = Propagation.MANDATORY) + public void requireCoordinatedDeparture(long dispatchId) { + var binding = bindings.lockByDispatch(dispatchId); + if (binding == null || !"DEPARTING".equals(bindings.tripStatus(dispatchId))) + throw new ResponseStatusException(HttpStatus.CONFLICT, "请通过车次统一发车"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchGateway.java new file mode 100644 index 000000000..efcacaa39 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchGateway.java @@ -0,0 +1,34 @@ +package com.baiyue.lastleg.fleet.trip; + +import java.time.Instant; + +/** Customer dispatches remain owned by dispatch; fleet only orchestrates their physical trip. */ +public interface FleetDispatchGateway { + record Snapshot(long dispatchId, String dispatchNo, String customerId, String warehouseId, int packageCount) { } + record SourceCargo(String inboundCode,String warehousePalletId,String palletNo,int packages,String destinationKey) { } + java.util.List sourceCargo(String dispatchNo); + record Assignment(String vehiclePlate, String driverName, String driverPhone) { } + + static String destinationKey(String type,String code,String fba,String address,String postalCode) { + if(!"PRIVATE".equals(type)){String value=code==null||code.isBlank()?fba:code;return value==null||value.isBlank()?null:"FBA:"+value.trim().toUpperCase(java.util.Locale.ROOT);} + if(address==null||address.isBlank())return null; + String key=address.strip().replaceAll("\\s+"," ").toUpperCase(java.util.Locale.ROOT)+"|"+java.util.Objects.requireNonNullElse(postalCode,"").strip().toUpperCase(java.util.Locale.ROOT); + try{return "PRIVATE:"+java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(key.getBytes(java.nio.charset.StandardCharsets.UTF_8)));} + catch(java.security.NoSuchAlgorithmException error){throw new IllegalStateException(error);} + } + + /** Locks the dispatch and rejects any picking or departure already performed. */ + Snapshot lockForPlanning(String dispatchNo); + /** Allows pre-departure resource/time changes without changing cargo or recorded picking. */ + Snapshot lockForAssignment(String dispatchNo); + /** Caller holds the dispatch lock for the entire transaction, including downstream snapshots. */ + void assign(Snapshot dispatch, Assignment assignment); + FleetExecutionModels.Manifest manifest(String dispatchNo, boolean lock); + FleetCompletionModels.DispatchCompletion completion(String dispatchNo, boolean lock); + /** Actual departure/terminal evidence for an in-transit handover; does not replay departure. */ + com.baiyue.lastleg.fleet.exception.FleetTransitEvidence transitEvidence(String dispatchNo, boolean lock); + /** Updates only unfinished delivery resource snapshots; departure and financial facts stay immutable. */ + void handover(com.baiyue.lastleg.fleet.exception.FleetTransitEvidence evidence, Assignment assignment); + FleetExecutionModels.DepartedDispatch depart(String warehouseId, String dispatchNo, String operator, + String idempotencyKey, String remark, Instant departedAt); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetExecutionModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetExecutionModels.java new file mode 100644 index 000000000..cb9951283 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetExecutionModels.java @@ -0,0 +1,42 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +public final class FleetExecutionModels { + private FleetExecutionModels() { } + public record Cargo(String dispatchNo, String customerId, String dispatchItemId, String warehousePalletId, + String palletNo, int packages, String destinationKey, String destinationLabel) { } + public record Manifest(String dispatchNo, String warehouseId, String vehiclePlate, String driverName, String driverPhone, + List cargo, String blockReason) { } + public record Measurement(@NotBlank String warehousePalletId, @Min(1) int palletSpaces, + @NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal weightKg, + @NotNull @DecimalMin("0.001") @Digits(integer = 9, fraction = 3) BigDecimal volumeCbm, + @Min(1) int deliveryIndex) { } + public record Confirm(@NotNull @Min(0) Long recordVersion, @NotBlank @Pattern(regexp = "[0-9a-f]{64}") String manifestHash, + @NotEmpty @Size(max = 1000) List<@NotNull @Valid Measurement> pallets, + @NotBlank @Size(max = 1000) String reason) { } + public record ConfirmedLoad(String manifestHash, List cargo, List pallets, + List segments, String operatorId, Instant confirmedAt) { } + public record Loading(String tripNo, long recordVersion, List cargo, String manifestHash, + ConfirmedLoad confirmedLoad, String blockReason) { } + public record PlannedLoad(String manifestHash, List cargo, List pallets, + List segments, String operatorId, Instant confirmedAt) { } + public record Planning(String tripNo, long recordVersion, List cargo, String manifestHash, + PlannedLoad capacityPlan, boolean planValid, String blockReason, List availableSegments) { } + public record Depart(@NotNull @Min(0) Long recordVersion, @NotBlank @Size(min = 8, max = 128) String idempotencyKey, + @Size(max = 1000) String remark) { } + public record DepartedDispatch(String dispatchNo, long shippedPackages) { } + public record Departed(String tripNo, String status, Instant departedAt, long shippedPackages, + List dispatches, boolean replayed) { + public Departed withReplay() { return new Departed(tripNo, status, departedAt, shippedPackages, dispatches, true); } + } + public record Gate(String tripNo, Long recordVersion, int dispatchCount, boolean canDepart, String blockReason, String driverWarning, + boolean canPick, String pickBlockReason) { + public Gate withPicking(boolean allowed, String reason) { return new Gate(tripNo, recordVersion, dispatchCount, canDepart, blockReason, driverWarning, allowed, reason); } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetResourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetResourceMapper.java new file mode 100644 index 000000000..ea620ef7f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetResourceMapper.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.fleet.trip; + +import org.apache.ibatis.annotations.*; +import java.util.List; + +@Mapper +public interface FleetResourceMapper { + @Select("SELECT resource_kind,resource_id,trip_id,trip_no FROM ll_fleet_resource_assignment WHERE resource_kind=#{kind} AND resource_id=#{id}") + Assignment find(@Param("kind") String kind, @Param("id") long id); + @Select("SELECT resource_kind,resource_id,trip_id,trip_no FROM ll_fleet_resource_assignment WHERE trip_id=#{tripId} ORDER BY resource_kind,resource_id") + List byTrip(@Param("tripId") long tripId); + @Insert("INSERT INTO ll_fleet_resource_assignment(resource_kind,resource_id,trip_id,trip_no) VALUES(#{kind},#{id},#{tripId},#{tripNo})") + int reserve(@Param("kind") String kind, @Param("id") long id, @Param("tripId") long tripId, @Param("tripNo") String tripNo); + @Delete("DELETE FROM ll_fleet_resource_assignment WHERE resource_kind=#{kind} AND resource_id=#{id} AND trip_id=#{tripId}") + int release(@Param("kind") String kind, @Param("id") long id, @Param("tripId") long tripId); + record Assignment(String resourceKind, long resourceId, long tripId, String tripNo) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCompletionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCompletionService.java new file mode 100644 index 000000000..580f9122a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCompletionService.java @@ -0,0 +1,146 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService; +import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.HexFormat; +import java.util.List; +import java.util.Set; + +@Service +public class FleetTripCompletionService { + private final FleetTripMapper trips; + private final FleetTripDispatchMapper bindings; + private final FleetResourceMapper resources; + private final FleetDispatchGateway dispatches; + private final FleetTripService history; + private final VehicleRepository vehicles; + private final VehicleApplicationService vehicleService; + private final ObjectMapper json; + + public FleetTripCompletionService(FleetTripMapper trips, FleetTripDispatchMapper bindings, FleetResourceMapper resources, + FleetDispatchGateway dispatches, FleetTripService history, VehicleRepository vehicles, + VehicleApplicationService vehicleService, ObjectMapper json) { + this.trips = trips; this.bindings = bindings; this.resources = resources; this.dispatches = dispatches; + this.history = history; this.vehicles = vehicles; this.vehicleService = vehicleService; this.json = json; + } + + @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) + public FleetCompletionModels.Inspection inspection(String tripNo) { return inspect(required(tripNo, false), false); } + + @Transactional + public FleetTripModels.Detail complete(String tripNo, String operator, FleetCompletionModels.Complete request) { + var trip = required(tripNo, true); + String hash = hash(write(request)); + if (trip.getCompletionJson() != null) { + var prior = read(trip.getCompletionJson(), FleetCompletionModels.Completed.class); + if (!prior.idempotencyKey().equals(request.idempotencyKey()) || !prior.requestHash().equals(hash)) + throw conflict("车次已结案,请核对原结案请求"); + return history.detail(tripNo); + } + requireVersion(trip, request.recordVersion()); + var inspection = inspect(trip, true); + if (!inspection.canComplete()) throw conflict(inspection.blockReason()); + Instant completedAt = inspection.outcomes().stream().map(FleetCompletionModels.Outcome::terminalAt).max(Instant::compareTo).orElseThrow(); + trip.setStatus("COMPLETED"); trip.setCompletedAt(LocalDateTime.ofInstant(completedAt, ZoneOffset.UTC)); + trip.setCompletionJson(write(new FleetCompletionModels.Completed(request.idempotencyKey(), hash, completedAt, Instant.now(), operator, inspection.outcomes()))); + // Completion freezes cargo outcomes. It deliberately does not release a driver still returning. + history.recordExecution(trip, request.recordVersion(), "COMPLETE", request.reason(), operator); + return history.detail(tripNo); + } + + @Transactional + public FleetTripModels.Detail release(String tripNo, String operator, FleetCompletionModels.Release request) { + var trip = required(tripNo, true); + if (!Set.of("VEHICLE", "DRIVER").contains(request.resourceKind())) throw bad("请选择车辆或司机任务"); + boolean vehicle = "VEHICLE".equals(request.resourceKind()); + String previous = vehicle ? trip.getVehicleReleaseJson() : trip.getDriverReleaseJson(); + String hash = hash(write(request)); + if (previous != null) { + var released = read(previous, FleetCompletionModels.Released.class); + if (!released.idempotencyKey().equals(request.idempotencyKey()) || !released.requestHash().equals(hash)) + throw conflict("该资源任务已结束,请核对原结束请求"); + return history.detail(tripNo); + } + requireVersion(trip, request.recordVersion()); + if (!"COMPLETED".equals(trip.getStatus()) || trip.getCompletionJson() == null || trip.getCompletedAt() == null) + throw conflict("请先核对全部货物并完成车次结案"); + Instant now = Instant.now(); + if (request.actualEndAt() == null || request.actualEndAt().isAfter(now) + || request.actualEndAt().isBefore(trip.getCompletedAt().toInstant(ZoneOffset.UTC))) throw bad("实际任务结束时间须不早于本车货物履约完成且不晚于当前时间"); + if (request.endLocation() == null || request.endLocation().isBlank()) throw bad("请填写实际任务结束地点"); + if (vehicle && (request.vehicleStatus() == null || !Set.of("IN_SERVICE", "MAINTENANCE", "INACTIVE", "RETURNED").contains(request.vehicleStatus()))) + throw bad("请确认车辆结束任务后的实际状态"); + if (!vehicle && request.vehicleStatus() != null) throw bad("结束司机任务不能修改车辆状态"); + if (vehicle) { + var current = vehicles.lockById(trip.getVehicleId()).orElseThrow(() -> conflict("车辆档案不存在")); + requireRelease("VEHICLE", trip.getVehicleId(), trip.getId()); + vehicleService.changeStatus(trip.getVehicleId(), operator, new VehicleModels.ChangeStatus(request.vehicleStatus(), current.recordVersion())); + } else { + // A now-expired or disabled driver may end their task; this never re-enables their qualification. + requireRelease("DRIVER", trip.getDriverId(), trip.getId()); + } + var released = new FleetCompletionModels.Released(request.idempotencyKey(), hash, request.actualEndAt(), + request.endLocation().trim(), request.vehicleStatus(), operator, now); + if (vehicle) trip.setVehicleReleaseJson(write(released)); else trip.setDriverReleaseJson(write(released)); + history.recordExecution(trip, request.recordVersion(), vehicle ? "RELEASE_VEHICLE" : "RELEASE_DRIVER", request.reason(), operator); + return history.detail(tripNo); + } + + private FleetCompletionModels.Inspection inspect(FleetTripEntity trip, boolean lock) { + if (trip.getCompletionJson() != null) { + var prior = read(trip.getCompletionJson(), FleetCompletionModels.Completed.class); + return new FleetCompletionModels.Inspection(trip.getTripNo(), trip.getRecordVersion(), false, "车次货物已结案", prior.outcomes()); + } + var completions = bindings.listByTrip(trip.getId()).stream().map(binding -> dispatches.completion(binding.getDispatchNo(), lock)).toList(); + var outcomes = completions.stream().flatMap(completion -> completion.outcomes().stream()).toList(); + String reason = null; + if (!"IN_TRANSIT".equals(trip.getStatus()) || trip.getDepartedAt() == null) reason = "仅已发车的车次可以结案"; + else if (completions.isEmpty()) reason = "缺少客户执行单据,不能结案"; + else { + for (var completion : completions) { + if (!trip.getWarehouseId().toString().equals(completion.warehouseId())) { reason = "客户执行单据起运仓与车次不一致"; break; } + if (completion.blockReason() != null) { reason = completion.dispatchNo() + ":" + completion.blockReason(); break; } + } + Instant departure = trip.getDepartedAt().toInstant(ZoneOffset.UTC), now = Instant.now(); + if (reason == null && outcomes.stream().anyMatch(outcome -> outcome.packages() <= 0 || outcome.remainingPackages() != 0 + || outcome.signedPackages() < 0 || outcome.returnedPackages() < 0 + || (long) outcome.signedPackages() + outcome.returnedPackages() != outcome.packages() || outcome.terminalAt() == null + || outcome.terminalAt().isBefore(departure) || outcome.terminalAt().isAfter(now))) reason = "签收或退回时间须在发车之后且不晚于当前时间,请核对原单"; + } + return new FleetCompletionModels.Inspection(trip.getTripNo(), trip.getRecordVersion(), reason == null, reason, outcomes); + } + private void requireRelease(String kind, Long resourceId, long tripId) { + if (resourceId == null || resources.release(kind, resourceId, tripId) != 1) throw conflict("资源占用与车次不一致,结束任务未完成"); + } + private FleetTripEntity required(String tripNo, boolean lock) { + var trip = lock ? trips.lockByNo(tripNo) : trips.findByNo(tripNo); + if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在"); + return trip; + } + private static void requireVersion(FleetTripEntity trip, long version) { + if (trip.getRecordVersion() != version) throw conflict("车次已被修改,请刷新后重试"); + } + private String write(Object value) { try { return json.writeValueAsString(value); } catch (JsonProcessingException e) { throw new IllegalStateException(e); } } + private T read(String value, Class type) { try { return json.readValue(value, type); } catch (JsonProcessingException e) { throw new IllegalStateException(e); } } + private static String hash(String value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } + } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostBasis.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostBasis.java new file mode 100644 index 000000000..6570e542d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostBasis.java @@ -0,0 +1,59 @@ +package com.baiyue.lastleg.fleet.trip; + +import java.util.*; + +/** Cost weights come only from the immutable physical departure, never a reservation or current inventory. */ +final class FleetTripCostBasis { + private FleetTripCostBasis() { } + record Share(String businessNo, String sourceType, String dispatchNo, long palletSpaces) { } + record Basis(String manifestHash, List shares, String blockReason) { } + + static Basis from(FleetTripModels.AuditSnapshot snapshot) { + if (snapshot == null || snapshot.confirmedLoad() == null) return blocked("尚无实际发车装载记录"); + var load = snapshot.confirmedLoad(); + if (load.pallets() == null || load.pallets().isEmpty() || load.cargo() == null || load.cargo().isEmpty()) + return blocked("发车装载记录缺少实运托盘或货物"); + var measured = new HashMap(); + for (var pallet : load.pallets()) { + if (pallet.palletSpaces() < 1 || measured.put(pallet.warehousePalletId(), pallet) != null) + return blocked("发车实测托位缺失或托盘重复"); + } + var cargoByPallet = new HashMap>(); + for (var cargo : load.cargo()) cargoByPallet.computeIfAbsent(cargo.warehousePalletId(), ignored -> new ArrayList<>()).add(cargo); + if (!measured.keySet().equals(cargoByPallet.keySet())) return blocked("发车货物与实测托盘未核平"); + var external = new HashMap(); + if (snapshot.externalBookings() != null) for (var booking : snapshot.externalBookings()) { + if ("DRAFT".equals(booking.status()) || "CANCELLED".equals(booking.status())) continue; + if (!"BOUND".equals(booking.status()) || booking.bookingNo() == null || booking.bookingNo().isBlank() + || booking.customerId() == null || booking.dispatchNo() == null || booking.dispatchNo().isBlank() + || booking.receipt() == null || booking.receipt().source() == null) + return blocked("发车外接货缺少实收及排车依据"); + var receipt = booking.receipt().source(); + if (receipt.pallets() == null || receipt.pallets().isEmpty()) return blocked("发车外接货缺少实收托盘"); + long packages = 0; + for (var pallet : receipt.pallets()) { + var cargo = cargoByPallet.get(pallet.warehousePalletId()); + if (external.put(pallet.warehousePalletId(), booking) != null || cargo == null + || cargo.stream().anyMatch(row -> !booking.dispatchNo().equals(row.dispatchNo()) || !booking.customerId().equals(row.customerId())) + || cargo.stream().mapToLong(FleetExecutionModels.Cargo::packages).sum() != pallet.packages()) + return blocked("外接实收与发车托盘归属未核平"); + packages += pallet.packages(); + } + if (packages != receipt.receivedPackages()) return blocked("外接实收总量与发车托盘未核平"); + } + var shares = new TreeMap(); + for (var entry : cargoByPallet.entrySet()) { + var owners = entry.getValue().stream().map(FleetExecutionModels.Cargo::dispatchNo).distinct().toList(); + if (owners.size() != 1) return blocked("同一实物托盘关联多张排车单,暂不可按托位自动分摊"); + var booking = external.get(entry.getKey()); + String number = booking == null ? owners.get(0) : booking.bookingNo(); + var previous = shares.get(number); + long spaces = measured.get(entry.getKey()).palletSpaces() + (previous == null ? 0 : previous.palletSpaces()); + shares.put(number, new Share(number, booking == null ? "OWN" : "EXTERNAL", owners.get(0), spaces)); + } + String reason = load.pallets().stream().map(FleetExecutionModels.Measurement::deliveryIndex).distinct().count() == 1 + ? null : "各票卸货站不同,暂不可按同路线托位自动分摊"; + return new Basis(load.manifestHash(), List.copyOf(shares.values()), reason); + } + private static Basis blocked(String reason) { return new Basis(null, List.of(), reason); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostModels.java new file mode 100644 index 000000000..cf8164332 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostModels.java @@ -0,0 +1,29 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.common.core.api.PageResult; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +public final class FleetTripCostModels { + private FleetTripCostModels() { } + public record Draft(@NotBlank @Pattern(regexp="FUEL|DRIVER|TOLL|PARKING|OTHER") String category, + @NotBlank @Pattern(regexp="CNY|USD") String currency, + @NotNull @DecimalMin("0") @Digits(integer=12,fraction=2) BigDecimal amount, + @NotNull Instant incurredAt, @Size(max=1000) String remark) { } + public record Create(@NotBlank @Size(min=8,max=128) String idempotencyKey, @NotNull @Valid Draft details) { } + public record Update(@NotNull @Min(0) Long recordVersion, @NotNull @Valid Draft details, @Size(max=1000) String reason) { } + public record Query(@Min(1) Integer page, @Min(1) @Max(100) Integer size) { + public Query { page = page == null ? 1 : page; size = size == null ? 10 : size; } + public long offset() { return ((long) page - 1) * size; } + } + public record Cost(String costNo, String tripNo, Draft details, long recordVersion, Instant updatedAt) { } + public record Allocation(String businessNo, String sourceType, String dispatchNo, long palletSpaces, BigDecimal amount) { } + public record Total(String currency, BigDecimal directCost, List allocations, BigDecimal ownCost, BigDecimal externalCost) { } + public record Summary(Long departureVersion, String manifestHash, String allocationMethod, String allocationBlockReason, List totals) { } + public record Listing(PageResult page, Summary summary) { } + public record Snapshot(Cost cost, Summary summary) { } + public record History(long recordVersion, String action, String reason, Instant occurredAt, Snapshot snapshot) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostRepository.java new file mode 100644 index 000000000..3da0d13db --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostRepository.java @@ -0,0 +1,93 @@ +package com.baiyue.lastleg.fleet.trip; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; +import java.math.BigDecimal; +import java.time.*; +import java.util.List; +import java.util.Map; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.ArrayList; + +@Repository +public class FleetTripCostRepository { + record Row(long id, long tripId, String costNo, FleetTripCostModels.Draft details, long version, + String requestHash, LocalDateTime updatedAt) { } + record Amount(String currency, BigDecimal amount) { } + record Departure(long version, String snapshot) { } + record Audit(long version, String action, String reason, Instant occurredAt, String snapshot) { } + private static final RowMapper ROW = (r, ignored) -> new Row(r.getLong("id"), r.getLong("trip_id"), r.getString("cost_no"), + new FleetTripCostModels.Draft(r.getString("category"), r.getString("currency"), r.getBigDecimal("amount"), + r.getTimestamp("incurred_at").toLocalDateTime().toInstant(ZoneOffset.UTC), r.getString("remark")), + r.getLong("record_version"), r.getString("request_hash"), r.getTimestamp("updated_at").toLocalDateTime()); + private final JdbcTemplate jdbc; + public FleetTripCostRepository(JdbcTemplate jdbc) { this.jdbc = jdbc; } + Row find(long tripId, String number) { + return jdbc.query("SELECT * FROM ll_fleet_trip_cost WHERE trip_id=? AND cost_no=?", ROW, tripId, number).stream().findFirst().orElse(null); + } + Row created(long tripId, String operator, String key) { + return jdbc.query("SELECT * FROM ll_fleet_trip_cost WHERE trip_id=? AND created_by=? AND request_key=?", ROW, tripId, operator, key).stream().findFirst().orElse(null); + } + long count(long tripId) { return jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_cost WHERE trip_id=?", Long.class, tripId); } + List page(long tripId, FleetTripCostModels.Query query) { + return jdbc.query("SELECT * FROM ll_fleet_trip_cost WHERE trip_id=? ORDER BY incurred_at DESC,id DESC LIMIT ? OFFSET ?", ROW, tripId, query.size(), query.offset()); + } + List totals(long tripId) { + return jdbc.query("SELECT currency,SUM(amount) total FROM ll_fleet_trip_cost WHERE trip_id=? GROUP BY currency ORDER BY currency", + (r, ignored) -> new Amount(r.getString("currency"), r.getBigDecimal("total")), tripId); + } + Departure departure(long tripId) { + var rows = jdbc.query("SELECT record_version,snapshot_json FROM ll_fleet_trip_history WHERE trip_id=? AND action='DEPART'", + (r, ignored) -> new Departure(r.getLong("record_version"), r.getString("snapshot_json")), tripId); + if (rows.size() > 1) throw new IllegalStateException("车次存在重复发车历史,不能计算成本分摊"); + return rows.isEmpty() ? null : rows.get(0); + } + Map trips(Collection numbers) { + var result = new LinkedHashMap(); + jdbc.query("SELECT trip_no,id FROM ll_fleet_trip WHERE trip_no IN (" + placeholders(numbers.size()) + ")", + (org.springframework.jdbc.core.RowCallbackHandler) r -> result.put(r.getString("trip_no"), r.getLong("id")), numbers.toArray()); + return result; + } + Map departures(Collection ids) { + var result = new LinkedHashMap(); + if (ids.isEmpty()) return result; + jdbc.query("SELECT trip_id,record_version,snapshot_json FROM ll_fleet_trip_history WHERE action='DEPART' AND trip_id IN (" + placeholders(ids.size()) + ")", + (org.springframework.jdbc.core.RowCallbackHandler) r -> { + if (result.put(r.getLong("trip_id"), new Departure(r.getLong("record_version"), r.getString("snapshot_json"))) != null) + throw new IllegalStateException("车次存在重复发车历史,不能计算成本分摊"); + }, ids.toArray()); + return result; + } + Map> totals(Collection ids) { + var result = new LinkedHashMap>(); + if (ids.isEmpty()) return result; + jdbc.query("SELECT trip_id,currency,SUM(amount) total FROM ll_fleet_trip_cost WHERE trip_id IN (" + placeholders(ids.size()) + ") GROUP BY trip_id,currency ORDER BY trip_id,currency", + (org.springframework.jdbc.core.RowCallbackHandler) r -> result.computeIfAbsent(r.getLong("trip_id"), ignored -> new ArrayList<>()) + .add(new Amount(r.getString("currency"), r.getBigDecimal("total"))), ids.toArray()); + return result; + } + private static String placeholders(int count) { return String.join(",", Collections.nCopies(count, "?")); } + void insert(Row row, String operator, String key) { + jdbc.update("INSERT INTO ll_fleet_trip_cost(id,trip_id,cost_no,category,currency,amount,incurred_at,remark,record_version,request_key,request_hash,created_by,updated_by,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + row.id(), row.tripId(), row.costNo(), row.details().category(), row.details().currency(), row.details().amount(), + utc(row.details().incurredAt()), row.details().remark(), row.version(), key, row.requestHash(), operator, operator, row.updatedAt(), row.updatedAt()); + } + int update(Row row, long expected, String operator) { + return jdbc.update("UPDATE ll_fleet_trip_cost SET category=?,currency=?,amount=?,incurred_at=?,remark=?,record_version=?,updated_by=?,updated_at=? WHERE id=? AND record_version=?", + row.details().category(), row.details().currency(), row.details().amount(), utc(row.details().incurredAt()), row.details().remark(), row.version(), operator, row.updatedAt(), row.id(), expected); + } + void audit(long id, Row row, String operator, String action, String reason, String snapshot) { + jdbc.update("INSERT INTO ll_fleet_trip_cost_history(id,cost_id,record_version,action,reason,operator_id,occurred_at,snapshot_json) VALUES(?,?,?,?,?,?,?,?)", + id, row.id(), row.version(), action, reason, operator, row.updatedAt(), snapshot); + } + long historyCount(long id) { return jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_cost_history WHERE cost_id=?", Long.class, id); } + List history(long id, FleetTripCostModels.Query query) { + return jdbc.query("SELECT * FROM ll_fleet_trip_cost_history WHERE cost_id=? ORDER BY record_version DESC LIMIT ? OFFSET ?", + (r, ignored) -> new Audit(r.getLong("record_version"), r.getString("action"), r.getString("reason"), + r.getTimestamp("occurred_at").toLocalDateTime().toInstant(ZoneOffset.UTC), r.getString("snapshot_json")), id, query.size(), query.offset()); + } + private static LocalDateTime utc(Instant instant) { return LocalDateTime.ofInstant(instant, ZoneOffset.UTC); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostService.java new file mode 100644 index 000000000..a699c2ce7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostService.java @@ -0,0 +1,143 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.fleet.domain.TripCostAllocationPolicy; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.time.*; +import java.util.*; + +@Service +public class FleetTripCostService { + private final FleetTripMapper trips; + private final FleetTripCostRepository costs; + private final ObjectMapper json; + public FleetTripCostService(FleetTripMapper trips, FleetTripCostRepository costs, ObjectMapper json) { + this.trips = trips; this.costs = costs; this.json = json; + } + @Transactional(readOnly=true, isolation=Isolation.REPEATABLE_READ) + public FleetTripCostModels.Listing list(String tripNo, FleetTripCostModels.Query query) { + var trip = required(tripNo, false); + long count = costs.count(trip.getId()); + return new FleetTripCostModels.Listing(new PageResult<>(costs.page(trip.getId(), query).stream().map(row -> response(tripNo, row)).toList(), + query.page(), query.size(), count, pages(count, query.size())), summary(trip)); + } + /** At most 100 distinct trips. Missing trips are omitted; existing trips with no costs have empty totals. */ + @Transactional(readOnly=true, isolation=Isolation.REPEATABLE_READ) + public Map summaries(Collection tripNos) { + if (tripNos == null || tripNos.size() > 100 || tripNos.stream().anyMatch(no -> no == null || no.isBlank())) + throw error(HttpStatus.BAD_REQUEST, "批量成本查询最多100个有效车次号"); + if (tripNos.isEmpty()) return Map.of(); + var numbers = new LinkedHashSet<>(tripNos); + var tripsByNo = costs.trips(numbers); + var departures = costs.departures(tripsByNo.values()); + var amounts = costs.totals(tripsByNo.values()); + var result = new LinkedHashMap(); + for (var number : numbers) { + var id = tripsByNo.get(number); + if (id != null) result.put(number, summary(departures.get(id), amounts.getOrDefault(id, List.of()))); + } + return Collections.unmodifiableMap(result); + } + @Transactional(readOnly=true, isolation=Isolation.REPEATABLE_READ) + public PageResult history(String tripNo, String costNo, FleetTripCostModels.Query query) { + var trip = required(tripNo, false); var row = cost(trip.getId(), costNo); long count = costs.historyCount(row.id()); + return new PageResult<>(costs.history(row.id(), query).stream().map(h -> new FleetTripCostModels.History(h.version(), h.action(), h.reason(), h.occurredAt(), + read(h.snapshot(), FleetTripCostModels.Snapshot.class))).toList(), query.page(), query.size(), count, pages(count, query.size())); + } + @Transactional(isolation=Isolation.READ_COMMITTED) + public FleetTripCostModels.Cost create(String tripNo, String operator, FleetTripCostModels.Create request) { + var trip = required(tripNo, true); + String hash = digest(write(canonical(request.details()))); + var prior = costs.created(trip.getId(), operator, request.idempotencyKey()); + if (prior != null) { + if (!hash.equals(prior.requestHash())) throw error(HttpStatus.CONFLICT, "请求标识已用于其他成本内容"); + return response(tripNo, prior); + } + validate(request.details()); + var row = new FleetTripCostRepository.Row(IdWorker.getId(), trip.getId(), "TC-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(Locale.ROOT), + canonical(request.details()), 0, hash, LocalDateTime.now(ZoneOffset.UTC)); + costs.insert(row, operator, request.idempotencyKey()); + audit(trip, row, operator, "CREATE", "登记本趟直接成本"); + return response(tripNo, row); + } + @Transactional(isolation=Isolation.READ_COMMITTED) + public FleetTripCostModels.Cost update(String tripNo, String costNo, String operator, FleetTripCostModels.Update request) { + var trip = required(tripNo, true); var prior = cost(trip.getId(), costNo); + if (prior.version() != request.recordVersion()) throw error(HttpStatus.CONFLICT, "成本记录已变化,请刷新后重试"); + validate(request.details()); + if (canonical(prior.details()).equals(canonical(request.details()))) return response(tripNo, prior); + var row = new FleetTripCostRepository.Row(prior.id(), prior.tripId(), prior.costNo(), canonical(request.details()), prior.version() + 1, prior.requestHash(), LocalDateTime.now(ZoneOffset.UTC)); + if (costs.update(row, prior.version(), operator) != 1) throw error(HttpStatus.CONFLICT, "成本记录已变化,请刷新后重试"); + audit(trip, row, operator, "UPDATE", request.reason() == null || request.reason().isBlank() ? "更正本趟直接成本" : request.reason().trim()); + return response(tripNo, row); + } + private void audit(FleetTripEntity trip, FleetTripCostRepository.Row row, String operator, String action, String reason) { + costs.audit(IdWorker.getId(), row, operator, action, reason, write(new FleetTripCostModels.Snapshot(response(trip.getTripNo(), row), summary(trip)))); + } + private FleetTripCostModels.Summary summary(FleetTripEntity trip) { + return summary(costs.departure(trip.getId()), costs.totals(trip.getId())); + } + private FleetTripCostModels.Summary summary(FleetTripCostRepository.Departure departure, List amountsByCurrency) { + var basis = FleetTripCostBasis.from(departure == null ? null : read(departure.snapshot(), FleetTripModels.AuditSnapshot.class)); + var totals = new ArrayList(); + for (var total : amountsByCurrency) { + var allocations = new ArrayList(); + BigDecimal own = null, external = null; + if (basis.blockReason() == null) { + var amounts = new HashMap(); + TripCostAllocationPolicy.allocate(total.amount(), total.currency(), basis.shares().stream() + .map(share -> new TripCostAllocationPolicy.Share(share.businessNo(), share.palletSpaces())).toList()) + .forEach(part -> amounts.put(part.bookingNo(), part.amount())); + own = BigDecimal.ZERO.setScale(2); external = BigDecimal.ZERO.setScale(2); + for (var share : basis.shares()) { + var amount = amounts.get(share.businessNo()); + allocations.add(new FleetTripCostModels.Allocation(share.businessNo(), share.sourceType(), share.dispatchNo(), share.palletSpaces(), amount)); + if ("OWN".equals(share.sourceType())) own = own.add(amount); else external = external.add(amount); + } + } + totals.add(new FleetTripCostModels.Total(total.currency(), total.amount(), allocations, own, external)); + } + return new FleetTripCostModels.Summary(departure == null ? null : departure.version(), basis.manifestHash(), + basis.blockReason() == null ? "SAME_ROUTE_PALLET_SPACES" : null, basis.blockReason(), totals); + } + private FleetTripEntity required(String number, boolean lock) { + var trip = lock ? trips.lockByNo(number) : trips.findByNo(number); + if (trip == null) throw error(HttpStatus.NOT_FOUND, "车次不存在"); return trip; + } + private FleetTripCostRepository.Row cost(long tripId, String number) { + var row = costs.find(tripId, number); if (row == null) throw error(HttpStatus.NOT_FOUND, "本车次成本记录不存在"); return row; + } + private static FleetTripCostModels.Cost response(String tripNo, FleetTripCostRepository.Row row) { + return new FleetTripCostModels.Cost(row.costNo(), tripNo, row.details(), row.version(), row.updatedAt().toInstant(ZoneOffset.UTC)); + } + private static void validate(FleetTripCostModels.Draft draft) { + if (draft.incurredAt().isAfter(Instant.now())) throw error(HttpStatus.BAD_REQUEST, "实际费用时间不能晚于当前时间"); + } + private static FleetTripCostModels.Draft canonical(FleetTripCostModels.Draft draft) { + String remark = draft.remark() == null || draft.remark().isBlank() ? null : draft.remark().trim(); + return new FleetTripCostModels.Draft(draft.category(), draft.currency(), draft.amount().stripTrailingZeros(), + draft.incurredAt().truncatedTo(java.time.temporal.ChronoUnit.MILLIS), remark); + } + private String write(Object value) { + try { return json.writeValueAsString(value); } catch (JsonProcessingException error) { throw new IllegalStateException("成本快照无法保存", error); } + } + private T read(String value, Class type) { + try { return json.readValue(value, type); } catch (JsonProcessingException error) { throw new IllegalStateException("成本依据无法读取", error); } + } + private static String digest(String value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException error) { throw new IllegalStateException(error); } + } + private static long pages(long count, int size) { return count / size + (count % size == 0 ? 0 : 1); } + private static ResponseStatusException error(HttpStatus status, String text) { return new ResponseStatusException(status, text); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripDispatch.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripDispatch.java new file mode 100644 index 000000000..065e973b1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripDispatch.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("ll_fleet_trip_dispatch") +public class FleetTripDispatch { + @TableId(type = IdType.INPUT) + private Long dispatchId; + private String dispatchNo; + private String customerId; + private Long tripId; + private String tripNo; + private Integer packageCount; + private String boundBy; + private LocalDateTime boundAt; + @com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS) + private String reviewJson; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripDispatchMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripDispatchMapper.java new file mode 100644 index 000000000..3de088e92 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripDispatchMapper.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.*; +import java.util.List; + +@Mapper +public interface FleetTripDispatchMapper extends BaseMapper { + @Select("SELECT * FROM ll_fleet_trip_dispatch WHERE trip_id=#{tripId} ORDER BY dispatch_id") + List listByTrip(@Param("tripId") long tripId); + @Select("SELECT * FROM ll_fleet_trip_dispatch WHERE dispatch_no=#{dispatchNo}") + FleetTripDispatch findByDispatchNo(@Param("dispatchNo") String dispatchNo); + @Select("") + List findByDispatchNos(@Param("numbers") List numbers); + + // Locking read sees a concurrent binding committed while the caller waited for the dispatch row. + @Select("SELECT * FROM ll_fleet_trip_dispatch WHERE dispatch_id=#{dispatchId} FOR UPDATE") + FleetTripDispatch lockByDispatch(@Param("dispatchId") long dispatchId); + + @Select("SELECT t.status FROM ll_fleet_trip_dispatch b JOIN ll_fleet_trip t ON t.id=b.trip_id WHERE b.dispatch_id=#{dispatchId}") + String tripStatus(@Param("dispatchId") long dispatchId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripEntity.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripEntity.java new file mode 100644 index 000000000..0dacd3636 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripEntity.java @@ -0,0 +1,53 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Data +@TableName("ll_fleet_trip") +public class FleetTripEntity { + @TableId(type = IdType.INPUT) private Long id; + private String tripNo; + private String idempotencyKey; + private String requestHash; + private String status; + private Long warehouseId; + private String warehouseCode; + private String warehouseName; + private Long vehicleId; + private String vehiclePlate; + private Long driverId; + private String driverName; + private String driverPhone; + private Integer maxPalletSpaces; + private BigDecimal maxLoadKg; + private BigDecimal maxVolumeCbm; + private LocalDateTime plannedDepartureAt; + private LocalDateTime expectedDriverEndAt; + private LocalDateTime departedAt; + private LocalDateTime completedAt; + private String stopsJson; + @com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS) + private String plannedLoadJson; + @com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS) + private String loadJson; + @com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS) + private String loadingReviewJson; + private String departureKey; + private String departureHash; + private String departureResultJson; + private String completionJson; + private String vehicleReleaseJson; + private String driverReleaseJson; + @com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS) + private String remark; + private Long recordVersion; + private String createdBy; + private LocalDateTime createdAt; + private String updatedBy; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java new file mode 100644 index 000000000..ab0cca722 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java @@ -0,0 +1,319 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.lastleg.fleet.domain.TripCapacityPolicy; +import com.baiyue.lastleg.fleet.driver.DriverModels; +import com.baiyue.lastleg.fleet.driver.DriverService; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.*; + +@Service +public class FleetTripExecutionService { + private final FleetTripMapper trips; + private final FleetTripDispatchMapper bindings; + private final FleetResourceMapper resources; + private final FleetDispatchGateway dispatches; + private final FleetTripService history; + private final DriverService drivers; + private final VehicleRepository vehicles; + private final ObjectMapper json; + private final FleetBookingReservations bookingReservations; + + public FleetTripExecutionService(FleetTripMapper trips, FleetTripDispatchMapper bindings, FleetResourceMapper resources, + FleetDispatchGateway dispatches, FleetTripService history, DriverService drivers, + VehicleRepository vehicles, ObjectMapper json, FleetBookingReservations bookingReservations) { + this.trips = trips; this.bindings = bindings; this.resources = resources; this.dispatches = dispatches; + this.history = history; this.drivers = drivers; this.vehicles = vehicles; this.json = json; this.bookingReservations = bookingReservations; + } + + @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) + public FleetExecutionModels.Planning capacityPlan(String tripNo) { + var trip = requireTrip(tripNo, false); + var cargo = cargo(manifests(trip, false)); + return planning(trip, cargo); + } + + @Transactional + public FleetExecutionModels.Planning confirmCapacityPlan(String tripNo, String operator, FleetExecutionModels.Confirm request) { + var trip = requireTrip(tripNo, true); + requireVersionAndPlan(trip, request.recordVersion()); + var cargo = cargo(manifests(trip, true)); + String reason = planningBlockReason(trip, cargo); + if (reason != null) throw conflict(reason); + String fingerprint = hash(write(cargo)); + if (!fingerprint.equals(request.manifestHash())) throw conflict("货物明细已变化,请刷新后重新核对计划容量"); + var pallets = request.pallets().stream().sorted(Comparator.comparing(FleetExecutionModels.Measurement::warehousePalletId)).toList(); + var plan = new FleetExecutionModels.PlannedLoad(fingerprint, cargo, pallets, capacity(trip, cargo, pallets), operator, Instant.now()); + bookingReservations.segments(trip, plan, null, null); + trip.setPlannedLoadJson(write(plan)); + history.recordExecution(trip, request.recordVersion(), "PLAN_CAPACITY", request.reason(), operator); + return planning(trip, cargo); + } + + private FleetExecutionModels.Planning planning(FleetTripEntity trip, List cargo) { + String fingerprint = hash(write(cargo)); + var plan = trip.getPlannedLoadJson() == null ? null : read(trip.getPlannedLoadJson(), FleetExecutionModels.PlannedLoad.class); + boolean valid = plan != null && plan.manifestHash().equals(fingerprint); + return new FleetExecutionModels.Planning(trip.getTripNo(), trip.getRecordVersion(), cargo, fingerprint, plan, valid, planningBlockReason(trip, cargo), valid ? bookingReservations.segments(trip, plan, null, null) : List.of()); + } + + private String planningBlockReason(FleetTripEntity trip, List cargo) { + if (!"PLANNED".equals(trip.getStatus())) return "仅计划中的车次允许核对计划容量"; + if (trip.getVehicleId() == null) return "请先分配车辆,明确可用托位、载重和容积"; + if (cargo.isEmpty()) return "请先关联本车自有货排车单"; + if (cargo.stream().anyMatch(row -> row.warehousePalletId() == null || row.destinationKey() == null)) + return "请先明确每票货物的实物托盘和卸货目的地"; + return null; + } + + @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) + public FleetExecutionModels.Loading loading(String tripNo) { + var trip = requireTrip(tripNo, false); + var manifest = manifests(trip, false); + var cargo = cargo(manifest); + return new FleetExecutionModels.Loading(tripNo, trip.getRecordVersion(), cargo, hash(write(cargo)), load(trip), blockReason(trip, manifest)); + } + + @Transactional + public FleetExecutionModels.Loading confirmLoad(String tripNo, String operator, FleetExecutionModels.Confirm request) { + var trip = requireTrip(tripNo, true); + requireVersionAndPlan(trip, request.recordVersion()); + var manifest = manifests(trip, true); + String block = blockReason(trip, manifest); + if (block != null) throw conflict(block); + var cargo = cargo(manifest); String fingerprint = hash(write(cargo)); + if (!fingerprint.equals(request.manifestHash())) throw conflict("货物明细已变化,请刷新后重新核对装载"); + var pallets = request.pallets().stream().sorted(Comparator.comparing(FleetExecutionModels.Measurement::warehousePalletId)).toList(); + var segments = capacity(trip, cargo, pallets); + var confirmed = new FleetExecutionModels.ConfirmedLoad(fingerprint, cargo, pallets, segments, operator, Instant.now()); + trip.setLoadJson(write(confirmed)); + history.recordExecution(trip, request.recordVersion(), "CONFIRM_LOAD", request.reason(), operator); + return new FleetExecutionModels.Loading(tripNo, trip.getRecordVersion(), cargo, fingerprint, confirmed, null); + } + + @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) + public FleetExecutionModels.Gate gate(String warehouseId, String dispatchNo) { + var binding = bindings.findByDispatchNo(dispatchNo); + if (binding == null) return missingGate(); + return pickingGate(gateForTrip(warehouseId, binding.getTripNo()), binding); + } + + @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) + public Map gates(String warehouseId, List numbers) { + if (numbers.isEmpty()) return Map.of(); + var result = new HashMap(); + numbers.forEach(number -> result.put(number, missingGate())); + var byTrip = new HashMap(); + for (var binding : bindings.findByDispatchNos(numbers)) result.put(binding.getDispatchNo(), pickingGate( + byTrip.computeIfAbsent(binding.getTripNo(), number -> gateForTrip(warehouseId, number)), binding)); + return result; + } + + private FleetExecutionModels.Gate gateForTrip(String warehouseId, String tripNo) { + var trip = requireTrip(tripNo, false); + requireWarehouse(trip, warehouseId); + var manifest = manifests(trip, false); + String reason = blockReason(trip, manifest); + String warning = null; + if (reason == null) reason = loadBlockReason(trip, cargo(manifest)); + if (reason == null) reason = loadingReviewBlockReason(trip); + if (reason == null) { + var driver = drivers.detail(trip.getDriverId(), trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC)); + if (!driver.qualification().canDispatch()) reason = "司机资格不满足本次任务,请在平台核对"; + else reason = driverBlockReason(trip, driver); + if (reason == null && driver.qualification().expiringSoon()) warning = "司机驾驶证将于 " + driver.qualification().licenseExpiresOn() + " 到期;有效期覆盖本次预计任务,请留意延期风险"; + } + boolean canPick = "PLANNED".equals(trip.getStatus()) && trip.getVehicleId() != null && trip.getDriverId() != null; + return new FleetExecutionModels.Gate(trip.getTripNo(), trip.getRecordVersion(), manifest.size(), reason == null, reason, warning, + canPick, canPick ? null : "当前车次不允许继续拣货"); + } + + private static FleetExecutionModels.Gate missingGate() { + return new FleetExecutionModels.Gate(null, null, 0, false, "请先在平台将排车单关联车次", null, false, "请先关联车次并通过调度审核"); + } + private FleetExecutionModels.Gate pickingGate(FleetExecutionModels.Gate gate, FleetTripDispatch binding) { + if (!gate.canPick()) return gate; + var review = FleetTripReview.read(json, binding.getReviewJson()); + String reason = "APPROVED".equals(review.status()) ? null : "REJECTED".equals(review.status()) + ? "调度审核已打回:" + review.reason() : "请先通过调度审核再拣货"; + return gate.withPicking(reason == null, reason); + } + private String loadingReviewBlockReason(FleetTripEntity trip) { + var review = FleetTripReview.read(json, trip.getLoadingReviewJson()); + return "APPROVED".equals(review.status()) ? null : "REJECTED".equals(review.status()) ? "理货审核已打回:" + review.reason() : "请先通过理货审核再发车"; + } + + @Transactional + public FleetTripModels.Detail reviewLoading(String tripNo, String operator, FleetTripModels.Review request) { + var trip = requireTrip(tripNo, true); + requireVersionAndPlan(trip, request.recordVersion()); + String reason = FleetTripReview.reason(request); + if ("APPROVED".equals(request.decision())) { + if (trip.getDriverId() == null) throw conflict("请先分配司机"); + var driver = drivers.requireDispatchable(trip.getDriverId(), trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC)); + String driverBlock = driverBlockReason(trip, driver); + if (driverBlock != null) throw conflict(driverBlock); + var manifest = manifests(trip, true); + String block = blockReason(trip, manifest); + if (block == null) block = loadBlockReason(trip, cargo(manifest)); + if (block != null) throw conflict(block); + } + trip.setLoadingReviewJson(write(new FleetTripModels.ReviewState(request.decision(), reason, operator, Instant.now(), request.recordVersion()))); + history.recordExecution(trip, request.recordVersion(), "LOADING_REVIEW", reason, operator); + return history.detail(tripNo); + } + + @Transactional + public FleetExecutionModels.Departed depart(String warehouseId, String dispatchNo, String operator, FleetExecutionModels.Depart request) { + var binding = bindings.findByDispatchNo(dispatchNo); + if (binding == null) throw conflict("请先在平台将排车单关联车次"); + var trip = requireTrip(binding.getTripNo(), true); + requireWarehouse(trip, warehouseId); + // The binding may have changed while waiting for the trip lock. Never ship a stale parent. + var members = bindings.listByTrip(trip.getId()); + if (members.stream().noneMatch(member -> member.getDispatchNo().equals(dispatchNo))) throw conflict("排车单所属车次已变化,请刷新"); + String requestHash = hash(write(request)); + if (trip.getDepartureKey() != null) { + if (!trip.getDepartureKey().equals(request.idempotencyKey()) || !trip.getDepartureHash().equals(requestHash)) + throw conflict("车次已发车,发车请求标识或内容不一致"); + return read(trip.getDepartureResultJson(), FleetExecutionModels.Departed.class).withReplay(); + } + requireVersionAndPlan(trip, request.recordVersion()); + // Resource lock order matches trip assignment: trip -> vehicle -> driver -> customer dispatches. + if (trip.getVehicleId() == null || trip.getDriverId() == null) throw conflict("请先分配车辆和司机"); + var vehicle = vehicles.lockById(trip.getVehicleId()).orElseThrow(() -> conflict("车辆档案不存在")); + if (!"IN_SERVICE".equals(vehicle.status()) || !"OVERSEAS".equals(vehicle.region()) + || !Objects.equals(vehicle.plateNumber(), trip.getVehiclePlate()) || !Objects.equals(vehicle.maxPalletSpaces(), trip.getMaxPalletSpaces()) + || !equalNumber(vehicle.maxLoadKg(), trip.getMaxLoadKg()) || !equalNumber(vehicle.maxVolumeCbm(), trip.getMaxVolumeCbm())) + throw conflict("车辆状态或容量已变化,请重新确认车次"); + requireResource("VEHICLE", trip.getVehicleId(), trip.getId()); requireResource("DRIVER", trip.getDriverId(), trip.getId()); + var driver = drivers.requireDispatchable(trip.getDriverId(), trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC)); + String driverBlock = driverBlockReason(trip, driver); + if (driverBlock != null) throw conflict(driverBlock); + var manifest = manifests(trip, true); + String reason = blockReason(trip, manifest); + if (reason == null) reason = loadBlockReason(trip, cargo(manifest)); + if (reason == null) reason = loadingReviewBlockReason(trip); + if (reason != null) throw conflict(reason); + // Store the same precision in DATETIME(3), the immutable DEPART snapshot and replay result. + Instant now = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MILLIS); + trip.setStatus("DEPARTING"); + if (trips.updateById(trip) != 1) throw conflict("车次状态已变化"); + var results = new ArrayList(); + for (var member : members) results.add(dispatches.depart(warehouseId, member.getDispatchNo(), operator, + trip.getTripNo() + ":" + member.getDispatchNo(), request.remark(), now)); + trip.setStatus("IN_TRANSIT"); trip.setDepartedAt(LocalDateTime.ofInstant(now, ZoneOffset.UTC)); + var result = new FleetExecutionModels.Departed(trip.getTripNo(), "IN_TRANSIT", now, + results.stream().mapToLong(FleetExecutionModels.DepartedDispatch::shippedPackages).sum(), List.copyOf(results), false); + trip.setDepartureKey(request.idempotencyKey()); trip.setDepartureHash(requestHash); trip.setDepartureResultJson(write(result)); + history.recordExecution(trip, request.recordVersion(), "DEPART", request.remark(), operator); + return result; + } + + private List manifests(FleetTripEntity trip, boolean lock) { + return bindings.listByTrip(trip.getId()).stream().map(binding -> dispatches.manifest(binding.getDispatchNo(), lock)).toList(); + } + private static List cargo(List manifests) { + return manifests.stream().flatMap(manifest -> manifest.cargo().stream()) + .sorted(Comparator.comparing(FleetExecutionModels.Cargo::dispatchNo).thenComparing(FleetExecutionModels.Cargo::dispatchItemId)).toList(); + } + private String blockReason(FleetTripEntity trip, List manifests) { + if (!"PLANNED".equals(trip.getStatus())) return "当前车次不是待发车计划"; + if (trip.getVehicleId() == null || trip.getDriverId() == null) return "请先分配车辆和司机"; + if (manifests.isEmpty()) return "车次尚未关联排车单"; + if (bookingReservations.hasConfirmed(trip.getId())) return "已确认外接预订尚未关联实际发运货物,请先完成来货与排车关联"; + for (var binding : bindings.listByTrip(trip.getId())) { + if (!FleetTripReview.approved(json, binding.getReviewJson())) return "排车单 " + binding.getDispatchNo() + " 尚未通过调度审核"; + } + for (var manifest : manifests) { + if (!trip.getWarehouseId().toString().equals(manifest.warehouseId())) return "排车单起运仓与车次不一致"; + if (!Objects.equals(trip.getVehiclePlate(), manifest.vehiclePlate()) || !Objects.equals(trip.getDriverName(), manifest.driverName()) + || !Objects.equals(trip.getDriverPhone(), manifest.driverPhone())) return "排车单 " + manifest.dispatchNo() + " 的车辆司机与车次不一致"; + if (manifest.blockReason() != null) return manifest.dispatchNo() + ":" + manifest.blockReason(); + } + return null; + } + private String loadBlockReason(FleetTripEntity trip, List cargo) { + var load = load(trip); + if (load == null) return "请在平台确认本车实际装载"; + if (!load.manifestHash().equals(hash(write(cargo)))) return "装载确认后货物发生变化,请重新确认"; + try { capacity(trip, cargo, load.pallets()); return null; } + catch (ResponseStatusException error) { return error.getReason(); } + } + private List capacity(FleetTripEntity trip, List cargo, + List pallets) { + List stops; + try { stops = json.readValue(trip.getStopsJson(), new TypeReference<>() { }); } + catch (JsonProcessingException error) { throw new IllegalStateException("车次路线读取失败", error); } + var destinations = new HashMap(); var palletNumbers = new HashMap(); + if (cargo.isEmpty()) throw conflict("没有待发运货物"); + for (var item : cargo) { + if (item.warehousePalletId() == null || item.palletNo() == null || item.palletNo().isBlank() || item.destinationKey() == null || item.packages() <= 0) + throw conflict("货物实物托盘、数量或目的地尚未确认"); + String previous = destinations.putIfAbsent(item.warehousePalletId(), item.destinationKey()); + if (previous != null && !previous.equals(item.destinationKey())) throw conflict("同一实物托盘包含不同卸货目的地,请先拆托"); + String oldNumber = palletNumbers.putIfAbsent(item.warehousePalletId(), item.palletNo()); + if (oldNumber != null && !oldNumber.equals(item.palletNo())) throw conflict("实物托盘编号不一致,请核对"); + } + var measured = new HashSet(); var bookings = new ArrayList(); + try { + for (var pallet : pallets) { + if (!measured.add(pallet.warehousePalletId())) throw conflict("同一实物托盘不能重复计量"); + if (!destinations.containsKey(pallet.warehousePalletId())) throw conflict("装载包含不属于本车的实物托盘"); + if (pallet.deliveryIndex() < 1 || pallet.deliveryIndex() >= stops.size() + || !destinations.get(pallet.warehousePalletId()).equals(stops.get(pallet.deliveryIndex()).destinationKey())) + throw conflict("托盘卸货站与货物目的地不一致"); + bookings.add(new TripCapacityPolicy.Booking(pallet.warehousePalletId(), 0, pallet.deliveryIndex(), + new TripCapacityPolicy.Load(pallet.palletSpaces(), pallet.weightKg(), pallet.volumeCbm()))); + } + if (!measured.equals(destinations.keySet())) throw conflict("请确认每个实物托盘的托位、实际重量及体积"); + if (trip.getMaxPalletSpaces() == null || trip.getMaxLoadKg() == null || trip.getMaxVolumeCbm() == null) + throw conflict("车辆容量未确认"); + return TripCapacityPolicy.calculate(stops.size(), new TripCapacityPolicy.Load(trip.getMaxPalletSpaces(), trip.getMaxLoadKg(), trip.getMaxVolumeCbm()), bookings); + } catch (IllegalArgumentException | ArithmeticException error) { throw conflict(error.getMessage()); } + } + private void requireResource(String kind, long id, long tripId) { + var claim = resources.find(kind, id); + if (claim == null || claim.tripId() != tripId) throw conflict("车次车辆或司机占用已变化"); + } + private static String driverBlockReason(FleetTripEntity trip, DriverModels.Response driver) { + if (!"OVERSEAS".equals(driver.details().region())) return "本车次须使用海外司机,请核对当前司机档案"; + if (!Objects.equals(driver.details().name(), trip.getDriverName())) return "司机档案已变化,请核对车次司机快照"; + return null; + } + private FleetTripEntity requireTrip(String tripNo, boolean lock) { + var trip = lock ? trips.lockByNo(tripNo) : trips.findByNo(tripNo); + if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在"); + return trip; + } + private static void requireWarehouse(FleetTripEntity trip, String warehouseId) { + if (!trip.getWarehouseId().toString().equals(warehouseId)) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "当前绑定仓无此车次"); + } + private static void requireVersionAndPlan(FleetTripEntity trip, long version) { + if (trip.getRecordVersion() != version) throw conflict("车次已被修改,请刷新后重新确认"); + if (!"PLANNED".equals(trip.getStatus())) throw conflict("仅计划中的车次允许此操作"); + } + private static boolean equalNumber(java.math.BigDecimal a, java.math.BigDecimal b) { return a != null && b != null && a.compareTo(b) == 0; } + private FleetExecutionModels.ConfirmedLoad load(FleetTripEntity trip) { return trip.getLoadJson() == null ? null : read(trip.getLoadJson(), FleetExecutionModels.ConfirmedLoad.class); } + private String write(Object value) { try { return json.writeValueAsString(value); } catch (JsonProcessingException e) { throw new IllegalStateException(e); } } + private T read(String value, Class type) { try { return json.readValue(value, type); } catch (JsonProcessingException e) { throw new IllegalStateException(e); } } + private static String hash(String value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } + } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripHistory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripHistory.java new file mode 100644 index 000000000..05f0085d8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripHistory.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("ll_fleet_trip_history") +public class FleetTripHistory { + @TableId(type = IdType.INPUT) private Long id; + private Long tripId; + private Long recordVersion; + private String action; + private String reason; + private String operatorId; + private LocalDateTime occurredAt; + private String snapshotJson; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripHistoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripHistoryMapper.java new file mode 100644 index 000000000..0e6603e89 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripHistoryMapper.java @@ -0,0 +1,7 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface FleetTripHistoryMapper extends BaseMapper { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripMapper.java new file mode 100644 index 000000000..430dcc49e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripMapper.java @@ -0,0 +1,18 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface FleetTripMapper extends BaseMapper { + @Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo} FOR UPDATE") + FleetTripEntity lockByNo(@Param("tripNo") String tripNo); + @Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo}") + FleetTripEntity findByNo(@Param("tripNo") String tripNo); + @Select("SELECT * FROM ll_fleet_trip WHERE created_by=#{operator} AND idempotency_key=#{key}") + FleetTripEntity findCreated(@Param("operator") String operator, @Param("key") String key); + @Select("SELECT * FROM ll_fleet_trip WHERE created_by=#{operator} AND idempotency_key=#{key} FOR UPDATE") + FleetTripEntity lockCreated(@Param("operator") String operator, @Param("key") String key); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripModels.java new file mode 100644 index 000000000..0d59736d0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripModels.java @@ -0,0 +1,46 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.lastleg.fleet.domain.DriverQualificationPolicy; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +public final class FleetTripModels { + private FleetTripModels() { } + public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size, @Size(max = 128) String keyword, + String status, @Positive Long warehouseId) { + public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; } + } + public record Stop(@NotBlank @Size(max = 128) String name, @Size(max = 500) String address, + @Size(max = 160) String destinationKey) { } + public record Create(@NotNull @Positive Long warehouseId, @NotNull Instant plannedDepartureAt, + @NotNull Instant expectedDriverEndAt, @NotNull @Size(min = 2, max = 200) List<@Valid Stop> stops, + @Size(max = 1000) String remark, @NotBlank @Size(min = 8, max = 128) String idempotencyKey) { } + public record Assign(@NotNull @Min(0) Long recordVersion, @NotNull @Positive Long vehicleId, + @NotNull @Positive Long driverId, @NotBlank @Size(max = 1000) String reason) { } + public record Plan(@NotNull @Min(0) Long recordVersion, @NotNull Instant plannedDepartureAt, + @NotNull Instant expectedDriverEndAt, @NotNull @Size(min = 2, max = 200) List<@Valid Stop> stops, + @Size(max = 1000) String remark, @NotBlank @Size(max = 1000) String reason) { } + public record Cancel(@NotNull @Min(0) Long recordVersion, @NotBlank @Size(max = 1000) String reason) { } + public record BindingCommand(@NotNull @Min(0) Long recordVersion, @NotBlank @Size(max = 64) String dispatchNo, + @NotBlank @Size(max = 1000) String reason) { } + public record Review(@NotNull @Min(0) Long recordVersion, @NotBlank @Pattern(regexp = "APPROVED|REJECTED") String decision, + @Size(max = 1000) String reason) { } + public record ReviewState(String status, String reason, String reviewerId, Instant reviewedAt, Long recordVersion) { + public static ReviewState pending() { return new ReviewState("PENDING", null, null, null, null); } + } + public record DispatchBinding(String dispatchNo, String customerId, int packageCount, String boundBy, Instant boundAt, ReviewState review) { } + public record Response(String tripNo, String status, String warehouseId, String warehouseCode, String warehouseName, + String vehicleId, String vehiclePlate, String driverId, String driverName, String driverPhone, + Integer maxPalletSpaces, BigDecimal maxLoadKg, BigDecimal maxVolumeCbm, + Instant plannedDepartureAt, Instant expectedDriverEndAt, Instant departedAt, Instant completedAt, + List stops, String remark, long recordVersion, Instant updatedAt, + FleetCompletionModels.ReleaseInfo vehicleRelease, FleetCompletionModels.ReleaseInfo driverRelease) { } + public record Detail(Response trip, DriverQualificationPolicy.Qualification driverQualification, List dispatches, ReviewState loadingReview) { } + public record AuditSnapshot(Response trip, List dispatches, FleetExecutionModels.ConfirmedLoad confirmedLoad, + FleetCompletionModels.Completed completion, ReviewState loadingReview, + FleetExecutionModels.PlannedLoad capacityPlan, List externalBookings) { } + public record History(long recordVersion, String action, String reason, String operatorId, Instant occurredAt, AuditSnapshot snapshot) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripReview.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripReview.java new file mode 100644 index 000000000..03179d7c7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripReview.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +final class FleetTripReview { + private FleetTripReview() { } + static FleetTripModels.ReviewState read(ObjectMapper json, String value) { + if (value == null) return FleetTripModels.ReviewState.pending(); + try { return json.readValue(value, FleetTripModels.ReviewState.class); } + catch (JsonProcessingException error) { throw new IllegalStateException("审核记录读取失败", error); } + } + static String reason(FleetTripModels.Review request) { + if (!"APPROVED".equals(request.decision()) && !"REJECTED".equals(request.decision())) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择审核通过或打回"); + String reason = request.reason() == null ? "" : request.reason().trim(); + if ("REJECTED".equals(request.decision()) && reason.isEmpty()) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请填写打回原因"); + return reason.isEmpty() ? "审核通过" : reason; + } + static boolean approved(ObjectMapper json, String value) { return "APPROVED".equals(read(json, value).status()); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java new file mode 100644 index 000000000..736accc84 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java @@ -0,0 +1,336 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.fleet.driver.DriverService; +import com.baiyue.lastleg.fleet.driver.FleetWarehouseDirectory; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.*; +import java.util.*; + +@Service +public class FleetTripService { + private final FleetTripMapper trips; + private final FleetResourceMapper resources; + private final FleetTripHistoryMapper histories; + private final FleetTripDispatchMapper bindings; + private final FleetDispatchGateway dispatches; + private final VehicleRepository vehicles; + private final DriverService drivers; + private final FleetWarehouseDirectory warehouses; + private final ObjectMapper json; + private final Clock clock; + private final FleetBookingReservations bookingReservations; + + @Autowired + public FleetTripService(FleetTripMapper trips, FleetResourceMapper resources, FleetTripHistoryMapper histories, + VehicleRepository vehicles, DriverService drivers, FleetWarehouseDirectory warehouses, ObjectMapper json, + FleetTripDispatchMapper bindings, FleetDispatchGateway dispatches, FleetBookingReservations bookingReservations) { + this(trips, resources, histories, vehicles, drivers, warehouses, json, bindings, dispatches, bookingReservations, Clock.systemUTC()); + } + FleetTripService(FleetTripMapper trips, FleetResourceMapper resources, FleetTripHistoryMapper histories, + VehicleRepository vehicles, DriverService drivers, FleetWarehouseDirectory warehouses, ObjectMapper json, + FleetTripDispatchMapper bindings, FleetDispatchGateway dispatches, FleetBookingReservations bookingReservations, Clock clock) { + this.trips = trips; this.resources = resources; this.histories = histories; this.vehicles = vehicles; + this.drivers = drivers; this.warehouses = warehouses; this.json = json; this.clock = clock; + this.bindings = bindings; this.dispatches = dispatches; this.bookingReservations = bookingReservations; + } + + public PageResult page(FleetTripModels.Query query) { + if (query.status() != null && !query.status().isBlank() && !Set.of("PLANNED", "IN_TRANSIT", "COMPLETED", "CANCELLED").contains(query.status())) + throw bad("车次状态无效"); + var filter = new LambdaQueryWrapper() + .eq(query.status() != null && !query.status().isBlank(), FleetTripEntity::getStatus, query.status()) + .eq(query.warehouseId() != null, FleetTripEntity::getWarehouseId, query.warehouseId()); + if (query.keyword() != null && !query.keyword().isBlank()) filter.and(part -> part.like(FleetTripEntity::getTripNo, query.keyword().trim()) + .or().like(FleetTripEntity::getVehiclePlate, query.keyword().trim()).or().like(FleetTripEntity::getDriverName, query.keyword().trim())); + filter.orderByDesc(FleetTripEntity::getUpdatedAt, FleetTripEntity::getId); + var page = trips.selectPage(new Page<>(query.page(), query.size()), filter); + return new PageResult<>(page.getRecords().stream().map(this::response).toList(), page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); + } + + @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) + public FleetTripModels.Detail detail(String tripNo) { + FleetTripEntity trip = required(tripNo); + return detail(trip); + } + + @Transactional + public FleetTripModels.Detail create(String operator, FleetTripModels.Create request) { + String hash = hash(write(request)); + var existing = trips.findCreated(operator, request.idempotencyKey()); + if (existing != null) return replay(existing, hash); + validatePlan(request.plannedDepartureAt(), request.expectedDriverEndAt(), request.stops()); + var warehouse = warehouses.required(request.warehouseId(), "OVERSEAS"); + var trip = new FleetTripEntity(); + trip.setId(IdWorker.getId()); + trip.setTripNo("TR-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(Locale.ROOT)); + trip.setIdempotencyKey(request.idempotencyKey()); trip.setRequestHash(hash); + trip.setStatus("PLANNED"); trip.setWarehouseId(Long.valueOf(warehouse.id())); + trip.setWarehouseCode(warehouse.code()); trip.setWarehouseName(warehouse.name()); + trip.setPlannedDepartureAt(utc(request.plannedDepartureAt())); trip.setExpectedDriverEndAt(utc(request.expectedDriverEndAt())); + trip.setStopsJson(write(request.stops())); trip.setRemark(trim(request.remark())); + trip.setRecordVersion(0L); trip.setCreatedBy(operator); trip.setUpdatedBy(operator); + trip.setCreatedAt(now()); trip.setUpdatedAt(trip.getCreatedAt()); + try { if (trips.insert(trip) != 1) throw conflict("车次创建失败"); } + catch (DuplicateKeyException duplicate) { + var concurrent = trips.lockCreated(operator, request.idempotencyKey()); + if (concurrent == null) throw conflict("车次编号冲突,请重试"); + return replay(concurrent, hash); + } + record(trip, "CREATE", null, operator); + return detail(trip); + } + + @Transactional + public FleetTripModels.Detail assign(String tripNo, String operator, FleetTripModels.Assign request) { + FleetTripEntity trip = planned(tripNo, request.recordVersion()); + requireReason(request.reason()); + // All assigners serialize first on the vehicle, then on the driver. Resource uniqueness is the final safeguard. + var vehicle = vehicles.lockById(request.vehicleId()).orElseThrow(() -> bad("车辆档案不存在")); + if (!"OVERSEAS".equals(vehicle.region()) || !"IN_SERVICE".equals(vehicle.status())) throw bad("请选择在役海外车辆"); + if (vehicle.maxPalletSpaces() == null || vehicle.maxLoadKg() == null || vehicle.maxVolumeCbm() == null + || vehicle.maxPalletSpaces() <= 0 || vehicle.maxLoadKg().signum() <= 0 || vehicle.maxVolumeCbm().signum() <= 0) + throw bad("请先确认车辆的托位、载重和容积"); + if (trip.getLoadJson() != null) { + var load = read(trip.getLoadJson(), FleetExecutionModels.ConfirmedLoad.class); + if (load.segments().stream().anyMatch(segment -> segment.occupied().palletSpaces() > vehicle.maxPalletSpaces() + || segment.occupied().weightKg().compareTo(vehicle.maxLoadKg()) > 0 + || segment.occupied().volumeCbm().compareTo(vehicle.maxVolumeCbm()) > 0)) + throw bad("已确认装载超过替换车辆的托位、载重或容积,请先调整装载安排"); + } + if (!Objects.equals(trip.getVehicleId(), vehicle.id())) + bookingReservations.requireReassignmentFits(trip, vehicle.maxPalletSpaces(), vehicle.maxLoadKg(), vehicle.maxVolumeCbm()); + var driver = drivers.requireDispatchable(request.driverId(), instant(trip.getExpectedDriverEndAt())); + if (!"OVERSEAS".equals(driver.details().region())) throw bad("请选择海外司机"); + reserve("VEHICLE", request.vehicleId(), trip); + reserve("DRIVER", request.driverId(), trip); + if (trip.getVehicleId() != null && !trip.getVehicleId().equals(request.vehicleId())) release("VEHICLE", trip.getVehicleId(), trip.getId()); + if (trip.getDriverId() != null && !trip.getDriverId().equals(request.driverId())) release("DRIVER", trip.getDriverId(), trip.getId()); + trip.setVehicleId(vehicle.id()); trip.setVehiclePlate(vehicle.plateNumber()); + trip.setMaxPalletSpaces(vehicle.maxPalletSpaces()); trip.setMaxLoadKg(vehicle.maxLoadKg()); trip.setMaxVolumeCbm(vehicle.maxVolumeCbm()); + trip.setDriverId(Long.valueOf(driver.id())); trip.setDriverName(driver.details().name()); trip.setDriverPhone(driver.details().phone()); + for (var binding : bindings.listByTrip(trip.getId())) { + var dispatch = dispatches.lockForAssignment(binding.getDispatchNo()); + if (!trip.getWarehouseId().toString().equals(dispatch.warehouseId())) throw conflict("排车单与车次起运仓不一致"); + dispatches.assign(dispatch, assignment(trip)); + } + persist(trip, request.recordVersion(), "ASSIGN", request.reason(), operator); + return new FleetTripModels.Detail(response(trip), driver.qualification(), bindingResponses(trip), FleetTripReview.read(json, trip.getLoadingReviewJson())); + } + + @Transactional + public FleetTripModels.Detail updatePlan(String tripNo, String operator, FleetTripModels.Plan request) { + FleetTripEntity trip = planned(tripNo, request.recordVersion()); + requireReason(request.reason()); + validatePlan(request.plannedDepartureAt(), request.expectedDriverEndAt(), request.stops()); + bookingReservations.requireRoute(trip, request.stops()); + if (trip.getDriverId() != null) drivers.requireDispatchable(trip.getDriverId(), request.expectedDriverEndAt()); + boolean unchangedRoute = response(trip).stops().equals(request.stops()); + boolean unchangedPlan = unchangedRoute && trip.getPlannedDepartureAt().equals(utc(request.plannedDepartureAt())) + && trip.getExpectedDriverEndAt().equals(utc(request.expectedDriverEndAt())); + for (var binding : bindings.listByTrip(trip.getId())) { + if (unchangedRoute) dispatches.lockForAssignment(binding.getDispatchNo()); + else dispatches.lockForPlanning(binding.getDispatchNo()); + } + trip.setPlannedDepartureAt(utc(request.plannedDepartureAt())); trip.setExpectedDriverEndAt(utc(request.expectedDriverEndAt())); + trip.setStopsJson(write(request.stops())); trip.setRemark(trim(request.remark())); + persist(trip, request.recordVersion(), unchangedPlan ? "REMARK" : "PLAN", request.reason(), operator); + return detail(trip); + } + + @Transactional + public FleetTripModels.Detail reviewDispatch(String tripNo, String dispatchNo, String operator, FleetTripModels.Review request) { + var trip = planned(tripNo, request.recordVersion()); + String reason = FleetTripReview.reason(request); + if (trip.getVehicleId() == null || trip.getDriverId() == null) throw conflict("请先分配车辆和司机"); + if ("APPROVED".equals(request.decision())) { + var vehicle = vehicles.lockById(trip.getVehicleId()).orElseThrow(() -> bad("车辆档案不存在")); + if (!"IN_SERVICE".equals(vehicle.status()) || !"OVERSEAS".equals(vehicle.region())) throw conflict("车辆当前不可派车"); + var driver = drivers.requireDispatchable(trip.getDriverId(), instant(trip.getExpectedDriverEndAt())); + if (!"OVERSEAS".equals(driver.details().region())) throw conflict("请选择海外司机"); + } + var dispatch = dispatches.lockForAssignment(dispatchNo); + var binding = bindings.lockByDispatch(dispatch.dispatchId()); + if (binding == null || !Objects.equals(binding.getTripId(), trip.getId())) throw conflict("排车单未关联当前车次"); + binding.setReviewJson(write(new FleetTripModels.ReviewState(request.decision(), reason, operator, clock.instant(), request.recordVersion()))); + if (bindings.updateById(binding) != 1) throw conflict("排车单审核保存失败"); + trip.setLoadingReviewJson(null); + persist(trip, request.recordVersion(), "DISPATCH_REVIEW", reason, operator); + return detail(trip); + } + + @Transactional + public FleetTripModels.Detail cancel(String tripNo, String operator, FleetTripModels.Cancel request) { + FleetTripEntity trip = planned(tripNo, request.recordVersion()); + requireReason(request.reason()); + if (!bindings.listByTrip(trip.getId()).isEmpty()) throw conflict("请先解除客户排车单关联,再取消车次"); + if (bookingReservations.hasConfirmed(trip.getId())) throw conflict("请先取消已确认的外接预订,再取消车次"); + if (trip.getVehicleId() != null) release("VEHICLE", trip.getVehicleId(), trip.getId()); + if (trip.getDriverId() != null) release("DRIVER", trip.getDriverId(), trip.getId()); + trip.setStatus("CANCELLED"); + persist(trip, request.recordVersion(), "CANCEL", request.reason(), operator); + return detail(trip); + } + + @Transactional + public FleetTripModels.Detail bindDispatch(String tripNo, String operator, FleetTripModels.BindingCommand request) { + var trip = planned(tripNo, request.recordVersion()); + requireReason(request.reason()); + if (trip.getVehicleId() == null || trip.getDriverId() == null) throw conflict("请先分配车辆和司机"); + var dispatch = dispatches.lockForPlanning(request.dispatchNo().trim()); + if (!trip.getWarehouseId().toString().equals(dispatch.warehouseId())) throw bad("排车单与车次必须使用同一起运仓"); + var bound = bindings.lockByDispatch(dispatch.dispatchId()); + if (bound != null) throw conflict("排车单已关联车次 " + bound.getTripNo()); + var binding = new FleetTripDispatch(); + binding.setDispatchId(dispatch.dispatchId()); binding.setDispatchNo(dispatch.dispatchNo()); binding.setCustomerId(dispatch.customerId()); + binding.setTripId(trip.getId()); binding.setTripNo(tripNo); binding.setPackageCount(dispatch.packageCount()); + binding.setBoundBy(operator); binding.setBoundAt(now()); + try { if (bindings.insert(binding) != 1) throw conflict("排车单关联失败"); } + catch (DuplicateKeyException duplicate) { throw conflict("排车单已关联其他车次,请刷新后重试"); } + bookingReservations.bindIncoming(trip, dispatch, dispatches.sourceCargo(dispatch.dispatchNo()), operator); + dispatches.assign(dispatch, assignment(trip)); + persist(trip, request.recordVersion(), "BIND_DISPATCH", request.reason(), operator); + return detail(trip); + } + + @Transactional + public FleetTripModels.Detail unbindDispatch(String tripNo, String operator, FleetTripModels.BindingCommand request) { + var trip = planned(tripNo, request.recordVersion()); + requireReason(request.reason()); + var dispatch = dispatches.lockForPlanning(request.dispatchNo().trim()); + var binding = bindings.lockByDispatch(dispatch.dispatchId()); + if (binding == null || !Objects.equals(binding.getTripId(), trip.getId())) throw conflict("排车单未关联当前车次"); + dispatches.assign(dispatch, new FleetDispatchGateway.Assignment(null, null, null)); + if (bindings.deleteById(binding.getDispatchId()) != 1) throw conflict("排车单关联已变化,请刷新后重试"); + bookingReservations.unbindIncoming(trip, dispatch.dispatchNo(), operator); + persist(trip, request.recordVersion(), "UNBIND_DISPATCH", request.reason(), operator); + return detail(trip); + } + + public PageResult history(String tripNo, int page, int size) { + var trip = required(tripNo); + var result = histories.selectPage(new Page<>(page, size), new LambdaQueryWrapper() + .eq(FleetTripHistory::getTripId, trip.getId()).orderByDesc(FleetTripHistory::getRecordVersion)); + return new PageResult<>(result.getRecords().stream().map(event -> new FleetTripModels.History(event.getRecordVersion(), event.getAction(), + event.getReason(), event.getOperatorId(), instant(event.getOccurredAt()), read(event.getSnapshotJson(), FleetTripModels.AuditSnapshot.class))).toList(), + result.getCurrent(), result.getSize(), result.getTotal(), result.getPages()); + } + + private void reserve(String kind, long resourceId, FleetTripEntity trip) { + var current = resources.find(kind, resourceId); + if (current != null) { + if (current.tripId() == trip.getId()) return; + throw conflict(("VEHICLE".equals(kind) ? "车辆" : "司机") + "已被车次 " + current.tripNo() + " 占用"); + } + try { if (resources.reserve(kind, resourceId, trip.getId(), trip.getTripNo()) != 1) throw conflict("车次资源预留失败"); } + catch (DuplicateKeyException duplicate) { throw conflict("车辆或司机已被其他车次占用,请刷新后重试"); } + } + + private void release(String kind, long resourceId, long tripId) { + if (resources.release(kind, resourceId, tripId) != 1) throw conflict("车次资源占用不一致,操作未完成"); + } + + private FleetTripEntity required(String number) { + var trip = trips.findByNo(number); + if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在"); + return trip; + } + private FleetTripEntity planned(String number, long version) { + var trip = trips.lockByNo(number); + if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在"); + if (trip.getRecordVersion() != version) throw conflict("车次已被修改,请刷新后重试"); + if (!"PLANNED".equals(trip.getStatus())) throw conflict("仅计划中的车次允许此操作"); + return trip; + } + private FleetTripModels.Detail replay(FleetTripEntity trip, String hash) { + if (!Objects.equals(trip.getRequestHash(), hash)) throw conflict("创建请求标识已用于不同车次内容"); + return detail(trip); + } + private FleetTripModels.Detail detail(FleetTripEntity trip) { + return new FleetTripModels.Detail(response(trip), trip.getDriverId() == null ? null + : drivers.detail(trip.getDriverId(), instant(trip.getExpectedDriverEndAt())).qualification(), bindingResponses(trip), FleetTripReview.read(json, trip.getLoadingReviewJson())); + } + private List bindingResponses(FleetTripEntity trip) { + return bindings.listByTrip(trip.getId()).stream().map(binding -> new FleetTripModels.DispatchBinding(binding.getDispatchNo(), + binding.getCustomerId(), binding.getPackageCount(), binding.getBoundBy(), instant(binding.getBoundAt()), FleetTripReview.read(json, binding.getReviewJson()))).toList(); + } + private static FleetDispatchGateway.Assignment assignment(FleetTripEntity trip) { + return new FleetDispatchGateway.Assignment(trip.getVehiclePlate(), trip.getDriverName(), trip.getDriverPhone()); + } + private FleetTripModels.Response response(FleetTripEntity trip) { + List stops; + try { stops = json.readValue(trip.getStopsJson(), new TypeReference<>() { }); } + catch (JsonProcessingException error) { throw new IllegalStateException("车次路线读取失败", error); } + return new FleetTripModels.Response(trip.getTripNo(), trip.getStatus(), trip.getWarehouseId().toString(), trip.getWarehouseCode(), trip.getWarehouseName(), + text(trip.getVehicleId()), trip.getVehiclePlate(), text(trip.getDriverId()), trip.getDriverName(), trip.getDriverPhone(), + trip.getMaxPalletSpaces(), trip.getMaxLoadKg(), trip.getMaxVolumeCbm(), instant(trip.getPlannedDepartureAt()), instant(trip.getExpectedDriverEndAt()), + instant(trip.getDepartedAt()), instant(trip.getCompletedAt()), List.copyOf(stops), trip.getRemark(), trip.getRecordVersion(), instant(trip.getUpdatedAt()), + releaseInfo(trip.getVehicleReleaseJson()), releaseInfo(trip.getDriverReleaseJson())); + } + private void validatePlan(Instant departure, Instant end, List stops) { + if (departure == null || end == null || !end.isAfter(departure) || !end.isAfter(clock.instant())) throw bad("司机预计任务结束时间须晚于计划发车及当前时间"); + try { + if (utc(departure).getYear() < 1900 || utc(end).getYear() > 9998) throw bad("车次时间超出允许范围"); + } catch (DateTimeException invalid) { throw bad("车次时间超出允许范围"); } + if (stops == null || stops.size() < 2 || stops.size() > 200 || stops.stream().anyMatch(stop -> stop == null || trim(stop.name()) == null)) + throw bad("车次须包含起点和目的地,最多200个站点"); + } + private void persist(FleetTripEntity trip, long version, String action, String reason, String operator) { + if (Set.of("ASSIGN", "PLAN", "BIND_DISPATCH", "UNBIND_DISPATCH", "CANCEL").contains(action)) trip.setPlannedLoadJson(null); + if (Set.of("ASSIGN", "PLAN", "BIND_DISPATCH", "UNBIND_DISPATCH", "CANCEL").contains(action)) trip.setLoadJson(null); + if (Set.of("ASSIGN", "PLAN", "BIND_DISPATCH", "UNBIND_DISPATCH", "CANCEL", "CONFIRM_LOAD").contains(action)) trip.setLoadingReviewJson(null); + if (Set.of("ASSIGN", "PLAN").contains(action)) bindings.update(null, new LambdaUpdateWrapper() + .eq(FleetTripDispatch::getTripId, trip.getId()).set(FleetTripDispatch::getReviewJson, null)); + trip.setRecordVersion(version + 1); trip.setUpdatedAt(now()); trip.setUpdatedBy(operator); + if (trips.update(trip, new LambdaUpdateWrapper().eq(FleetTripEntity::getId, trip.getId()).eq(FleetTripEntity::getRecordVersion, version)) != 1) + throw conflict("车次已被修改,请刷新后重试"); + record(trip, action, reason, operator); + } + private void record(FleetTripEntity trip, String action, String reason, String operator) { + var event = new FleetTripHistory(); event.setId(IdWorker.getId()); event.setTripId(trip.getId()); event.setRecordVersion(trip.getRecordVersion()); + event.setAction(action); event.setReason(trim(reason)); event.setOperatorId(operator); event.setOccurredAt(trip.getUpdatedAt()); + event.setSnapshotJson(write(new FleetTripModels.AuditSnapshot(response(trip), bindingResponses(trip), trip.getLoadJson() == null ? null + : read(trip.getLoadJson(), FleetExecutionModels.ConfirmedLoad.class), trip.getCompletionJson() == null ? null + : read(trip.getCompletionJson(), FleetCompletionModels.Completed.class), FleetTripReview.read(json, trip.getLoadingReviewJson()), + trip.getPlannedLoadJson() == null ? null : read(trip.getPlannedLoadJson(), FleetExecutionModels.PlannedLoad.class), bookingReservations.responses(trip.getId())))); + if (histories.insert(event) != 1) throw conflict("车次历史保存失败"); + } + private String write(Object value) { try { return json.writeValueAsString(value); } catch (JsonProcessingException e) { throw new IllegalStateException("车次序列化失败", e); } } + private FleetCompletionModels.ReleaseInfo releaseInfo(String value) { + if (value == null) return null; + var released = read(value, FleetCompletionModels.Released.class); + return new FleetCompletionModels.ReleaseInfo(released.actualEndAt(), released.endLocation(), released.vehicleStatus()); + } + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public void recordExecution(FleetTripEntity trip, long version, String action, String reason, String operator) { + persist(trip, version, action, reason, operator); + } + private T read(String value, Class type) { try { return json.readValue(value, type); } catch (JsonProcessingException e) { throw new IllegalStateException("车次历史读取失败", e); } } + private static String hash(String value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } } + private LocalDateTime now() { return utc(clock.instant()); } + private static LocalDateTime utc(Instant value) { return LocalDateTime.ofInstant(value, ZoneOffset.UTC); } + private static Instant instant(LocalDateTime value) { return value == null ? null : value.toInstant(ZoneOffset.UTC); } + private static String text(Long value) { return value == null ? null : value.toString(); } + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static void requireReason(String reason) { if (trim(reason) == null) throw bad("请填写操作原因"); } + private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetVehicleAssignmentGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetVehicleAssignmentGuard.java new file mode 100644 index 000000000..cb015a615 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetVehicleAssignmentGuard.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleAssignmentGuard; +import org.springframework.stereotype.Component; + +@Component +public class FleetVehicleAssignmentGuard implements VehicleAssignmentGuard { + private final FleetResourceMapper resources; + public FleetVehicleAssignmentGuard(FleetResourceMapper resources) { this.resources = resources; } + @Override public void requireUnassigned(long vehicleId) { + var assignment = resources.find("VEHICLE", vehicleId); + if (assignment != null) throw new VehicleApplicationService.VehicleFailure(VehicleApplicationService.FailureKind.CONFLICT, + "车辆正在车次 " + assignment.tripNo() + " 中使用,请先在车次中改派或取消安排"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetBookingController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetBookingController.java new file mode 100644 index 000000000..0bb897829 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetBookingController.java @@ -0,0 +1,44 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.fleet.quote.FleetQuoteCandidateModels; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import java.util.List; + +@RestController @RequestMapping("/api/platform/v1/last-leg/fleet/trips") +public class PlatformFleetBookingController { + private final FleetBookingService service; + private final FleetCustomerDirectory customers; + public PlatformFleetBookingController(FleetBookingService service,FleetCustomerDirectory customers){this.service=service;this.customers=customers;} + @GetMapping("/customer-options") @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse> customers(@RequestParam(required=false) String keyword){return ApiResponse.success(customers.options(keyword));} + @GetMapping("/{tripNo}/bookings") @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse list(@PathVariable String tripNo){return ApiResponse.success(service.list(tripNo));} + @GetMapping("/{tripNo}/bookings/quote-candidates") @PreAuthorize("hasAuthority('platform:fleet:trip:list') and hasAuthority('platform:fleet:quote:list')") + public ApiResponse> quoteCandidates(@PathVariable String tripNo,@Valid FleetBookingModels.QuoteCandidatesQuery query){return ApiResponse.success(service.quoteCandidates(tripNo,query));} + @PostMapping("/{tripNo}/bookings") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-create')") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@Valid @RequestBody FleetBookingModels.Create request){return ApiResponse.success(service.create(tripNo,operator(user),request));} + @PutMapping("/{tripNo}/bookings/{bookingNo}") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-edit')") + public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.Update request){return ApiResponse.success(service.update(tripNo,bookingNo,operator(user),request));} + @PostMapping("/{tripNo}/bookings/{bookingNo}/confirm") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-confirm')") + public ApiResponse confirm(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.Command request){return ApiResponse.success(service.confirm(tripNo,bookingNo,operator(user),request));} + @PostMapping("/{tripNo}/bookings/{bookingNo}/cancel") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-cancel')") + public ApiResponse cancel(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.Command request){return ApiResponse.success(service.cancel(tripNo,bookingNo,operator(user),request));} + @GetMapping("/{tripNo}/bookings/{bookingNo}/incoming") @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse incoming(@PathVariable String tripNo,@PathVariable String bookingNo){return ApiResponse.success(service.incoming(tripNo,bookingNo));} + @PostMapping("/{tripNo}/bookings/{bookingNo}/incoming") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-edit')") + public ApiResponse incoming(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.StartIncoming request){return ApiResponse.success(service.startIncoming(tripNo,bookingNo,operator(user),request));} + @PostMapping("/{tripNo}/bookings/{bookingNo}/receipt-confirmation") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-confirm')") + public ApiResponse receipt(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.AcceptReceipt request){return ApiResponse.success(service.acceptReceipt(tripNo,bookingNo,operator(user),request));} + @PostMapping("/{tripNo}/bookings/{bookingNo}/dispatch") @PreAuthorize("hasAuthority('platform:fleet:trip:booking-edit') and hasAuthority('platform:fleet:trip:edit')") + public ApiResponse bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.BindDispatch request){return ApiResponse.success(service.bindDispatch(tripNo,bookingNo,operator(user),request));} + private static String operator(AuthenticatedUser user){if(user==null||user.userId()==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,"请重新登录");return user.userId().toString();} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java new file mode 100644 index 000000000..18259b501 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java @@ -0,0 +1,120 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@Validated +@RequestMapping("/api/platform/v1/last-leg/fleet/trips") +public class PlatformFleetTripController { + private final FleetTripService service; + private final FleetTripExecutionService execution; + private final FleetTripCompletionService completion; + public PlatformFleetTripController(FleetTripService service, FleetTripExecutionService execution, FleetTripCompletionService completion) { + this.service = service; this.execution = execution; this.completion = completion; + } + + @GetMapping + @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse> page(@Valid FleetTripModels.Query query) { return ApiResponse.success(service.page(query)); } + @GetMapping("/{tripNo}") + @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse detail(@PathVariable String tripNo) { return ApiResponse.success(service.detail(tripNo)); } + @PostMapping + @PreAuthorize("hasAuthority('platform:fleet:trip:create')") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody FleetTripModels.Create request) { + return ApiResponse.success(service.create(operator(user), request)); + } + @PutMapping("/{tripNo}/plan") + @PreAuthorize("hasAuthority('platform:fleet:trip:edit')") + public ApiResponse plan(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, @Valid @RequestBody FleetTripModels.Plan request) { + return ApiResponse.success(service.updatePlan(tripNo, operator(user), request)); + } + @PostMapping("/{tripNo}/assignment") + @PreAuthorize("hasAuthority('platform:fleet:trip:assign')") + public ApiResponse assign(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, @Valid @RequestBody FleetTripModels.Assign request) { + return ApiResponse.success(service.assign(tripNo, operator(user), request)); + } + @PostMapping("/{tripNo}/cancel") + @PreAuthorize("hasAuthority('platform:fleet:trip:cancel')") + public ApiResponse cancel(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, @Valid @RequestBody FleetTripModels.Cancel request) { + return ApiResponse.success(service.cancel(tripNo, operator(user), request)); + } + @PostMapping("/{tripNo}/dispatches/bind") + @PreAuthorize("hasAuthority('platform:fleet:trip:edit')") + public ApiResponse bind(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, + @Valid @RequestBody FleetTripModels.BindingCommand request) { + return ApiResponse.success(service.bindDispatch(tripNo, operator(user), request)); + } + @PostMapping("/{tripNo}/dispatches/unbind") + @PreAuthorize("hasAuthority('platform:fleet:trip:edit')") + public ApiResponse unbind(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, + @Valid @RequestBody FleetTripModels.BindingCommand request) { + return ApiResponse.success(service.unbindDispatch(tripNo, operator(user), request)); + } + @GetMapping("/{tripNo}/history") + @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse> history(@PathVariable String tripNo, + @RequestParam(defaultValue = "1") @Min(1) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(200) int size) { + return ApiResponse.success(service.history(tripNo, page, size)); + } + @PostMapping("/{tripNo}/dispatches/{dispatchNo}/review") + @PreAuthorize("hasAuthority('platform:fleet:trip:dispatch-review')") + public ApiResponse reviewDispatch(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, + @PathVariable String dispatchNo, @Valid @RequestBody FleetTripModels.Review request) { + return ApiResponse.success(service.reviewDispatch(tripNo, dispatchNo, operator(user), request)); + } + @PostMapping("/{tripNo}/loading-review") + @PreAuthorize("hasAuthority('platform:fleet:trip:loading-review')") + public ApiResponse reviewLoading(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, + @Valid @RequestBody FleetTripModels.Review request) { + return ApiResponse.success(execution.reviewLoading(tripNo, operator(user), request)); + } + @GetMapping("/{tripNo}/loading") + @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse loading(@PathVariable String tripNo) { return ApiResponse.success(execution.loading(tripNo)); } + @GetMapping("/{tripNo}/capacity-plan") + @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse capacityPlan(@PathVariable String tripNo) { return ApiResponse.success(execution.capacityPlan(tripNo)); } + @PostMapping("/{tripNo}/capacity-plan") + @PreAuthorize("hasAuthority('platform:fleet:trip:edit')") + public ApiResponse confirmCapacityPlan(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, + @Valid @RequestBody FleetExecutionModels.Confirm request) { + return ApiResponse.success(execution.confirmCapacityPlan(tripNo, operator(user), request)); + } + @PostMapping("/{tripNo}/loading") + @PreAuthorize("hasAuthority('platform:fleet:trip:edit')") + public ApiResponse confirmLoading(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, + @Valid @RequestBody FleetExecutionModels.Confirm request) { + return ApiResponse.success(execution.confirmLoad(tripNo, operator(user), request)); + } + private static String operator(AuthenticatedUser user) { + if (user == null || user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "请重新登录"); + return user.userId().toString(); + } + @GetMapping("/{tripNo}/completion") + @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse completion(@PathVariable String tripNo) { return ApiResponse.success(completion.inspection(tripNo)); } + @PostMapping("/{tripNo}/complete") + @PreAuthorize("hasAuthority('platform:fleet:trip:complete')") + public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, + @Valid @RequestBody FleetCompletionModels.Complete request) { + return ApiResponse.success(completion.complete(tripNo, operator(user), request)); + } + @PostMapping("/{tripNo}/resources/release") + @PreAuthorize("hasAuthority('platform:fleet:trip:release')") + public ApiResponse release(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, + @Valid @RequestBody FleetCompletionModels.Release request) { + return ApiResponse.success(completion.release(tripNo, operator(user), request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripCostController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripCostController.java new file mode 100644 index 000000000..6effa8274 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripCostController.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.common.core.api.*; +import com.baiyue.common.security.AuthenticatedUser; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/fleet/trips/{tripNo}/costs") +public class PlatformFleetTripCostController { + private final FleetTripCostService service; + public PlatformFleetTripCostController(FleetTripCostService service) { this.service = service; } + @GetMapping + @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse list(@PathVariable String tripNo, @Valid FleetTripCostModels.Query query) { + return ApiResponse.success(service.list(tripNo, query)); + } + @GetMapping("/{costNo}/history") + @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse> history(@PathVariable String tripNo, @PathVariable String costNo, @Valid FleetTripCostModels.Query query) { + return ApiResponse.success(service.history(tripNo, costNo, query)); + } + @PostMapping + @PreAuthorize("hasAuthority('platform:fleet:trip:cost-create')") + public ApiResponse create(@PathVariable String tripNo, @AuthenticationPrincipal AuthenticatedUser user, + @RequestBody @Valid FleetTripCostModels.Create request) { + return ApiResponse.success(service.create(tripNo, user.userId().toString(), request)); + } + @PutMapping("/{costNo}") + @PreAuthorize("hasAuthority('platform:fleet:trip:cost-edit')") + public ApiResponse update(@PathVariable String tripNo, @PathVariable String costNo, + @AuthenticationPrincipal AuthenticatedUser user, @RequestBody @Valid FleetTripCostModels.Update request) { + return ApiResponse.success(service.update(tripNo, costNo, user.userId().toString(), request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformVehicleFulfillmentController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformVehicleFulfillmentController.java new file mode 100644 index 000000000..6bed8a9b0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformVehicleFulfillmentController.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@RestController +@Validated +@RequestMapping("/api/platform/v1/last-leg/fleet/vehicles/{vehicleId}") +public class PlatformVehicleFulfillmentController { + private final VehicleFulfillmentService service; + public PlatformVehicleFulfillmentController(VehicleFulfillmentService service) { this.service = service; } + @GetMapping("/deliveries") + @PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:list') and hasAuthority('platform:overseas-warehouse:delivery:list')") + public ApiResponse> deliveries(@PathVariable @Min(1) long vehicleId, @Valid VehicleFulfillmentModels.Query query) { + return ApiResponse.success(service.deliveries(vehicleId, query)); + } + @GetMapping("/assignment-history") + @PreAuthorize("hasAuthority('platform:overseas-master-data:vehicle:list') and hasAuthority('platform:fleet:trip:list')") + public ApiResponse> changes(@PathVariable @Min(1) long vehicleId, @Valid VehicleFulfillmentModels.Query query) { + return ApiResponse.success(service.changes(vehicleId, query)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentMapper.java new file mode 100644 index 000000000..720cd2b0f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentMapper.java @@ -0,0 +1,44 @@ +package com.baiyue.lastleg.fleet.trip; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import java.util.List; + +/** Read projection in the Lastleg schema. Never derives vehicle identity from a mutable plate. */ +@Mapper +public interface VehicleFulfillmentMapper { + String ORIGINAL_DEPARTURE = "EXISTS (SELECT 1 FROM ll_fleet_trip_history h WHERE h.trip_id=t.id AND h.action='DEPART' " + + "AND JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.trip.vehicleId'))=#{vehicleKey})"; + String HANDOVER = "EXISTS (SELECT 1 FROM ll_fleet_trip_handover ho WHERE ho.trip_id=t.id " + + "AND JSON_UNQUOTE(JSON_EXTRACT(ho.snapshot_json,'$.replacement.vehicleId'))=#{vehicleKey} " + + "AND JSON_CONTAINS(ho.snapshot_json,CONCAT('{\"customerId\":',JSON_QUOTE(d.customer_id),',\"deliveryNo\":',JSON_QUOTE(d.delivery_no),'}'),'$.carriedDeliveries')=1)"; + String DEPARTED = "(" + ORIGINAL_DEPARTURE + " OR " + HANDOVER + ")"; + String DELIVERIES = " FROM ll_delivery_order d JOIN ll_fleet_trip_dispatch b " + + "ON b.dispatch_id=d.dispatch_order_id AND CAST(b.customer_id AS BINARY(128))=CAST(d.customer_id AS BINARY(128)) " + + "JOIN ll_fleet_trip t ON t.id=b.trip_id WHERE ((t.status='PLANNED' AND t.vehicle_id=#{vehicleId}) OR " + DEPARTED + ")"; + String PREVIOUS_VEHICLE = "JSON_UNQUOTE(JSON_EXTRACT(p.snapshot_json,'$.trip.vehicleId'))"; + String NEXT_VEHICLE = "JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.trip.vehicleId'))"; + String CHANGES = " FROM ll_fleet_trip_history h JOIN ll_fleet_trip t ON t.id=h.trip_id " + + "JOIN ll_fleet_trip_history p ON p.trip_id=h.trip_id AND p.record_version=h.record_version-1 " + + "LEFT JOIN ll_fleet_trip_handover ho ON ho.trip_id=h.trip_id AND ho.trip_version=h.record_version " + + "WHERE (h.action='ASSIGN' OR (h.action='HANDOVER' AND ho.id IS NOT NULL)) AND " + PREVIOUS_VEHICLE + " IS NOT NULL AND " + PREVIOUS_VEHICLE + " <> 'null' " + + "AND (" + PREVIOUS_VEHICLE + "=#{vehicleKey} OR " + NEXT_VEHICLE + "=#{vehicleKey}) " + + "AND (" + PREVIOUS_VEHICLE + "<>" + NEXT_VEHICLE + + " OR JSON_UNQUOTE(JSON_EXTRACT(p.snapshot_json,'$.trip.driverId'))<>JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.trip.driverId')))"; + + @Select("SELECT COUNT(*)" + DELIVERIES) + long countDeliveries(@Param("vehicleId") long vehicleId, @Param("vehicleKey") String vehicleKey); + @Select("SELECT d.delivery_no,d.dispatch_no,t.trip_no,d.bol_no,d.customer_name,d.destination_type," + + "d.destination_warehouse_code,d.destination_warehouse_name,d.delivery_address,d.planned_delivery_at,d.status," + + "CASE WHEN " + DEPARTED + " THEN 'DEPARTED' ELSE 'PLANNED' END AS relationship" + DELIVERIES + + " ORDER BY d.created_at DESC,d.id DESC LIMIT #{size} OFFSET #{offset}") + List deliveries(@Param("vehicleId") long vehicleId, @Param("vehicleKey") String vehicleKey, + @Param("offset") long offset, @Param("size") int size); + @Select("SELECT COUNT(*)" + CHANGES) + long countChanges(@Param("vehicleKey") String vehicleKey); + @Select("SELECT t.trip_no,h.record_version,h.occurred_at,h.reason,p.snapshot_json AS before_snapshot," + + "CASE WHEN h.action='HANDOVER' THEN JSON_SET(h.snapshot_json,'$.handover',JSON_EXTRACT(ho.snapshot_json,'$')) ELSE h.snapshot_json END AS after_snapshot" + + CHANGES + " ORDER BY h.occurred_at DESC,h.id DESC LIMIT #{size} OFFSET #{offset}") + List changes(@Param("vehicleKey") String vehicleKey, @Param("offset") long offset, @Param("size") int size); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentModels.java new file mode 100644 index 000000000..255e135c8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentModels.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.fleet.trip; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; + +public final class VehicleFulfillmentModels { + private VehicleFulfillmentModels() { } + public record Query(@Min(1) Integer page, @Min(1) @Max(100) Integer size) { + public Query { page = page == null ? 1 : page; size = size == null ? 10 : size; } + public long offset() { return ((long) page - 1) * size; } + } + public record Delivery(String deliveryNo, String dispatchNo, String tripNo, String bolNo, + String customerName, String destinationType, String destinationWarehouseCode, + String destinationWarehouseName, String deliveryAddress, + LocalDateTime plannedDeliveryAt, String status, String relationship) { } + public record ChangeRow(String tripNo, long recordVersion, LocalDateTime occurredAt, String reason, + String beforeSnapshot, String afterSnapshot) { } + public record Change(String tripNo, long recordVersion, Instant occurredAt, String reason, + String previousVehiclePlate, String vehiclePlate, + String previousDriverName, String driverName, String relationship, + List dispatchNos) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentService.java new file mode 100644 index 000000000..82647e42b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentService.java @@ -0,0 +1,53 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import java.time.ZoneOffset; + +@Service +@Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) +public class VehicleFulfillmentService { + private final VehicleRepository vehicles; + private final VehicleFulfillmentMapper mapper; + private final ObjectMapper json; + public VehicleFulfillmentService(VehicleRepository vehicles, VehicleFulfillmentMapper mapper, ObjectMapper json) { + this.vehicles = vehicles; this.mapper = mapper; this.json = json; + } + public PageResult deliveries(long vehicleId, VehicleFulfillmentModels.Query query) { + requireVehicle(vehicleId); String key = Long.toString(vehicleId); + long total = mapper.countDeliveries(vehicleId, key); + return new PageResult<>(mapper.deliveries(vehicleId, key, query.offset(), query.size()), query.page(), query.size(), total, pages(total, query.size())); + } + public PageResult changes(long vehicleId, VehicleFulfillmentModels.Query query) { + requireVehicle(vehicleId); String key = Long.toString(vehicleId); + long total = mapper.countChanges(key); + var records = mapper.changes(key, query.offset(), query.size()).stream().map(row -> change(key, row)).toList(); + return new PageResult<>(records, query.page(), query.size(), total, pages(total, query.size())); + } + private VehicleFulfillmentModels.Change change(String key, VehicleFulfillmentModels.ChangeRow row) { + try { + JsonNode before = json.readTree(row.beforeSnapshot()).path("trip"), snapshot = json.readTree(row.afterSnapshot()), after = snapshot.path("trip"); + if (!before.hasNonNull("vehicleId") || !after.hasNonNull("vehicleId")) throw new IllegalStateException("车辆更换历史缺少身份快照"); + var dispatches = new java.util.LinkedHashSet(); + JsonNode associated = snapshot.hasNonNull("handover") ? snapshot.path("handover").path("cargo").path("remainingCargo") : snapshot.path("dispatches"); + for (JsonNode binding : associated) if (binding.hasNonNull("dispatchNo")) dispatches.add(binding.get("dispatchNo").asText()); + String relationship = before.path("vehicleId").asText().equals(after.path("vehicleId").asText()) ? "DRIVER_CHANGED" + : key.equals(before.path("vehicleId").asText()) ? "REPLACED_OUT" : "REPLACED_IN"; + return new VehicleFulfillmentModels.Change(row.tripNo(), row.recordVersion(), row.occurredAt().toInstant(ZoneOffset.UTC), row.reason(), + text(before, "vehiclePlate"), text(after, "vehiclePlate"), text(before, "driverName"), text(after, "driverName"), relationship, java.util.List.copyOf(dispatches)); + } catch (JsonProcessingException error) { throw new IllegalStateException("车辆更换历史快照无法读取", error); } + } + private static String text(JsonNode value, String key) { return value.hasNonNull(key) ? value.get(key).asText() : null; } + private static long pages(long total, int size) { return total / size + (total % size == 0 ? 0 : 1); } + private void requireVehicle(long vehicleId) { + vehicles.findById(vehicleId).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "车辆不存在")); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index 76699da30..e9c576057 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -86,7 +86,7 @@ public class InboundPlanController { if (user == null || user.tenantId() == null || user.tenantId() != 0L || !"PLATFORM".equals(user.appCode())) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台账号可预览整柜入库计划"); } - return ApiResponse.success(service.previewContainer(sourceContainerBookingId)); + return ApiResponse.success(containerConversion.preview(sourceContainerBookingId)); } @GetMapping("/{planNo}") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ContainerInboundPlanPreviewResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ContainerInboundPlanPreviewResponse.java index 639b9851e..e78d7805c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ContainerInboundPlanPreviewResponse.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ContainerInboundPlanPreviewResponse.java @@ -23,8 +23,20 @@ public record ContainerInboundPlanPreviewResponse( int totalSuggestedPalletCount, int totalPlannedPalletCount, boolean ready, - List groups + List groups, + ResourceRetry resourceRetry ) { + /** Original persisted command; distinct from the current display snapshot. */ + public record ResourceRetry(String idempotencyKey, long snapshotVersion, String snapshotHash) { } + + public ContainerInboundPlanPreviewResponse withResourceRetry(ResourceRetry retry) { + return new ContainerInboundPlanPreviewResponse(sourceContainerBookingId, containerNo, + overseasWarehouseId, overseasWarehouseCode, overseasWarehouseName, snapshotVersion, snapshotHash, + totalOrderCount, readyOrderCount, blockedOrderCount, pendingOrderCount, pendingPalletCount, + sourcePalletCount, supplementPalletCount, existingPlannedPalletCount, totalSuggestedPalletCount, + totalPlannedPalletCount, ready, groups, retry); + } + public record Group( String destinationType, String destinationWarehouseId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/ContainerPlanConversionCommandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/ContainerPlanConversionCommandMapper.java index a4bc87cc3..a9053503e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/ContainerPlanConversionCommandMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/ContainerPlanConversionCommandMapper.java @@ -11,6 +11,14 @@ import java.time.LocalDateTime; @Mapper public interface ContainerPlanConversionCommandMapper extends BaseMapper { + // Only plans listed in this durable command are pending; older plans in the same cabinet stay confirmed. + String UNCONFIRMED_PLAN = "EXISTS (SELECT 1 FROM ll_container_plan_conversion_command rc " + + "JOIN JSON_TABLE(rc.preparation_json, '$.items[*]' COLUMNS (" + + "customer_id VARCHAR(20) PATH '$.customerId' ERROR ON EMPTY ERROR ON ERROR," + + "plan_id BIGINT PATH '$.planId' ERROR ON EMPTY ERROR ON ERROR)) resource_item " + + "ON resource_item.plan_id=p.id AND CAST(resource_item.customer_id AS BINARY)=CAST(p.customer_id AS BINARY) " + + "WHERE rc.source_container_booking_id=o.source_container_booking_id AND rc.status='LOCAL_COMMITTED')"; + @Select("SELECT * FROM ll_container_plan_conversion_command WHERE source_container_booking_id=#{bookingId} AND idempotency_key=#{key} LIMIT 1") ContainerPlanConversionCommand find(@Param("bookingId") String bookingId, @Param("key") String key); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java index 534969f23..d572e8e41 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java @@ -49,6 +49,11 @@ public interface InboundPlanMapper extends BaseMapper { @Select("SELECT * FROM ll_inbound_plan WHERE customer_id=#{customerId} AND source_order_id=#{sourceOrderId} LIMIT 1") InboundPlan findBySourceOrder(@Param("customerId") String customerId, @Param("sourceOrderId") Long sourceOrderId); + @Select("SELECT COUNT(*) FROM ll_inbound_plan p JOIN ll_order o ON o.customer_id=p.customer_id " + + "AND o.id=p.source_order_id WHERE p.customer_id=#{customerId} AND p.id=#{planId} AND " + + ContainerPlanConversionCommandMapper.UNCONFIRMED_PLAN) + long countUnconfirmedResourcePlans(@Param("customerId") String customerId, @Param("planId") long planId); + @Update("UPDATE ll_inbound_plan SET overseas_warehouse_id=#{warehouseId},updated_at=NOW(3) " + "WHERE id=#{planId} AND customer_id=#{customerId} " + "AND (overseas_warehouse_id IS NULL OR overseas_warehouse_id=#{warehouseId})") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java index 0569b86d0..6d43cc29d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java @@ -38,7 +38,8 @@ public interface InboundPlanOrderMapper { "o.inbound_order_no AS inboundOrderNo,o.container_no AS containerNo,COALESCE(o.pallet_count,0) AS recommendedPalletCount," + "COALESCE(o.package_count,0) AS packageCount,COALESCE(o.volume_cbm,0) AS volumeCbm," + "o.overseas_warehouse_id AS overseasWarehouseId,o.overseas_warehouse_code AS overseasWarehouseCode," + - "o.overseas_warehouse_name AS overseasWarehouseName,o.fba_warehouse_code AS fbaWarehouseCode," + + "o.overseas_warehouse_name AS overseasWarehouseName," + + "COALESCE(NULLIF(o.transfer_warehouse_code,''),NULLIF(o.fba_warehouse_code,'')) AS fbaWarehouseCode," + "COALESCE(NULLIF(o.transfer_warehouse_id,''),(SELECT MIN(NULLIF(d.destination_warehouse_id,'')) FROM ll_order_detail d WHERE d.order_id=o.id)) AS destinationWarehouseId," + "COALESCE(NULLIF(o.transfer_warehouse_code,''),(SELECT MIN(NULLIF(d.destination_warehouse_code,'')) FROM ll_order_detail d WHERE d.order_id=o.id)) AS destinationWarehouseCode," + "COALESCE(NULLIF(o.transfer_warehouse_name,''),(SELECT MIN(NULLIF(d.destination_warehouse_name,'')) FROM ll_order_detail d WHERE d.order_id=o.id)) AS destinationWarehouseName," + @@ -47,7 +48,8 @@ public interface InboundPlanOrderMapper { "o.detained AS detained,o.version AS version,p.plan_no AS planNo,p.status AS planStatus," + "p.pallet_count AS plannedPalletCount," + "(SELECT COUNT(*) FROM ll_inbound_plan_pallet pp WHERE pp.customer_id=p.customer_id " + - "AND pp.plan_id=p.id AND pp.warehouse_pallet_id IS NOT NULL) AS materializedPalletCount," + + "AND pp.plan_id=p.id AND pp.warehouse_pallet_id IS NOT NULL AND NOT " + + ContainerPlanConversionCommandMapper.UNCONFIRMED_PLAN + ") AS materializedPalletCount," + "(SELECT COUNT(*) FROM ll_order_source_pallet sp WHERE sp.customer_id=o.customer_id " + "AND sp.order_id=o.id) AS sourcePalletCount " + "FROM ll_order o LEFT JOIN ll_inbound_plan p ON p.customer_id=o.customer_id AND p.source_order_id=o.id " + @@ -91,7 +93,7 @@ public interface InboundPlanOrderMapper { "COALESCE(o.volume_cbm,0) AS volumeCbm,COALESCE(o.package_count,0) AS packageCount," + "COALESCE(o.pallet_count,0) AS recommendedPalletCount,o.overseas_warehouse_id AS overseasWarehouseId," + "o.overseas_warehouse_code AS overseasWarehouseCode,o.overseas_warehouse_name AS overseasWarehouseName," + - "NULLIF(o.fba_warehouse_code,'') AS fbaWarehouseCode," + + "COALESCE(NULLIF(o.transfer_warehouse_code,''),NULLIF(o.fba_warehouse_code,'')) AS fbaWarehouseCode," + "COALESCE(NULLIF(o.transfer_warehouse_id,''),MIN(NULLIF(d.destination_warehouse_id,''))) AS destinationWarehouseId," + "COALESCE(NULLIF(o.transfer_warehouse_code,''),MIN(NULLIF(d.destination_warehouse_code,''))) AS destinationWarehouseCode," + "COALESCE(NULLIF(o.transfer_warehouse_name,''),MIN(NULLIF(d.destination_warehouse_name,''))) AS destinationWarehouseName," + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java index f428897ce..c2b77aaef 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java @@ -10,6 +10,12 @@ import java.util.List; @Mapper public interface InboundPlanQueryMapper { + String SCHEDULED_DISPATCH = "EXISTS (SELECT 1 FROM ll_dispatch_item di" + + " JOIN ll_dispatch_order dispatch ON dispatch.id=di.dispatch_order_id" + + " AND dispatch.customer_id=di.customer_id" + + " WHERE di.customer_id=p.customer_id AND di.order_id=l.order_id" + + " AND dispatch.status<>'CANCELLED')"; + String FROM_AND_FILTER = " FROM ll_inbound_plan_line l" + " JOIN ll_inbound_plan p ON p.id=l.plan_id" + " JOIN ll_order o ON o.id=l.order_id" + @@ -31,8 +37,8 @@ public interface InboundPlanQueryMapper { " AND l.fba_warehouse_code LIKE CONCAT('%',#{q.fbaWarehouseCode},'%')" + " AND ai.id IS NULL" + " AND a.appointment_status=#{q.appointmentStatus}" + - " AND EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE di.customer_id=#{customerId} AND di.order_id=l.order_id)" + - " AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE di.customer_id=#{customerId} AND di.order_id=l.order_id)" + + " AND " + SCHEDULED_DISPATCH + "" + + " AND NOT " + SCHEDULED_DISPATCH + "" + " AND p.created_at >=#{q.createdFrom}" + " AND p.created_at <=#{q.createdTo}"; @@ -52,8 +58,7 @@ public interface InboundPlanQueryMapper { "p.urgent_flag urgent,p.status status,a.appointment_no appointmentNo," + "a.appointment_status appointmentStatus,a.appointment_at appointmentAt," + "a.expected_loading_at expectedLoadingAt," + - "CASE WHEN EXISTS (SELECT 1 FROM ll_dispatch_item di WHERE " + - "di.customer_id=#{customerId} AND di.order_id=l.order_id)" + + "CASE WHEN " + SCHEDULED_DISPATCH + " THEN 'SCHEDULED' ELSE 'UNSCHEDULED' END dispatchStatus,p.created_at createdAt,p.version version" + FROM_AND_FILTER + " ORDER BY p.created_at DESC,l.id DESC LIMIT #{limit} OFFSET #{offset}") List rows(@Param("customerId") String customerId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java index 4983eda0d..2a56165fe 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java @@ -75,7 +75,15 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { response.message() == null || response.message().isBlank() ? "Warehouse resource reservation was rejected" : response.message()); } - return response.data().lockId(); + ReserveData lock = response.data(); + if (!"200".equals(response.code()) || lock.lockId() <= 0 || !"ACTIVE".equals(lock.status()) + || !Long.valueOf(Long.parseLong(customerId)).equals(lock.customerId()) + || !Long.valueOf(warehouseId).equals(lock.warehouseId()) || !Long.valueOf(palletId).equals(lock.palletId()) + || !Long.valueOf(locationId).equals(lock.locationId()) || !java.util.Objects.equals(businessType, lock.businessType()) + || !java.util.Objects.equals(businessId, lock.businessId()) || !java.util.Objects.equals(reservationKey, lock.reservationKey())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Warehouse reservation is not the requested ACTIVE lock"); + } + return lock.lockId(); } @Override @@ -340,7 +348,8 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { String locationType, String locationStatus, boolean reservable, String reservationBlockReason) {} private record ReserveEnvelope(String code, String message, ReserveData data) {} - private record ReserveData(long lockId) {} + private record ReserveData(long lockId, Long customerId, Long warehouseId, Long palletId, Long locationId, + String businessType, String businessId, String reservationKey, String status) {} private record WarehouseReferenceResponse(String code, String message, WarehouseReference data) {} private record WarehouseLocationResponse(String code, String message, WarehouseLocationReference data) {} private record PalletBatchEnvelope(String code, String message, PalletBatchResponse data) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java index 9a080980d..d28ad8587 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java @@ -277,6 +277,7 @@ public class InboundStockReceiptService { receipt.setPalletNo(pallet.getPalletNo()); receipt.setSourcePalletNo(pallet.getSourcePalletNo()); receipt.setWarehouseLocationId(pallet.getWarehouseLocationId()); + receipt.setWarehouseLocationCode(warehouseLocationCode); receipt.setBatchNo(plan.getPlanNo()); receipt.setReceivedPackages(packages); receipt.setInventoryBalanceId(result.balanceId()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java index b7988f67d..5b5e9e4f9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java @@ -1,8 +1,18 @@ package com.baiyue.lastleg.inboundplan.pda.service; +import com.baiyue.common.core.outbox.EventDeliveryJournal; +import com.baiyue.common.core.outbox.EventDeliveryProducer; +import com.baiyue.common.core.outbox.EventDeliveryJournal.Outcome; +import com.baiyue.common.web.EventDeliveryMetrics; +import com.baiyue.common.web.EventDeliveryMetrics.Stream; +import com.baiyue.common.web.EventDeliveryObservation; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.web.EventDeliveryFailure; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.baiyue.common.core.retry.BoundedRetryPolicy; +import com.baiyue.common.event.DeliveryAck; import com.baiyue.common.event.EventEnvelopeValidator; -import com.baiyue.lastleg.client.EventBridgeClient; +import com.baiyue.lastleg.client.DirectEventClient; import com.baiyue.lastleg.entity.LastlegEventOutbox; import com.baiyue.lastleg.repository.UnloadCompletedOutboxRepository; import com.fasterxml.jackson.databind.ObjectMapper; @@ -15,51 +25,84 @@ import org.springframework.stereotype.Component; import java.time.LocalDateTime; import java.util.UUID; -/** 至少一次投递海外到国内业务事件;国内事件桥按 eventId 幂等接收。 */ +/** 至少一次投递海外到国内业务事件;消费服务按 eventId 幂等接收。 */ @Component @Slf4j -public class InboundUnloadCompletedOutboxRelay { +public class InboundUnloadCompletedOutboxRelay implements EventDeliveryProducer { @Resource private UnloadCompletedOutboxRepository repository; - @Resource private EventBridgeClient client; + @Resource private DirectEventClient client; @Resource private ObjectMapper objectMapper; + @Resource private EventDeliveryJournal journal; + @Resource private EventDeliveryMetrics metrics; @Value("${baiyue.security.internal-service-token}") private String internalToken; - @Value("${baiyue.firstleg.outbox-relay.lease-seconds:60}") private long leaseSeconds; - @Value("${baiyue.firstleg.outbox-relay.retry-seconds:30}") private long retrySeconds; - private final String workerId = UUID.randomUUID().toString(); + @Value("${baiyue.event-delivery.lease-seconds:60}") private long leaseSeconds = 60; + + @Value("${baiyue.event-delivery.max-attempts:12}") private int maxAttempts = 12; + @Value("${baiyue.event-delivery.base-delay-seconds:5}") private long baseDelaySeconds = 5; + @Value("${baiyue.event-delivery.max-delay-seconds:1800}") private long maxDelaySeconds = 1800; + @Value("${baiyue.event-delivery.jitter:0.1}") private double retryJitter = 0.1; @Scheduled(fixedDelayString = "${baiyue.firstleg.outbox-relay.fixed-delay-ms:5000}") public void relay() { - LocalDateTime now = LocalDateTime.now(); - LocalDateTime expiredLeaseBefore = now.minusSeconds(Math.max(leaseSeconds, 10)); - for (LastlegEventOutbox candidate : repository.pending(50, now, expiredLeaseBefore)) { - if (!repository.claim(candidate.getId(), workerId, now, expiredLeaseBefore)) continue; - LastlegEventOutbox row = repository.claimed(candidate.getId(), workerId); - if (row == null) continue; - try { - EventEnvelope event = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class); - EventEnvelopeValidator.requireValid(event); - var response = client.receive(internalToken, event); - requireAcknowledgement(event, response); - repository.published(row.getId(), workerId, LocalDateTime.now()); - } catch (Exception exception) { - repository.retry(row.getId(), workerId, LocalDateTime.now().plusSeconds(Math.max(retrySeconds, 1)), - exception.getMessage()); - log.warn("海外到国内事件投递失败: eventKey={}, reason={}", row.getEventKey(), exception.getMessage()); + if (leaseSeconds < 1) throw new IllegalArgumentException("Lease duration must be positive"); + var policy = new BoundedRetryPolicy(maxAttempts, baseDelaySeconds, maxDelaySeconds, retryJitter); + LocalDateTime scan = LocalDateTime.now(); + for (LastlegEventOutbox candidate : repository.pending(50, scan, scan.minusSeconds(leaseSeconds))) { + LocalDateTime started = LocalDateTime.now(); + if ("PROCESSING".equals(candidate.getStatus())) { + int failures = (candidate.getRetryCount() == null ? 0 : candidate.getRetryCount()) + 1; + LocalDateTime next = policy.exhausted(failures) ? null : started.plus(policy.delayAfterFailure(failures)); + journal.complete(candidate.getLockedBy(), Outcome.LEASE_EXPIRED, started, "LEASE_EXPIRED", next, + () -> repository.expire(candidate.getId(), candidate.getLockedBy(), started.minusSeconds(leaseSeconds), + failures, next, "投递租约超时,消费结果未知")); + continue; // Recovery always waits for the new backoff window; never sends in this pass. } + String worker = UUID.randomUUID().toString(); + String type = Stream.LASTLEG.type(candidate.getEventType()); + if (!journal.begin(worker, Stream.LASTLEG.service, type, String.valueOf(candidate.getId()), + EventDeliveryObservation.eventId(objectMapper, candidate.getPayloadJson()), Stream.LASTLEG.target(type), started, + () -> repository.claim(candidate.getId(), worker, started))) continue; + LastlegEventOutbox row = repository.claimed(candidate.getId(), worker); + if (row == null) continue; + int failures = row.getRetryCount() == null ? 0 : row.getRetryCount(); + if (policy.exhausted(failures)) { + journal.complete(worker, Outcome.DEAD, LocalDateTime.now(), "RETRY_EXHAUSTED", null, + () -> repository.dead(row.getId(), worker, failures, "自动投递次数已耗尽(含租约超时)")); + continue; + } + Outcome outcome; + String diagnostic = null; + String auditError = null; + boolean permanent = false; + boolean requestStarted = false; + try { + EventEnvelope event = objectMapper.readValue(row.getPayloadJson(), EventEnvelope.class).requireValid(); + requestStarted = true; + var response = client.receive(internalToken, event); + EventDeliveryFailure.requireSuccessful(response); + response.data().requireMatches(event); + outcome = Outcome.valueOf(response.data().outcome().name()); + } catch (Exception exception) { + permanent = EventDeliveryFailure.isPermanent(exception) + || (!requestStarted && (exception instanceof JsonProcessingException || exception instanceof IllegalArgumentException)); + diagnostic = EventDeliveryFailure.diagnostic(exception, internalToken); + auditError = EventDeliveryObservation.errorCode(exception, permanent, requestStarted); + outcome = permanent || policy.exhausted(failures + 1) ? Outcome.DEAD : Outcome.RETRY; + } + // Persistence failures propagate. Do not reinterpret a failed commit as an HTTP failure. + LocalDateTime finished = LocalDateTime.now(); + LocalDateTime next = outcome == Outcome.RETRY ? finished.plus(policy.delayAfterFailure(failures + 1)) : null; + Outcome result = outcome; + String error = diagnostic; + int retries = failures + 1; + journal.complete(worker, result, finished, auditError, next, () -> { + if (result == Outcome.DEAD) return repository.dead(row.getId(), worker, retries, error); + if (result == Outcome.RETRY) return repository.retry(row.getId(), worker, next, error); + return repository.published(row.getId(), worker, finished); + }); } } - private void requireAcknowledgement(EventEnvelope event, - com.baiyue.common.core.api.ApiResponse response) { - if (response == null) throw new IllegalStateException("国内事件桥返回空响应"); - if (!response.successful()) { - throw new IllegalStateException("国内事件桥拒绝海外事件: code=" + response.code() - + ", message=" + response.message() + ", requestId=" + response.requestId()); - } - if (response.data() == null) throw new IllegalStateException("国内事件桥成功响应缺少确认数据"); - if (!event.eventId().equals(response.data().eventId())) { - throw new IllegalStateException("国内事件桥确认eventId不一致: expected=" + event.eventId() - + ", actual=" + response.data().eventId()); - } - } + @Scheduled(fixedDelayString = "${baiyue.event-delivery.metrics-sample-ms:30000}") + public void sampleMetrics() { metrics.sample(Stream.LASTLEG, LocalDateTime.now()); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java index 60de90bae..db7c5f342 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java @@ -7,7 +7,6 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.OverseasContainerUnloadedV1; -import com.baiyue.common.event.RegionCode; import com.baiyue.lastleg.entity.LastlegEventOutbox; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.inboundplan.entity.InboundPlan; @@ -78,7 +77,7 @@ public class InboundUnloadCompletedOutboxService { JsonNode payloadNode = objectMapper.valueToTree(payload); String traceId = EventIdentifiers.newUuid(); EventEnvelope envelope = new EventEnvelope(EventIdentifiers.newUuid(), EventTypes.OVERSEAS_CONTAINER_UNLOADED, - 1, Instant.now(), RegionCode.OVERSEAS, RegionCode.CN, "0", "CONTAINER_BOOKING", + 1, Instant.now(), "0", "CONTAINER_BOOKING", sourceContainerBookingId, Math.max(1, aggregateVersion), 1L, traceId, eventKey, null, payloadNode, EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payloadNode))).requireValid(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index 63ed8f26a..b4998a5e3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -716,11 +716,14 @@ public class ReceivingContainerService { && matchedPlans.stream().allMatch(plan -> List.of("COMPLETED", "PENDING_RECONCILIATION") .contains(plan.getStatus())); boolean shippingReady = shippingGates.receivingAvailable(sourceContainerBookingId, containerNo) == 1; - boolean operable = allPlansReady && shippingReady && warehouseSnapshotConsistent; + boolean resourcesConfirmed = matchedPlans.stream().noneMatch(plan -> + plans.countUnconfirmedResourcePlans(plan.getCustomerId(), plan.getId()) > 0); + boolean operable = allPlansReady && resourcesConfirmed && shippingReady && warehouseSnapshotConsistent; int pending = Math.max(0, totalOrders - plannedOrders); String blockReason = operable ? null : (!warehouseSnapshotConsistent ? "柜内接柜仓业务快照不一致" : !allPlansReady ? plannedOrders + "个入仓码已生成计划," + pending + "个订单未操作/缺入仓码" + : !resourcesConfirmed ? "入库计划资源尚未确认,请先在平台恢复原柜级转换命令" : "柜号尚未达到海外收货可操作节点"); LocalDateTime arrival = orderFacts.stream().map(LastlegOrder::getEstimatedWarehouseAt) .filter(Objects::nonNull).min(LocalDateTime::compareTo).orElse(null); @@ -958,6 +961,9 @@ public class ReceivingContainerService { private void requireMaterializedPlans(List containerPlans) { for (InboundPlan plan : containerPlans) { + if (plans.countUnconfirmedResourcePlans(plan.getCustomerId(), plan.getId()) > 0) { + throw conflict("入库计划资源尚未确认,请先在平台恢复原柜级转换命令"); + } int expected = plan.getPalletCount() == null ? 0 : plan.getPalletCount(); long materialized = pallets.selectCount(new LambdaQueryWrapper() .eq(InboundPlanPallet::getCustomerId, plan.getCustomerId()) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java index 45f2f0447..13e3782bc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java @@ -47,6 +47,20 @@ public class ContainerInboundPlanConversionCoordinator { @Resource private ObjectMapper objectMapper; @Resource private PlatformTransactionManager transactionManager; + @org.springframework.transaction.annotation.Transactional(readOnly = true) + public ContainerInboundPlanPreviewResponse preview(String sourceContainerBookingId) { + ContainerInboundPlanPreviewResponse current = plans.previewContainer(sourceContainerBookingId); + ContainerPlanConversionCommand command = commands.findByBooking(current.sourceContainerBookingId()); + if (command == null || !"LOCAL_COMMITTED".equals(command.getStatus())) return current; + if (trimToNull(command.getIdempotencyKey()) == null || command.getSnapshotVersion() == null + || command.getSnapshotVersion() < 0 || command.getSnapshotHash() == null + || !command.getSnapshotHash().matches("[a-f0-9]{64}")) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "原柜级转换命令缺少重试依据,请联系管理员核查"); + } + return current.withResourceRetry(new ContainerInboundPlanPreviewResponse.ResourceRetry( + command.getIdempotencyKey(), command.getSnapshotVersion(), command.getSnapshotHash())); + } + /** * Saves one manually adjusted plan, then materializes every plan in the container as soon as * the last logical plan is complete. The manual endpoint is therefore never a separate pallet @@ -67,9 +81,11 @@ public class ContainerInboundPlanConversionCoordinator { .anyMatch(order -> "RESOURCE_PENDING".equals(order.readiness())); if (!preview.ready() || !allLogicalPlansComplete || !needsMaterialization) return manual; + var retry = preview(bookingId).resourceRetry(); List materialized = confirm(new ConvertContainerInboundPlanRequest( - bookingId, preview.snapshotVersion(), preview.snapshotHash()), - "AUTO-MANUAL:" + hash(List.of(bookingId, idempotencyKey))); + bookingId, retry == null ? preview.snapshotVersion() : retry.snapshotVersion(), + retry == null ? preview.snapshotHash() : retry.snapshotHash()), + retry == null ? "AUTO-MANUAL:" + hash(List.of(bookingId, idempotencyKey)) : retry.idempotencyKey()); return materialized.stream() .filter(plan -> Objects.equals(plan.customerId(), manual.customerId()) && Objects.equals(plan.sourceOrderId(), manual.sourceOrderId())) @@ -90,6 +106,9 @@ public class ContainerInboundPlanConversionCoordinator { } else if ("FAILED".equals(resolved.getStatus())) { resolved = replaceFailedCommand(resolved, bookingId, key, request, requestHash); } else { + if ("LOCAL_COMMITTED".equals(resolved.getStatus()) && !key.equals(resolved.getIdempotencyKey())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "资源恢复必须使用原柜级转换命令"); + } requireSameRequest(resolved, requestHash); } if ("COMPLETED".equals(resolved.getStatus())) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index 61700639a..0713ee552 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -270,7 +270,7 @@ public class InboundPlanService { orders.stream().map(ContainerInboundPlanPreviewResponse.Order::suggestedPalletCount) .filter(Objects::nonNull).mapToInt(Integer::intValue).sum(), orders.stream().map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount) - .filter(Objects::nonNull).mapToInt(Integer::intValue).sum(), blocked == 0, groups); + .filter(Objects::nonNull).mapToInt(Integer::intValue).sum(), blocked == 0, groups, null); } private ContainerInboundPlanPreviewResponse.Order previewOrder( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/ShipmentSource.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/ShipmentSource.java new file mode 100644 index 000000000..4409283a8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/ShipmentSource.java @@ -0,0 +1,4 @@ +package com.baiyue.lastleg.inventory.command.dto; + +/** SKU and shipped quantity derived only from the selected immutable outbound shipment facts. */ +public record ShipmentSource(String skuCode, long shippedPackages) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java index f5b7c5668..ffe56f392 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java @@ -1,6 +1,7 @@ package com.baiyue.lastleg.inventory.command.mapper; import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.command.dto.ShipmentSource; import com.baiyue.lastleg.inventory.entity.InventoryPosition; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Delete; @@ -11,6 +12,15 @@ import java.util.List; @Mapper public interface InventoryCommandMapper { + @Select("SELECT id FROM ll_warehouse_transfer_order WHERE customer_id=#{customerId} AND relabel_application_id=#{applicationId} FOR UPDATE") + Long relabelTransfer(@Param("customerId") String customerId, @Param("applicationId") Long applicationId); + + record TransferReservation(Long transferOrderId, long packageCount) {} + + @Select("SELECT transfer_order_id,package_count FROM ll_warehouse_transfer_inventory_allocation " + + "WHERE customer_id=#{customerId} AND inventory_position_id=#{positionId} AND status='RESERVED' FOR UPDATE") + TransferReservation transferReservation(@Param("customerId") String customerId, @Param("positionId") Long positionId); + @Select("SELECT * FROM ll_inventory_position WHERE id=#{positionId} LIMIT 1") InventoryPosition findPosition(@Param("positionId") Long positionId); @@ -47,11 +57,26 @@ public interface InventoryCommandMapper { "AND NOT EXISTS (SELECT 1 FROM ll_inventory_position p WHERE p.balance_id=ll_inventory_balance.id)") int deleteEmptyBalance(@Param("customerId") String customerId, @Param("balanceId") Long balanceId); - @Select("SELECT COALESCE(-SUM(delta_packages),0) FROM ll_inventory_ledger " + - "WHERE customer_id=#{customerId} AND position_id=#{positionId} AND outbound_order_no=#{outboundNo} " + - "AND sku_code=#{skuCode} AND business_type='SHIP' AND delta_packages<0") - long shippedPackages(@Param("customerId") String customerId, @Param("positionId") Long positionId, - @Param("outboundNo") String outboundNo, @Param("skuCode") String skuCode); + @Select("SELECT l.sku_code AS skuCode, -SUM(l.delta_packages) AS shippedPackages FROM ll_inventory_ledger l " + + "JOIN ll_outbound_order o ON o.customer_id=l.customer_id AND o.outbound_no=#{outboundNo} " + + "AND o.overseas_warehouse_id=l.overseas_warehouse_id AND o.status='OUTBOUNDED' " + + "WHERE l.customer_id=#{customerId} AND l.position_id=#{positionId} " + + "AND l.business_type='SHIP' AND l.delta_packages<0 " + + "AND ((o.outbound_type='DISPATCH_DELIVERY' AND l.business_order_no=o.dispatch_no " + + "AND l.outbound_order_no=o.dispatch_no AND EXISTS (" + + "SELECT 1 FROM ll_dispatch_pda_task t JOIN ll_dispatch_pda_pick p " + + "ON p.customer_id=t.customer_id AND p.task_id=t.id " + + "JOIN ll_inventory_operation op ON op.customer_id=p.customer_id " + + "AND op.operation_type='SHIP' AND op.status='COMPLETED' " + + "AND op.idempotency_key=CONCAT('DISPATCH:',o.dispatch_no,':',p.dispatch_item_id,':',p.idempotency_key,':SHIP') " + + "WHERE t.customer_id=o.customer_id AND t.dispatch_order_id=o.dispatch_order_id " + + "AND t.warehouse_id=o.overseas_warehouse_id AND p.inventory_position_id=l.position_id " + + "AND p.packages=-l.delta_packages AND l.idempotency_key=CONCAT(op.idempotency_key,':',op.id))) " + + "OR (o.outbound_type<>'DISPATCH_DELIVERY' AND l.outbound_order_no=o.outbound_no)) " + + "GROUP BY l.sku_code") + List shipmentSources(@Param("customerId") String customerId, @Param("positionId") Long positionId, + @Param("outboundNo") String outboundNo); + @Select("SELECT COALESCE(SUM(delta_packages),0) FROM ll_inventory_ledger " + "WHERE customer_id=#{customerId} AND source_event_id=#{sourceIdentity} " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java index 540f25419..d6c4cb571 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java @@ -36,11 +36,15 @@ import java.util.Objects; @Service public class InventoryCommandService { + private record ReservedTransferMove(InventoryCommandRequests.Move move, String sku, Long transferOrderId) {} + private record TransferMoveSnapshot(InventoryCommandRequests.Move move, String sku) {} + private record TransferAdjustmentSnapshot(InventoryCommandRequests.Adjustment adjustment, String sku) {} @Resource private InventoryBalanceMapper balances; @Resource private InventoryPositionMapper positions; @Resource private InventoryLedgerMapper ledgers; @Resource private InventoryOperationMapper operations; @Resource private InventoryCommandMapper commands; + @Resource private com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper returnReceipts; @Resource private WarehouseResourceGateway warehouseGateway; @Resource private ObjectMapper objectMapper; @@ -144,23 +148,25 @@ public class InventoryCommandService { @Transactional public void holdInboundOrderInventory(String customerId, String operatorId, long inboundOrderId, - String businessOrderNo, String remark) { + String businessOrderNo, String remark, String holdCycle) { for (InventoryPosition position : commands.lockInboundAvailablePositions(customerId, inboundOrderId)) { long packages = value(position.getAvailablePackages()); if (packages == 0) continue; hold(customerId, operatorId, new InventoryCommandRequests.Move(position.getId(), packages, - businessOrderNo, null, null, "ORDER_HOLD:" + inboundOrderId + ":" + position.getId(), remark)); + businessOrderNo, null, holdCycle, holdCycle + ":" + position.getId(), remark)); } } @Transactional public void releaseInboundOrderHold(String customerId, String operatorId, long inboundOrderId, - String businessOrderNo, String remark) { - for (InventoryPosition position : commands.lockInboundHeldPositions(customerId, inboundOrderId)) { - long packages = value(position.getHoldPackages()); - if (packages == 0) continue; - release(customerId, operatorId, new InventoryCommandRequests.Move(position.getId(), packages, - businessOrderNo, null, null, "ORDER_HOLD_RELEASE:" + inboundOrderId + ":" + position.getId(), remark)); + String businessOrderNo, String remark, String holdCycle) { + // HOLD is shared by several business flows. Only release facts owned by this order hold cycle. + for (InventoryLedger held : ledgers.orderHoldCycle(customerId, inboundOrderId, holdCycle)) { + if (held.getPositionId() == null || value(held.getDeltaPackages()) <= 0) { + throw conflict("Order hold inventory fact is invalid"); + } + release(customerId, operatorId, new InventoryCommandRequests.Move(held.getPositionId(), held.getDeltaPackages(), + businessOrderNo, null, holdCycle, holdCycle + ":RELEASE:" + held.getPositionId(), remark)); } } @@ -234,8 +240,72 @@ public class InventoryCommandService { @Transactional public InventoryCommandResponse ship(String customerId, String operatorId, InventoryCommandRequests.Move request) { - return execute(customerId, operatorId, "SHIP", request.idempotencyKey(), request, operation -> { + return ship(customerId, operatorId, request, null); + } + + @Transactional + public InventoryCommandResponse holdWarehouseTransfer(String customerId, String operatorId, + Long transferOrderId, InventoryCommandRequests.Move request) { + return move(customerId, operatorId, "HOLD", Bucket.AVAILABLE, Bucket.HOLD, request, null, + java.util.Objects.requireNonNull(transferOrderId)); + } + + @Transactional + public InventoryCommandResponse releaseWarehouseTransfer(String customerId, String operatorId, + Long transferOrderId, InventoryCommandRequests.Move request) { + return move(customerId, operatorId, "RELEASE", Bucket.HOLD, Bucket.AVAILABLE, request, null, + java.util.Objects.requireNonNull(transferOrderId)); + } + + /** Internal transfer service entry points; no Controller binds this owner argument. */ + @Transactional + public InventoryCommandResponse allocateWarehouseTransfer(String customerId, String operatorId, + Long transferOrderId, InventoryCommandRequests.Move request, String skuSnapshot) { + if (skuSnapshot != null) requireTransferSku(request.idempotencyKey(), skuSnapshot); + return move(customerId, operatorId, "ALLOCATE", Bucket.AVAILABLE, Bucket.ALLOCATED, + request, skuSnapshot, java.util.Objects.requireNonNull(transferOrderId)); + } + + @Transactional + public InventoryCommandResponse pickWarehouseTransfer(String customerId, String operatorId, + Long transferOrderId, InventoryCommandRequests.Move request, String skuSnapshot) { + if (skuSnapshot != null) requireTransferSku(request.idempotencyKey(), skuSnapshot); + return move(customerId, operatorId, "PICK", Bucket.ALLOCATED, Bucket.PICKED, + request, skuSnapshot, java.util.Objects.requireNonNull(transferOrderId)); + } + + @Transactional + public InventoryCommandResponse shipWarehouseTransfer(String customerId, String operatorId, + Long transferOrderId, InventoryCommandRequests.Move request, String skuSnapshot) { + if (skuSnapshot != null) requireTransferSku(request.idempotencyKey(), skuSnapshot); + return ship(customerId, operatorId, request, skuSnapshot, java.util.Objects.requireNonNull(transferOrderId)); + } + + private void requireReservation(String customerId, Long positionId, Long owner, long packages, String operationType) { + var reservation = commands.transferReservation(customerId, positionId); + if (reservation == null) { + if (owner != null && !"RELEASE".equals(operationType) && !"HOLD".equals(operationType)) throw conflict("Warehouse transfer reservation is missing"); + return; + } + boolean partial = "RELEASE".equals(operationType) || "HOLD".equals(operationType); + boolean quantityMatches = partial ? packages <= reservation.packageCount() : packages == reservation.packageCount(); + if (owner == null || !owner.equals(reservation.transferOrderId()) || !quantityMatches) { + throw conflict("Inventory is reserved by a warehouse transfer"); + } + } + + private InventoryCommandResponse ship(String customerId, String operatorId, + InventoryCommandRequests.Move request, String skuSnapshot) { + return ship(customerId, operatorId, request, skuSnapshot, null); + } + + private InventoryCommandResponse ship(String customerId, String operatorId, + InventoryCommandRequests.Move request, String skuSnapshot, Long reservationOwner) { + return execute(customerId, operatorId, "SHIP", request.idempotencyKey(), + reservationOwner != null ? new ReservedTransferMove(request, skuSnapshot, reservationOwner) + : skuSnapshot == null ? request : new TransferMoveSnapshot(request, skuSnapshot), operation -> { Locked locked = locked(customerId, request.positionId()); + requireReservation(customerId, request.positionId(), reservationOwner, request.packages(), "SHIP"); InventoryPosition position = locked.position(); InventoryBalance balance = locked.balance(); requireAtLeast(value(position.getPickedPackages()), request.packages(), "picked"); @@ -252,7 +322,8 @@ public class InventoryCommandService { balances.updateById(balance); boolean palletEmpty = commands.palletPackages(position.getPalletId()) == 0; if (palletEmpty) warehouseGateway.scrapPallet(customerId, position.getPalletId()); - ledger(customerId, operatorId, operation.getId(), balance, position, balance.getSkuCode(), "SHIP", + ledger(customerId, operatorId, operation.getId(), balance, position, + skuSnapshot == null ? balance.getSkuCode() : skuSnapshot, "SHIP", "PICKED", "SHIPPED", before, -request.packages(), before - request.packages(), request.businessOrderNo(), null, request.outboundOrderNo(), request.sourceEventId(), request.idempotencyKey(), request.remark()); @@ -294,7 +365,7 @@ public class InventoryCommandService { || source.maxReturnPackages() <= 0) { throw badRequest("A valid returned inventory source is required"); } - String sourceSku = required(source.skuCode(), "Returned inventory source SKU is required"); + String sourceSku = trim(source.skuCode()); ReturnedInventoryRestoreRequest.SourceItem normalized = new ReturnedInventoryRestoreRequest.SourceItem( source.outboundPalletItemId(), source.inventoryPositionId(), sourceSku, source.maxReturnPackages()); if (sources.putIfAbsent(source.outboundPalletItemId(), normalized) != null) { @@ -333,6 +404,17 @@ public class InventoryCommandService { InventoryOperation existing = operations.findIdempotency(customerId, normalizedKey); if (existing != null) return replayReturned(existing, requestHash); + Map returnOwners = new LinkedHashMap<>(); + for (var source : sources.values()) { + var owner = returnReceipts.owner(customerId, exceptionNo, outboundNo, source.outboundPalletItemId(), source.inventoryPositionId()); + if (owner == null || owner.orderId() == null || !Objects.equals(owner.inboundOrderId(), request.inboundOrderId())) + throw conflict("Returned stock receipt is missing the original order attribution"); + returnOwners.put(source.outboundPalletItemId(), owner); + } + for (Long orderId : returnOwners.values().stream().map(com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper.ReturnOwner::orderId).distinct().sorted().toList()) { + if (returnReceipts.lockOrder(customerId, orderId) == null) throw conflict("Returned stock source order is missing or held"); + } + InventoryOperation operation = new InventoryOperation(); operation.setId(IdWorker.getId()); operation.setCustomerId(customerId); @@ -351,6 +433,7 @@ public class InventoryCommandService { } Map lockedSources = new LinkedHashMap<>(); + Map resolvedSources = new LinkedHashMap<>(); Map balanceBefore = new LinkedHashMap<>(); for (ReturnedInventoryRestoreRequest.SourceItem source : sources.values()) { Locked locked = locked(customerId, source.inventoryPositionId()); @@ -361,22 +444,36 @@ public class InventoryCommandService { if (!warehouseId.equals(trim(balance.getOverseasWarehouseId()))) { throw conflict("Returned inventory source warehouse does not match"); } - long shipped = commands.shippedPackages(customerId, source.inventoryPositionId(), outboundNo, - source.skuCode()); - long restored = commands.restoredPackages(customerId, returnSourceIdentity(source), source.skuCode()); + var shipmentSources = commands.shipmentSources(customerId, source.inventoryPositionId(), outboundNo); + if (shipmentSources == null || shipmentSources.isEmpty()) { + throw conflict("Immutable outbound shipment source was not found"); + } + var matchingSources = source.skuCode() == null ? shipmentSources : shipmentSources.stream() + .filter(shipment -> source.skuCode().equals(shipment.skuCode())).toList(); + if (matchingSources.size() != 1) { + throw conflict(source.skuCode() == null ? "Immutable outbound shipment SKU source is ambiguous" + : "Declared return SKU does not match an immutable shipment source"); + } + var shipment = matchingSources.get(0); + String actualSku = required(shipment.skuCode(), "Immutable outbound shipment SKU is missing"); + var resolved = new ReturnedInventoryRestoreRequest.SourceItem(source.outboundPalletItemId(), + source.inventoryPositionId(), actualSku, source.maxReturnPackages()); + long shipped = shipment.shippedPackages(); + long restored = commands.restoredPackages(customerId, returnSourceIdentity(resolved), actualSku); long requested = requestedBySource.get(source.outboundPalletItemId()); if (shipped <= 0) throw conflict("Immutable outbound shipment source was not found"); if (restored + requested > shipped) { throw conflict("Returned packages exceed the remaining shipped packages"); } lockedSources.put(source.outboundPalletItemId(), locked); + resolvedSources.put(source.outboundPalletItemId(), resolved); balanceBefore.putIfAbsent(balance.getId(), value(balance.getAvailablePackages())); } long restoredPackages = 0; List ledgerIds = new java.util.ArrayList<>(); for (ReturnedInventoryRestoreRequest.Receipt receipt : request.receipts()) { - ReturnedInventoryRestoreRequest.SourceItem source = sources.get(receipt.outboundPalletItemId()); + ReturnedInventoryRestoreRequest.SourceItem source = resolvedSources.get(receipt.outboundPalletItemId()); Locked sourceLocked = lockedSources.get(receipt.outboundPalletItemId()); InventoryBalance balance = sourceLocked.balance(); WarehouseResourceGateway.WarehousePalletReference pallet = warehouseGateway.resolvePallet( @@ -413,6 +510,16 @@ public class InventoryCommandService { normalizedKey + ":" + receipt.outboundPalletItemId() + ":" + target.getId(), "Exception return received with evidence " + request.evidenceFileIds().size()); ledgerIds.add(ledger.getId()); + var owner = returnOwners.get(source.outboundPalletItemId()); + var stockReceipt = new com.baiyue.lastleg.inventory.entity.ReturnStockReceipt(); + stockReceipt.setId(IdWorker.getId()); stockReceipt.setCustomerId(customerId); + stockReceipt.setReceiptNo("RSR" + stockReceipt.getId()); stockReceipt.setInventoryLedgerId(ledger.getId()); + stockReceipt.setExceptionOrderId(owner.exceptionOrderId()); stockReceipt.setOrderId(owner.orderId()); + stockReceipt.setInboundOrderId(owner.inboundOrderId()); stockReceipt.setSourceOutboundNo(outboundNo); + stockReceipt.setSourceOutboundPalletItemId(source.outboundPalletItemId()); stockReceipt.setSourceInventoryPositionId(source.inventoryPositionId()); + stockReceipt.setInventoryBalanceId(balance.getId()); stockReceipt.setInventoryPositionId(target.getId()); + stockReceipt.setReceivedPackages(receipt.receivedPackages()); stockReceipt.setReceivedAt(ledger.getOccurredAt()); + stockReceipt.setCreatedAt(ledger.getOccurredAt()); returnReceipts.insert(stockReceipt); restoredPackages += receipt.receivedPackages(); } long beforePackages = balanceBefore.values().stream().mapToLong(Long::longValue).sum(); @@ -432,10 +539,23 @@ public class InventoryCommandService { @Transactional public InventoryCommandResponse adjust(String customerId, String operatorId, InventoryCommandRequests.Adjustment request) { + return adjust(customerId, operatorId, request, null); + } + + @Transactional + public InventoryCommandResponse adjustTransferRelabel(String customerId, String operatorId, + InventoryCommandRequests.Adjustment request, String skuSnapshot) { + requireTransferSku(request.idempotencyKey(), skuSnapshot); + return adjust(customerId, operatorId, request, skuSnapshot); + } + + private InventoryCommandResponse adjust(String customerId, String operatorId, + InventoryCommandRequests.Adjustment request, String skuSnapshot) { if (request.packageDelta() == 0) { throw badRequest("Adjustment package delta cannot be zero"); } - return execute(customerId, operatorId, "ADJUST", request.idempotencyKey(), request, operation -> { + return execute(customerId, operatorId, "ADJUST", request.idempotencyKey(), + skuSnapshot == null ? request : new TransferAdjustmentSnapshot(request, skuSnapshot), operation -> { Locked locked = locked(customerId, request.positionId()); InventoryPosition position = locked.position(); InventoryBalance balance = locked.balance(); @@ -453,7 +573,8 @@ public class InventoryCommandService { balance.setAvailablePackages(balanceAfter); touch(balance); balances.updateById(balance); - ledger(customerId, operatorId, operation.getId(), balance, position, balance.getSkuCode(), "ADJUST", + ledger(customerId, operatorId, operation.getId(), balance, position, + skuSnapshot == null ? balance.getSkuCode() : skuSnapshot, "ADJUST", "AVAILABLE", "AVAILABLE", before, request.packageDelta(), after, request.businessOrderNo(), null, null, null, request.idempotencyKey(), request.reason() + remarkSuffix(request.remark())); @@ -468,8 +589,34 @@ public class InventoryCommandService { private InventoryCommandResponse move(String customerId, String operatorId, String operationType, Bucket from, Bucket to, InventoryCommandRequests.Move request) { - return execute(customerId, operatorId, operationType, request.idempotencyKey(), request, operation -> { + return move(customerId, operatorId, operationType, from, to, request, null); + } + + @Transactional + public InventoryCommandResponse releaseTransferRelabel(String customerId, String operatorId, Long transferOrderId, + InventoryCommandRequests.Move request, String skuSnapshot) { + requireTransferSku(request.idempotencyKey(), skuSnapshot); + return move(customerId, operatorId, "RELEASE", Bucket.HOLD, Bucket.AVAILABLE, request, skuSnapshot, + java.util.Objects.requireNonNull(transferOrderId)); + } + + private static void requireTransferSku(String key, String skuSnapshot) { + if (key == null || !key.startsWith("WAREHOUSE_TRANSFER:") || skuSnapshot == null || skuSnapshot.isBlank() + || "MIXED".equals(skuSnapshot)) throw badRequest("Exact warehouse transfer SKU snapshot is required"); + } + + private InventoryCommandResponse move(String customerId, String operatorId, String operationType, + Bucket from, Bucket to, InventoryCommandRequests.Move request, String skuSnapshot) { + return move(customerId, operatorId, operationType, from, to, request, skuSnapshot, null); + } + + private InventoryCommandResponse move(String customerId, String operatorId, String operationType, + Bucket from, Bucket to, InventoryCommandRequests.Move request, String skuSnapshot, Long reservationOwner) { + return execute(customerId, operatorId, operationType, request.idempotencyKey(), + reservationOwner != null ? new ReservedTransferMove(request, skuSnapshot, reservationOwner) + : skuSnapshot == null ? request : new TransferMoveSnapshot(request, skuSnapshot), operation -> { Locked locked = locked(customerId, request.positionId()); + requireReservation(customerId, request.positionId(), reservationOwner, request.packages(), operationType); InventoryPosition position = locked.position(); InventoryBalance balance = locked.balance(); requireAtLeast(packages(position, from), request.packages(), from.name().toLowerCase()); @@ -485,7 +632,8 @@ public class InventoryCommandService { packages(balance, to, packages(balance, to) + request.packages()); touch(balance); balances.updateById(balance); - ledger(customerId, operatorId, operation.getId(), balance, position, balance.getSkuCode(), operationType, + ledger(customerId, operatorId, operation.getId(), balance, position, + skuSnapshot == null ? balance.getSkuCode() : skuSnapshot, operationType, from.name(), to.name(), beforeTarget, request.packages(), beforeTarget + request.packages(), request.businessOrderNo(), null, request.outboundOrderNo(), request.sourceEventId(), request.idempotencyKey(), request.remark()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/ReturnStockReceipt.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/ReturnStockReceipt.java new file mode 100644 index 000000000..12f0f87f8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/ReturnStockReceipt.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.inventory.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +/** An immutable receipt from a real return ledger; it is not another customer order or initial inbound receipt. */ +@Data +@TableName("ll_return_stock_receipt") +public class ReturnStockReceipt { + @TableId private Long id; + private String customerId; + private String receiptNo; + private Long inventoryLedgerId; + private Long exceptionOrderId; + private Long orderId; + private Long inboundOrderId; + private String sourceOutboundNo; + private Long sourceOutboundPalletItemId; + private Long sourceInventoryPositionId; + private Long inventoryBalanceId; + private Long inventoryPositionId; + private Long receivedPackages; + private LocalDateTime receivedAt; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryLedgerMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryLedgerMapper.java index cb39de2de..82286989e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryLedgerMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryLedgerMapper.java @@ -12,6 +12,11 @@ import java.util.List; @Mapper public interface InventoryLedgerMapper extends BaseMapper { + @Select("SELECT * FROM ll_inventory_ledger WHERE customer_id=#{customerId} AND inbound_order_id=#{inboundOrderId} " + + "AND business_type='HOLD' AND source_event_id=#{holdCycle} ORDER BY position_id,id") + List orderHoldCycle(@Param("customerId") String customerId, + @Param("inboundOrderId") long inboundOrderId, @Param("holdCycle") String holdCycle); + @Select("SELECT * FROM ll_inventory_ledger WHERE customer_id=#{customerId} AND idempotency_key=#{idempotencyKey} LIMIT 1") InventoryLedger findIdempotency(@Param("customerId") String customerId, @Param("idempotencyKey") String idempotencyKey); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/ReturnStockReceiptMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/ReturnStockReceiptMapper.java new file mode 100644 index 000000000..deb20a909 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/ReturnStockReceiptMapper.java @@ -0,0 +1,65 @@ +package com.baiyue.lastleg.inventory.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.inventory.entity.ReturnStockReceipt; +import org.apache.ibatis.annotations.*; +import java.util.List; +import java.time.LocalDateTime; + +@Mapper +public interface ReturnStockReceiptMapper extends BaseMapper { + @Select(""" + SELECT r.id AS receiptId,r.receipt_no AS receiptNo,r.received_packages AS receivedPackages, + r.received_at AS receivedAt,di.id AS dispatchItemId,d.dispatch_no AS dispatchNo, + d.status AS dispatchStatus,di.package_count AS selectedPackages, + COALESCE((SELECT SUM(a.packages) FROM ll_dispatch_pda_pick_order a + JOIN ll_dispatch_pda_pick p ON p.id=a.pick_id AND BINARY p.customer_id=BINARY a.customer_id + WHERE BINARY a.customer_id=BINARY r.customer_id AND a.return_receipt_id=r.id + AND p.dispatch_item_id=di.id),0) AS pickedPackages, + delivery.delivery_no AS deliveryNo,delivery.status AS deliveryStatus + FROM ll_return_stock_receipt r + LEFT JOIN ll_dispatch_item di ON di.return_receipt_id=r.id AND BINARY di.customer_id=BINARY r.customer_id + LEFT JOIN ll_dispatch_order d ON d.id=di.dispatch_order_id AND BINARY d.customer_id=BINARY r.customer_id + LEFT JOIN ll_delivery_order delivery ON delivery.dispatch_order_id=d.id + AND BINARY delivery.customer_id=BINARY r.customer_id + AND BINARY delivery.delivery_group_key=BINARY CASE + WHEN di.destination_type='PRIVATE' AND NULLIF(TRIM(di.delivery_address),'') IS NOT NULL + THEN CONCAT('PRIVATE:',TRIM(di.delivery_address),':',TRIM(COALESCE(di.postal_code,''))) + WHEN COALESCE(NULLIF(TRIM(di.destination_warehouse_code),''),NULLIF(TRIM(di.fba_warehouse_code),'')) IS NOT NULL + THEN CONCAT('CODE:',UPPER(COALESCE(NULLIF(TRIM(di.destination_warehouse_code),''),NULLIF(TRIM(di.fba_warehouse_code),'')))) + WHEN NULLIF(TRIM(di.destination_warehouse_id),'') IS NOT NULL THEN CONCAT('ID:',TRIM(di.destination_warehouse_id)) + ELSE NULL END + WHERE BINARY r.customer_id=BINARY #{customerId} AND r.exception_order_id=#{exceptionId} + ORDER BY r.received_at,r.id,d.id,di.id,delivery.id + """) + List history(@Param("customerId") String customerId, @Param("exceptionId") Long exceptionId); + + record ReturnHistoryRow(Long receiptId, String receiptNo, Long receivedPackages, LocalDateTime receivedAt, + Long dispatchItemId, String dispatchNo, String dispatchStatus, Integer selectedPackages, + Long pickedPackages, String deliveryNo, String deliveryStatus) {} + + @Select("SELECT id FROM ll_order WHERE customer_id=#{customerId} AND id=#{id} FOR UPDATE") + Long lockOrder(@Param("customerId") String customerId, @Param("id") Long id); + record ReturnOwner(Long exceptionOrderId, Long orderId, Long inboundOrderId) { } + @Select("SELECT e.id AS exception_order_id,e.last_mile_order_id AS order_id,e.inbound_order_id " + + "FROM ll_exception_order e JOIN ll_order o ON o.customer_id=e.customer_id AND o.id=e.last_mile_order_id " + + "JOIN ll_exception_return_item i ON i.customer_id=e.customer_id AND i.exception_order_id=e.id " + + "WHERE e.customer_id=#{customerId} AND e.exception_no=#{exceptionNo} AND e.outbound_no=#{outboundNo} " + + "AND e.exception_type='RETURN' AND e.return_direction='OVERSEAS_WAREHOUSE' AND e.status='PROCESSING' " + + "AND i.outbound_pallet_item_id=#{itemId} AND i.inventory_position_id=#{positionId}") + ReturnOwner owner(@Param("customerId") String customerId, @Param("exceptionNo") String exceptionNo, + @Param("outboundNo") String outboundNo, @Param("itemId") Long itemId, @Param("positionId") Long positionId); + + @Select("SELECT r.* FROM ll_return_stock_receipt r JOIN ll_inventory_position p ON p.id=#{positionId} " + + "AND p.customer_id=r.customer_id AND p.balance_id=r.inventory_balance_id " + + "WHERE r.customer_id=#{customerId} AND r.received_packages > (SELECT COALESCE(SUM(a.packages),0) " + + "FROM ll_dispatch_pda_pick_order a WHERE a.customer_id=r.customer_id AND a.return_receipt_id=r.id) " + + "ORDER BY r.received_at,r.id") + List available(@Param("customerId") String customerId, @Param("positionId") Long positionId); + + @Select("SELECT * FROM ll_return_stock_receipt WHERE customer_id=#{customerId} AND id=#{id} FOR UPDATE") + ReturnStockReceipt lock(@Param("customerId") String customerId, @Param("id") Long id); + + @Select("SELECT COALESCE(SUM(packages),0) FROM ll_dispatch_pda_pick_order WHERE customer_id=#{customerId} AND return_receipt_id=#{id}") + long picked(@Param("customerId") String customerId, @Param("id") Long id); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java index 26dd83ad7..ac6290793 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java @@ -71,8 +71,9 @@ public interface LastlegOrderMapper extends BaseMapper { "THEN 1 ELSE 0 END) AS pendingPlanCount," + "SUM(CASE WHEN p.id IS NOT NULL AND p.status IN ('PENDING_ASSIGNMENT','SIGNED') " + "AND COALESCE(p.pallet_count,0)>0 AND " + - "(SELECT COUNT(*) FROM ll_inbound_plan_pallet pp WHERE pp.customer_id=p.customer_id " + - "AND pp.plan_id=p.id AND pp.warehouse_pallet_id IS NOT NULL)<COALESCE(p.pallet_count,0) " + + "(" + com.baiyue.lastleg.inboundplan.mapper.ContainerPlanConversionCommandMapper.UNCONFIRMED_PLAN + + " OR (SELECT COUNT(*) FROM ll_inbound_plan_pallet pp WHERE pp.customer_id=p.customer_id " + + "AND pp.plan_id=p.id AND pp.warehouse_pallet_id IS NOT NULL)<COALESCE(p.pallet_count,0)) " + "THEN 1 ELSE 0 END) AS resourcePendingPlanCount," + "CASE WHEN COUNT(o.estimated_warehouse_at)=COUNT(*) " + "AND COUNT(DISTINCT o.estimated_warehouse_at)=1 THEN MAX(o.estimated_warehouse_at) ELSE NULL END AS plannedArrivalAt," + @@ -220,18 +221,24 @@ public interface LastlegOrderMapper extends BaseMapper { @Param("expectedVersion") int expectedVersion, @Param("updatedAt") java.time.LocalDateTime updatedAt); + String RETURNED_PACKAGES = "(SELECT COALESCE(SUM(r.received_packages),0) FROM ll_return_stock_receipt r " + + "WHERE r.customer_id=ll_order.customer_id AND r.order_id=ll_order.id)"; + + @Select("SELECT COALESCE(SUM(received_packages),0) FROM ll_return_stock_receipt WHERE customer_id=#{customerId} AND order_id=#{id}") + long returnedPackages(@Param("customerId") String customerId, @Param("id") Long id); + @Update("UPDATE ll_order SET picked_package_count=picked_package_count+#{packages},updated_at=NOW() " + "WHERE customer_id=#{customerId} AND id=#{id} AND detained=0 " + - "AND package_count-shipped_package_count-picked_package_count >= #{packages}") + "AND #{packages}>0 AND package_count+" + RETURNED_PACKAGES + "-shipped_package_count-picked_package_count >= #{packages}") int increasePicked(@Param("customerId") String customerId, @Param("id") Long id, @Param("packages") long packages); - @Update("UPDATE ll_order SET current_status=CASE WHEN shipped_package_count+#{packages}=package_count " + + @Update("UPDATE ll_order SET current_status=CASE WHEN shipped_package_count+#{packages}=package_count+" + RETURNED_PACKAGES + " " + "THEN 'COMPLETED' ELSE 'PARTIALLY_SHIPPED' END," + "picked_package_count=picked_package_count-#{packages}," + "shipped_package_count=shipped_package_count+#{packages},outbound_order_no=#{outboundNo},updated_at=NOW() " + "WHERE customer_id=#{customerId} AND id=#{id} AND detained=0 AND picked_package_count >= #{packages} " + - "AND shipped_package_count+#{packages} <= package_count") + "AND #{packages}>0 AND shipped_package_count+#{packages} <= package_count+" + RETURNED_PACKAGES) int transferPickedToShipped(@Param("customerId") String customerId, @Param("id") Long id, @Param("packages") long packages, @Param("outboundNo") String outboundNo); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehiclePersistenceAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehiclePersistenceAdapter.java index cb5252cf7..c889adaf6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehiclePersistenceAdapter.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehiclePersistenceAdapter.java @@ -48,6 +48,9 @@ public class VehiclePersistenceAdapter implements VehicleRepository { record.setId(IdWorker.getId()); record.setPlateNumber(vehicle.plateNumber()); record.setModel(vehicle.model()); + record.setMaxPalletSpaces(vehicle.maxPalletSpaces()); + record.setMaxLoadKg(vehicle.maxLoadKg()); + record.setMaxVolumeCbm(vehicle.maxVolumeCbm()); record.setFuelConsumptionPer100Km(vehicle.fuelConsumptionPer100Km()); record.setMileageKm(vehicle.mileageKm()); record.setDriverOrDispatchCompany(vehicle.driverOrDispatchCompany()); @@ -76,6 +79,9 @@ public class VehiclePersistenceAdapter implements VehicleRepository { .eq(VehicleRecord::getId, id).eq(VehicleRecord::getRecordVersion, expectedVersion) .set(VehicleRecord::getPlateNumber, changes.plateNumber()) .set(VehicleRecord::getModel, changes.model()) + .set(VehicleRecord::getMaxPalletSpaces, changes.maxPalletSpaces()) + .set(VehicleRecord::getMaxLoadKg, changes.maxLoadKg()) + .set(VehicleRecord::getMaxVolumeCbm, changes.maxVolumeCbm()) .set(VehicleRecord::getFuelConsumptionPer100Km, changes.fuelConsumptionPer100Km()) .set(VehicleRecord::getMileageKm, changes.mileageKm()) .set(VehicleRecord::getDriverOrDispatchCompany, changes.driverOrDispatchCompany()) @@ -112,7 +118,7 @@ public class VehiclePersistenceAdapter implements VehicleRepository { return new VehicleSnapshot(value.getId(), value.getPlateNumber(), value.getModel(), value.getFuelConsumptionPer100Km(), value.getMileageKm(), value.getDriverOrDispatchCompany(), value.getPhone(), value.getRegion(), value.getOwnership(), value.getStatus(), value.getRemark(), version(value.getRecordVersion()), - value.getUpdatedBy(), value.getUpdatedAt()); + value.getUpdatedBy(), value.getUpdatedAt(), value.getMaxPalletSpaces(), value.getMaxLoadKg(), value.getMaxVolumeCbm()); } private static long version(Long value) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleRecord.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleRecord.java index 9518e94e4..9ae5a923b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleRecord.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleRecord.java @@ -14,6 +14,9 @@ public class VehicleRecord { @TableId(type = IdType.INPUT) private Long id; private String plateNumber; private String model; + private Integer maxPalletSpaces; + private BigDecimal maxLoadKg; + private BigDecimal maxVolumeCbm; private BigDecimal fuelConsumptionPer100Km; private Long mileageKm; private String driverOrDispatchCompany; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java index 79fbb24d1..d350c34e4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java @@ -3,6 +3,7 @@ package com.baiyue.lastleg.masterdata.vehicle.application; import com.baiyue.common.core.api.PageResult; import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels; import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleAssignmentGuard; import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.DuplicatePlateException; import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.NewVehicle; import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.SearchCriteria; @@ -24,9 +25,11 @@ public class VehicleApplicationService { private static final Set STATUSES = Set.of("IN_SERVICE", "DISPATCHED", "MAINTENANCE", "INACTIVE", "RETURNED"); private final VehicleRepository vehicles; + private final VehicleAssignmentGuard assignments; - public VehicleApplicationService(VehicleRepository vehicles) { + public VehicleApplicationService(VehicleRepository vehicles, VehicleAssignmentGuard assignments) { this.vehicles = vehicles; + this.assignments = assignments; } public PageResult page(VehicleModels.Query query) { @@ -41,6 +44,7 @@ public class VehicleApplicationService { @Transactional public VehicleModels.Response create(String operator, VehicleModels.Create request) { + validateCapacity(request.maxPalletSpaces(), request.maxLoadKg(), request.maxVolumeCbm()); LocalDateTime now = LocalDateTime.now(); long id; try { @@ -48,7 +52,8 @@ public class VehicleApplicationService { requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT), requiredText(request.model(), "车型"), request.fuelConsumptionPer100Km(), request.mileageKm(), trim(request.driverOrDispatchCompany()), trim(request.phone()), region(request.region()), - ownership(request.ownership()), status(request.status()), trim(request.remark()), operator, now)); + ownership(request.ownership()), status(request.status()), trim(request.remark()), operator, now, + request.maxPalletSpaces(), request.maxLoadKg(), request.maxVolumeCbm())); } catch (DuplicatePlateException exception) { throw conflict("车牌号已存在"); } @@ -57,16 +62,25 @@ public class VehicleApplicationService { @Transactional public VehicleModels.Response update(long id, String operator, VehicleModels.Update request) { + validateCapacity(request.maxPalletSpaces(), request.maxLoadKg(), request.maxVolumeCbm()); VehicleSnapshot current = locked(id); requireVersion(current, request.recordVersion()); if (request.mileageKm() < current.mileageKm()) throw badRequest("当前公里码不能小于已有公里码"); + if (!current.plateNumber().equals(requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT)) + || !current.model().equals(requiredText(request.model(), "车型")) || !current.region().equals(region(request.region())) + || !current.ownership().equals(ownership(request.ownership())) + || !java.util.Objects.equals(current.maxPalletSpaces(), request.maxPalletSpaces()) + || !sameDecimal(current.maxLoadKg(), request.maxLoadKg()) || !sameDecimal(current.maxVolumeCbm(), request.maxVolumeCbm())) { + assignments.requireUnassigned(id); + } boolean updated; try { updated = vehicles.update(id, request.recordVersion(), new VehicleChanges( requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT), requiredText(request.model(), "车型"), request.fuelConsumptionPer100Km(), request.mileageKm(), trim(request.driverOrDispatchCompany()), trim(request.phone()), region(request.region()), - ownership(request.ownership()), trim(request.remark()), operator, LocalDateTime.now())); + ownership(request.ownership()), trim(request.remark()), operator, LocalDateTime.now(), + request.maxPalletSpaces(), request.maxLoadKg(), request.maxVolumeCbm())); } catch (DuplicatePlateException exception) { throw conflict("车牌号已存在"); } @@ -80,6 +94,7 @@ public class VehicleApplicationService { requireVersion(current, request.recordVersion()); String status = status(request.status()); if (status.equals(current.status())) return response(current); + assignments.requireUnassigned(id); boolean updated = vehicles.updateStatus(id, request.recordVersion(), status, operator, LocalDateTime.now()); if (!updated) throw conflict("车辆档案已被其他操作修改,请刷新后重试"); return detail(id); @@ -87,11 +102,12 @@ public class VehicleApplicationService { public byte[] exportCsv(VehicleModels.Query query) { var rows = vehicles.list(criteria(query)).stream().map(this::response).toList(); - StringBuilder csv = new StringBuilder("\uFEFF车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间\r\n"); - for (var row : rows) csv.append(row.id()).append(',').append(cell(row.plateNumber())).append(',') + StringBuilder csv = new StringBuilder("\uFEFF车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,最大托位,最大载重(kg),可用容积(m³),更新时间\r\n"); + for (var row : rows) csv.append(cell(row.plateNumber())).append(',') .append(cell(row.model())).append(',').append(row.fuelConsumptionPer100Km()).append(',').append(row.mileageKm()).append(',') .append(cell(row.driverOrDispatchCompany())).append(',').append(cell(row.phone())).append(',') .append(row.region()).append(',').append(row.ownership()).append(',').append(row.status()).append(',') + .append(cell(row.maxPalletSpaces())).append(',').append(cell(row.maxLoadKg())).append(',').append(cell(row.maxVolumeCbm())).append(',') .append(row.updatedAt()).append("\r\n"); return csv.toString().getBytes(StandardCharsets.UTF_8); } @@ -115,7 +131,17 @@ public class VehicleApplicationService { return new VehicleModels.Response(String.valueOf(value.id()), value.plateNumber(), value.model(), value.fuelConsumptionPer100Km(), value.mileageKm(), value.driverOrDispatchCompany(), value.phone(), value.region(), value.ownership(), value.status(), value.remark(), value.recordVersion(), - value.updatedBy(), value.updatedAt()); + value.updatedBy(), value.updatedAt(), value.maxPalletSpaces(), value.maxLoadKg(), value.maxVolumeCbm()); + } + + private static void validateCapacity(Integer pallets, java.math.BigDecimal weight, java.math.BigDecimal volume) { + if (pallets == null && weight == null && volume == null) return; + if (pallets == null || weight == null || volume == null || pallets < 1 || pallets > 10000 + || weight.signum() <= 0 || volume.signum() <= 0) throw badRequest("车辆托位、载重和容积须同时填写且大于零,或全部留空待确认"); + } + + private static boolean sameDecimal(java.math.BigDecimal first, java.math.BigDecimal second) { + return first == null ? second == null : second != null && first.compareTo(second) == 0; } private void requireVersion(VehicleSnapshot value, long expected) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java index 36a307164..c00dcb711 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java @@ -26,7 +26,10 @@ public final class VehicleModels { @Size(max = 128) String driverOrDispatchCompany, @Size(max = 64) String phone, @NotBlank String region, @NotBlank String ownership, - @NotBlank String status, @Size(max = 1000) String remark) { } + @NotBlank String status, @Size(max = 1000) String remark, + @Min(1) @Max(10000) Integer maxPalletSpaces, + @DecimalMin(value = "0", inclusive = false) @Digits(integer = 9, fraction = 3) BigDecimal maxLoadKg, + @DecimalMin(value = "0", inclusive = false) @Digits(integer = 9, fraction = 3) BigDecimal maxVolumeCbm) { } public record Update(@NotNull @Min(0) Long recordVersion, @NotBlank @Size(max = 64) String plateNumber, @@ -36,12 +39,16 @@ public final class VehicleModels { @Size(max = 128) String driverOrDispatchCompany, @Size(max = 64) String phone, @NotBlank String region, @NotBlank String ownership, - @Size(max = 1000) String remark) { } + @Size(max = 1000) String remark, + @Min(1) @Max(10000) Integer maxPalletSpaces, + @DecimalMin(value = "0", inclusive = false) @Digits(integer = 9, fraction = 3) BigDecimal maxLoadKg, + @DecimalMin(value = "0", inclusive = false) @Digits(integer = 9, fraction = 3) BigDecimal maxVolumeCbm) { } public record ChangeStatus(@NotBlank String status, @NotNull @Min(0) Long recordVersion) { } public record Response(String id, String plateNumber, String model, BigDecimal fuelConsumptionPer100Km, long mileageKm, String driverOrDispatchCompany, String phone, String region, String ownership, String status, String remark, long recordVersion, - String updatedBy, LocalDateTime updatedAt) { } + String updatedBy, LocalDateTime updatedAt, + Integer maxPalletSpaces, BigDecimal maxLoadKg, BigDecimal maxVolumeCbm) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleAssignmentGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleAssignmentGuard.java new file mode 100644 index 000000000..950631ab0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleAssignmentGuard.java @@ -0,0 +1,6 @@ +package com.baiyue.lastleg.masterdata.vehicle.application.port.out; + +/** Called while the vehicle row is locked; fleet owns the active assignment. */ +public interface VehicleAssignmentGuard { + void requireUnassigned(long vehicleId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java index 31f0b89a9..0f4f9147b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java @@ -26,16 +26,19 @@ public interface VehicleRepository { record NewVehicle(String plateNumber, String model, BigDecimal fuelConsumptionPer100Km, long mileageKm, String driverOrDispatchCompany, String phone, String region, String ownership, String status, - String remark, String operator, LocalDateTime createdAt) { } + String remark, String operator, LocalDateTime createdAt, + Integer maxPalletSpaces, BigDecimal maxLoadKg, BigDecimal maxVolumeCbm) { } record VehicleChanges(String plateNumber, String model, BigDecimal fuelConsumptionPer100Km, long mileageKm, String driverOrDispatchCompany, String phone, String region, String ownership, String remark, - String operator, LocalDateTime updatedAt) { } + String operator, LocalDateTime updatedAt, + Integer maxPalletSpaces, BigDecimal maxLoadKg, BigDecimal maxVolumeCbm) { } record VehicleSnapshot(long id, String plateNumber, String model, BigDecimal fuelConsumptionPer100Km, long mileageKm, String driverOrDispatchCompany, String phone, String region, String ownership, String status, String remark, long recordVersion, - String updatedBy, LocalDateTime updatedAt) { } + String updatedBy, LocalDateTime updatedAt, + Integer maxPalletSpaces, BigDecimal maxLoadKg, BigDecimal maxVolumeCbm) { } final class DuplicatePlateException extends RuntimeException { public DuplicatePlateException(Throwable cause) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java index 0a3472298..a47e6234e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java @@ -1,5 +1,7 @@ package com.baiyue.lastleg.palletops.gateway; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; import com.baiyue.lastleg.config.LastlegRestClientFactory; import com.baiyue.lastleg.config.LastlegRestClientProperties; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; @@ -111,18 +113,28 @@ public class HttpPalletPhysicalGateway implements PalletPhysicalGateway { .onStatus(HttpStatusCode::isError, (request, result) -> { throw new IllegalStateException("Warehouse rejected pallet transition: HTTP " + result.getStatusCode()); }).body(TransitionEnvelope.class); - if (response == null || response.data() == null) throw new IllegalStateException("Warehouse returned no transition"); + requireTransitionSuccess(response); + if (response.data() == null) throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "Warehouse returned no transition"); return response.data(); } @Override public void compensate(long warehouseId, Transition transition) { - client.post().uri("/internal/v1/pda-pallet-transitions/{id}/{key}/compensate", + TransitionEnvelope response = client.post().uri("/internal/v1/pda-pallet-transitions/{id}/{key}/compensate", transition.palletId(), transition.operationKey()) .header("X-Internal-Service-Token", internalToken).body(Map.of("warehouseId", warehouseId)) .retrieve().onStatus(HttpStatusCode::isError, (request, result) -> { throw new IllegalStateException("Warehouse rejected pallet compensation: HTTP " + result.getStatusCode()); - }).toBodilessEntity(); + }).body(TransitionEnvelope.class); + requireTransitionSuccess(response); + } + + private void requireTransitionSuccess(TransitionEnvelope response) { + if (response != null && "200".equals(response.code())) return; + if (response != null && "A0409".equals(response.code())) { + throw new BizException(ResultCode.CONFLICT, response.message()); + } + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "Warehouse pallet transition failed"); } @JsonIgnoreProperties(ignoreUnknown = true) @@ -130,5 +142,5 @@ public class HttpPalletPhysicalGateway implements PalletPhysicalGateway { @JsonIgnoreProperties(ignoreUnknown = true) private record LocationEnvelope(LocationReference data) { } @JsonIgnoreProperties(ignoreUnknown = true) - private record TransitionEnvelope(Transition data) { } + private record TransitionEnvelope(String code, String message, Transition data) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java index 8fc1a31bb..1e4cf9624 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java @@ -58,6 +58,7 @@ import java.util.stream.Collectors; @Service public class PdaPalletOperationService { @Resource private PdaPalletOperationMapper mapper; + @Resource private com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper inventoryCommands; @Resource private InventoryPositionMapper positions; @Resource private InventoryBalanceMapper balances; @Resource private InventoryLedgerMapper ledgers; @@ -465,14 +466,33 @@ public class PdaPalletOperationService { List transitions = new ArrayList<>(); try { - for (PalletChange change : plan.changes()) { - transitions.add(physical.apply(identity.overseasWarehouseId(), change.pallet(), change.targetStatus(), - change.targetLocationId(), transitionKey(identity.operatorKey(), normalizedKey, - change.pallet().palletId()), reason)); - } - saveTransitions(operation.getId(), transitions); - PalletOperationResponses.Operation result = transaction.execute(status -> - mutate(identity, operation, plan, normalizedKey, requestHash, reason)); + PalletOperationResponses.Operation result = transaction.execute(status -> { + if (plan.relabelMutation() != null) lockRelabelMutation(plan.relabelMutation(), identity.overseasWarehouseId()); + Long relabelOwner = plan.relabelMutation() == null ? null : inventoryCommands.relabelTransfer( + plan.relabelMutation().application().getCustomerId(), plan.relabelMutation().application().getId()); + // Allocation also locks the position first: preserve mutual exclusion until commit. + for (PlannedMove move : plan.moves()) { + PalletInventoryRow source = mapper.lockPosition(String.valueOf(identity.overseasWarehouseId()), move.positionId()); + if (source == null || !Objects.equals(source.getPalletId(), move.sourcePalletId()) + || value(source.getVersion()) != move.expectedVersion()) throw conflict("Inventory source changed"); + requirePalletReservation(source.getCustomerId(), move.positionId(), + move.bucket() == InventoryBucket.HOLD ? relabelOwner : null); + } + if (plan.relabelMutation() != null) { + for (Long anchorId : plan.relabelMutation().anchors().keySet()) { + PalletInventoryRow anchor = mapper.lockPosition(String.valueOf(identity.overseasWarehouseId()), anchorId); + if (anchor == null) throw conflict("Relabel inventory anchor disappeared"); + requirePalletReservation(anchor.getCustomerId(), anchorId, relabelOwner); + } + } + for (PalletChange change : plan.changes()) { + transitions.add(physical.apply(identity.overseasWarehouseId(), change.pallet(), change.targetStatus(), + change.targetLocationId(), transitionKey(identity.operatorKey(), normalizedKey, + change.pallet().palletId()), reason)); + } + saveTransitions(operation.getId(), transitions); + return mutate(identity, operation, plan, normalizedKey, requestHash, reason); + }); if (result == null) throw new IllegalStateException("托盘作业事务没有返回结果"); return result; } catch (RuntimeException failure) { @@ -482,6 +502,12 @@ public class PdaPalletOperationService { } } + private void requirePalletReservation(String customerId, Long positionId, Long owner) { + var reservation = inventoryCommands.transferReservation(customerId, positionId); + if (reservation != null && (owner == null || !owner.equals(reservation.transferOrderId()))) + throw conflict("Inventory is reserved by a warehouse transfer"); + } + private PalletOperationResponses.Operation mutate(PdaWarehouseIdentity identity, PdaPalletOperation operation, Plan plan, String key, String requestHash, String reason) { if (plan.relabelMutation() != null) lockRelabelMutation(plan.relabelMutation(), identity.overseasWarehouseId()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java index f91799008..796f0daf1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -102,6 +102,7 @@ public class RelabelApplicationService implements WalletResultHandler { @Resource private OutboundPdaTaskMapper outboundTasks; @Resource private OverseasFeeSourceService feeSources; @Resource private WarehouseTransferOrderMapper warehouseTransfers; + @Resource private com.baiyue.lastleg.transfer.service.WarehouseTransferRelabelCompletionService transferRelabelCompletion; @Transactional(readOnly = true) public String customerId(String relabelNo) { return require(null, relabelNo).getCustomerId(); } @@ -426,10 +427,10 @@ public class RelabelApplicationService implements WalletResultHandler { int holdIndex = 0; for (SourceInventory source : sources) { if (source.returnCarton() != null) continue; - inventoryCommands.hold(customerId, operatorId, new InventoryCommandRequests.Move( + holdRelabelInventory(customerId, operatorId, new InventoryCommandRequests.Move( source.request().sourceInventoryPositionId(), source.request().notifiedPackageCount(), relabelNo, null, "RELABEL:" + relabelNo, "RELABEL:" + applicationId + ":HOLD:" + holdIndex++, - "贴标申请冻结原入仓单库存")); + "贴标申请冻结原入仓单库存"), existingTailOrderId, null); } LastlegOrder tailOrder = existingTailOrderId == null ? insertTailOrder(customerId, request, requestHash, relabelNo, operationWarehouseId, total, now) @@ -613,22 +614,7 @@ public class RelabelApplicationService implements WalletResultHandler { && !preDispatched) { releaseInventory(customerId, operatorId, application); } - if (!"NOT_REQUIRED".equals(application.getWalletStatus())) { - BigDecimal quantity = BigDecimal.valueOf(Objects.requireNonNullElse(application.getTotalPackageCount(), 0)); - feeSources.publishApproved(operatorId, "RELABEL:" + relabelNo, - new OverseasFeeSourceDtos.SaveRequest(customerId, linkedOrders.get(0).getCustomerName(), - "OVERSEAS_RELABEL_FEE", "RELABEL_ORDER", application.getId(), relabelNo, - application.getLastlegOrderId(), null, null, null, null, null, - parseLong(application.getOverseasWarehouseId()), "RELABEL_SERVICE", "换标费", "RECEIVABLE", - null, null, null, null, null, null, null, List.of(), LocalDate.now(), - application.getChargeCurrency(), application.getChargeAmount(), - quantity.signum() > 0 ? quantity : null, quantity.signum() > 0 ? "BOX" : null, - quantity.signum() > 0 ? application.getChargeAmount().divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null, - null, null)); - walletCommands.publish("SETTLE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null, - "换标完成,结算费用", 2L); - application.setWalletStatus("SETTLE_PENDING"); applications.updateById(application); - } + publishCompletionFeesAndSettlement(application, operatorId, linkedOrders); } else if (Set.of("REJECTED", "CANCELLED").contains(next)) { releaseInventory(customerId, operatorId, application); if ("FROZEN".equals(application.getWalletStatus())) { @@ -695,10 +681,10 @@ public class RelabelApplicationService implements WalletResultHandler { for (RelabelItem item : itemList(application.getId())) { if (item.getSourceInventoryPositionId() == null || Objects.requireNonNullElse(item.getNotifiedPackageCount(), 0) <= 0) continue; - inventoryCommands.hold(customerId, operatorId, new InventoryCommandRequests.Move( + holdRelabelInventory(customerId, operatorId, new InventoryCommandRequests.Move( item.getSourceInventoryPositionId(), item.getNotifiedPackageCount(), relabelNo, null, "RELABEL:" + relabelNo, "RELABEL:" + application.getId() + ":RETRY_HOLD:" - + walletVersion + ":" + holdIndex++, "补款后重试冻结换标库存")); + + walletVersion + ":" + holdIndex++, "补款后重试冻结换标库存"), null, application); } String walletEventId = walletCommands.publish("FREEZE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, application.getChargeAmount(), trim(request.remark()), walletVersion); @@ -753,10 +739,10 @@ public class RelabelApplicationService implements WalletResultHandler { } } else if (item.getSourceInventoryPositionId() != null && Objects.requireNonNullElse(item.getNotifiedPackageCount(), 0) > 0) { - inventoryCommands.hold(customerId, operatorId, new InventoryCommandRequests.Move( + holdRelabelInventory(customerId, operatorId, new InventoryCommandRequests.Move( item.getSourceInventoryPositionId(), item.getNotifiedPackageCount(), relabelNo, null, "RELABEL:" + relabelNo, request.idempotencyKey().trim() + ":RESUBMIT-HOLD:" + holdIndex++, - "换标驳回修改后重新冻结库存")); + "换标驳回修改后重新冻结库存"), null, application); } } application.setLabelDeadline(request.labelDeadline()); @@ -794,6 +780,58 @@ public class RelabelApplicationService implements WalletResultHandler { insertHistory(customerId, application.getId(), before, "PROCESSING", operatorId, remark, LocalDateTime.now()); } + @Transactional + public void completeWarehouseTransferFulfillment(String customerId, String relabelNo, Long transferId, + String operatorId) { + RelabelApplication application = applications.lockNo(customerId, relabelNo); + WarehouseTransferOrder transfer = warehouseTransfers.lockByRelabelNo(customerId, relabelNo); + if (application == null || transfer == null || !customerId.equals(application.getCustomerId()) + || !customerId.equals(transfer.getCustomerId()) || !Objects.equals(transfer.getId(), transferId) + || !Boolean.TRUE.equals(transfer.getNeedRelabel()) || !"RECEIVED".equals(transfer.getStatus()) + || !Objects.equals(application.getId(), transfer.getRelabelApplicationId()) + || !Objects.equals(application.getLastlegOrderId(), transfer.getLastlegOrderId()) + || !Objects.equals(application.getCustomerOrderNo(), transfer.getTransferNo()) + || application.getCustomerConfirmedAt() == null || transfer.getRelabelCompletedAt() == null) { + throw conflict("本票换标完成归属或客户确认不一致"); + } + // Completion replay has no side effects, including for an old TEST row missing settlement. + if ("COMPLETED".equals(application.getStatus())) return; + if (!READY_FOR_OUTBOUND.equals(application.getStatus()) + || !"OUTBOUNDED".equals(application.getFulfillmentStatus()) + || !Set.of("FROZEN", "NOT_REQUIRED").contains(application.getWalletStatus())) { + throw conflict("本票换标尚未完成出库或资金冻结"); + } + List linkedOrders = tailOrders(application); + if (linkedOrders.isEmpty()) throw conflict("Linked tail-leg order is missing"); + LocalDateTime now = LocalDateTime.now(); + application.setStatus("COMPLETED"); application.setFulfillmentStatus("COMPLETED"); + application.setUpdatedAt(now); applications.updateById(application); + // The original transfer already released/transformed/shipped its own stock; never release it again. + publishCompletionFeesAndSettlement(application, operatorId, linkedOrders); + insertHistory(customerId, application.getId(), READY_FOR_OUTBOUND, "COMPLETED", operatorId, + "Warehouse transfer received; relabel fulfillment completed", now); + } + + private void publishCompletionFeesAndSettlement(RelabelApplication application, String operatorId, + List linkedOrders) { + if ("NOT_REQUIRED".equals(application.getWalletStatus())) return; + BigDecimal quantity = BigDecimal.valueOf(Objects.requireNonNullElse(application.getTotalPackageCount(), 0)); + String customerId = application.getCustomerId(), relabelNo = application.getRelabelNo(); + feeSources.publishApproved(operatorId, "RELABEL:" + relabelNo, + new OverseasFeeSourceDtos.SaveRequest(customerId, linkedOrders.get(0).getCustomerName(), + "OVERSEAS_RELABEL_FEE", "RELABEL_ORDER", application.getId(), relabelNo, + application.getLastlegOrderId(), null, null, null, null, null, + parseLong(application.getOverseasWarehouseId()), "RELABEL_SERVICE", "换标费", "RECEIVABLE", + null, null, null, null, null, null, null, List.of(), LocalDate.now(), + application.getChargeCurrency(), application.getChargeAmount(), + quantity.signum() > 0 ? quantity : null, quantity.signum() > 0 ? "BOX" : null, + quantity.signum() > 0 ? application.getChargeAmount().divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null, + null, null)); + walletCommands.publish("SETTLE", Long.parseLong(customerId), "RELABEL_ORDER", relabelNo, null, + "换标完成,结算费用", 2L); + application.setWalletStatus("SETTLE_PENDING"); applications.updateById(application); + } + @Transactional public RelabelResponses.Detail confirmCompletion(String customerId, String relabelNo, String operatorId, RelabelRequests.CustomerConfirm request) { @@ -812,6 +850,10 @@ public class RelabelApplicationService implements WalletResultHandler { throw conflict("Upload the shipping label before confirming relabel completion"); } List relabelItems = itemList(application.getId()); + WarehouseTransferOrder linkedTransfer = warehouseTransfers == null ? null + : warehouseTransfers.lockByRelabelNo(customerId, relabelNo); + boolean existingStockTransfer = linkedTransfer != null + && StringUtils.hasText(linkedTransfer.getAllocationIdempotencyKey()); boolean requiresOutbound = relabelItems.stream() .anyMatch(item -> Set.of("CARTON_LABEL", "CARTON_AND_SKU").contains(operationType(item))); reviewTask.setStatus("COMPLETED"); reviewTask.setReviewDecision("CONFIRMED"); @@ -827,14 +869,12 @@ public class RelabelApplicationService implements WalletResultHandler { application.setFulfillmentStatus(requiresOutbound ? ("TRUCK".equals(application.getDeliveryMethod()) ? "WAIT_PRE_DISPATCH" : "WAIT_OUTBOUND_CREATION") : "NOT_REQUIRED"); - if (requiresOutbound && !"TRUCK".equals(application.getDeliveryMethod())) { + if (requiresOutbound && !existingStockTransfer && !"TRUCK".equals(application.getDeliveryMethod())) { createDirectOutbound(application, operatorId, now); application.setFulfillmentStatus("OUTBOUND_CREATED"); } application.setUpdatedAt(now); applications.updateById(application); - WarehouseTransferOrder linkedTransfer = warehouseTransfers == null ? null - : warehouseTransfers.lockByRelabelNo(customerId, relabelNo); for (LastlegOrder order : tailOrders(application)) { if ("RETURN_RELABEL".equals(order.getOrderType()) && !Objects.equals(order.getRelabelOrderNo(), application.getRelabelNo())) continue; // 仓储转运尾程单由下方按转运目的仓一次性写入精确状态,避免先落入通用状态。 @@ -910,20 +950,25 @@ public class RelabelApplicationService implements WalletResultHandler { && StringUtils.hasText(transfer.getPrivateDestinationAddress()); String transferStatus = transferHasDestination ? "READY_FOR_DISPATCH" : "WAIT_FBA_DESTINATION"; + if (existingStockTransfer) { + transferRelabelCompletion.complete(transfer, application, relabelItems, operatorId, now); + transferStatus = "ALLOCATED"; + } transfer.setStatus(transferStatus); transfer.setRelabelCompletedAt(now); transfer.setUpdatedAt(now); warehouseTransfers.updateById(transfer); boolean relabelHasDestination = !relabelItems.isEmpty() && relabelItems.stream() .allMatch(item -> StringUtils.hasText(item.getDestinationFbaWarehouseCode())); - application.setFulfillmentStatus("READY_FOR_DISPATCH".equals(transferStatus) || relabelHasDestination + application.setFulfillmentStatus(existingStockTransfer ? "WAIT_TRANSFER_OUTBOUND" + : "READY_FOR_DISPATCH".equals(transferStatus) || relabelHasDestination ? "WAIT_PRE_DISPATCH" : transferStatus); applications.updateById(application); // 转运单自身的尾程订单与换标申请尾程订单是两条记录,需同步释放原转运单参与排车。 LastlegOrder transferOrder = orders.selectById(transfer.getLastlegOrderId()); if (transferOrder != null && customerId.equals(transferOrder.getCustomerId())) { String transferOrderBefore = transferOrder.getCurrentStatus(); - transferOrder.setCurrentStatus(transferStatus); + if (!existingStockTransfer) transferOrder.setCurrentStatus(transferStatus); transferOrder.setFbaWarehouseCode(transfer.getFbaWarehouseCode()); transferOrder.setRelabelStatus("COMPLETED"); transferOrder.setSubOrderStatus("WAREHOUSE_TRANSFER:" + transferStatus + ";RELABEL:COMPLETED"); @@ -1169,13 +1214,39 @@ public class RelabelApplicationService implements WalletResultHandler { continue; } if (item.getSourceInventoryPositionId() == null || item.getNotifiedPackageCount() == null) continue; - inventoryCommands.release(customerId, operatorId, new InventoryCommandRequests.Move( + releaseRelabelInventory(customerId, operatorId, new InventoryCommandRequests.Move( item.getSourceInventoryPositionId(), item.getNotifiedPackageCount(), application.getRelabelNo(), null, "RELABEL:" + application.getRelabelNo(), application.getIdempotencyKey() + ":RELEASE:" - + item.getSourceInventoryPositionId(), "贴标结束释放库存至原入仓单")); + + item.getSourceInventoryPositionId(), "贴标结束释放库存至原入仓单"), application); } } + private WarehouseTransferOrder relabelInventoryOwner(String customerId, Long existingTailOrderId, + RelabelApplication application) { + if (application != null) { + WarehouseTransferOrder owner = warehouseTransfers.lockByRelabelNo(customerId, application.getRelabelNo()); + if (owner != null && (!Objects.equals(owner.getRelabelApplicationId(), application.getId()) + || !Objects.equals(owner.getLastlegOrderId(), application.getLastlegOrderId()))) + throw conflict("Relabel transfer association changed"); + return owner; + } + return existingTailOrderId == null ? null : warehouseTransfers.lockTailOrder(customerId, existingTailOrderId); + } + + private void holdRelabelInventory(String customerId, String operatorId, InventoryCommandRequests.Move request, + Long existingTailOrderId, RelabelApplication application) { + WarehouseTransferOrder owner = relabelInventoryOwner(customerId, existingTailOrderId, application); + if (owner == null) inventoryCommands.hold(customerId, operatorId, request); + else inventoryCommands.holdWarehouseTransfer(customerId, operatorId, owner.getId(), request); + } + + private void releaseRelabelInventory(String customerId, String operatorId, InventoryCommandRequests.Move request, + RelabelApplication application) { + WarehouseTransferOrder owner = relabelInventoryOwner(customerId, null, application); + if (owner == null) inventoryCommands.release(customerId, operatorId, request); + else inventoryCommands.releaseWarehouseTransfer(customerId, operatorId, owner.getId(), request); + } + private List itemList(Long applicationId) { return items.selectList(new LambdaQueryWrapper().eq(RelabelItem::getApplicationId, applicationId) .orderByAsc(RelabelItem::getId)); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java index 279b9e728..ff7a259e6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java @@ -17,7 +17,8 @@ public class UnloadCompletedOutboxRepository { EventTypes.OVERSEAS_CONTAINER_UNLOADED, EventTypes.OVERSEAS_INBOUND_ORDER_DETAINED_CHANGED, EventTypes.OVERSEAS_DELIVERY_COMPLETED, - EventTypes.WALLET_COMMAND_REQUESTED); + EventTypes.WALLET_COMMAND_REQUESTED, + EventTypes.COMPANY_VEHICLE_COST_SUBMITTED); @Resource private LastlegEventOutboxMapper mapper; public List pending(int limit, LocalDateTime now, LocalDateTime expiredLeaseBefore) { @@ -32,20 +33,25 @@ public class UnloadCompletedOutboxRepository { .last("LIMIT " + Math.min(Math.max(limit, 1), 100))); } - public boolean claim(long id, String worker, LocalDateTime now, LocalDateTime expiredLeaseBefore) { + public boolean claim(long id, String worker, LocalDateTime now) { return mapper.update(null, new LambdaUpdateWrapper() - .eq(LastlegEventOutbox::getId, id) - .in(LastlegEventOutbox::getEventType, OVERSEAS_TO_CN_TYPES) - .and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING") - .and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt) - .or().le(LastlegEventOutbox::getNextRetryAt, now)) - .or(stale -> stale.eq(LastlegEventOutbox::getStatus, "PROCESSING") - .le(LastlegEventOutbox::getLockedAt, expiredLeaseBefore))) + .eq(LastlegEventOutbox::getId, id).in(LastlegEventOutbox::getEventType, OVERSEAS_TO_CN_TYPES) + .eq(LastlegEventOutbox::getStatus, "PENDING") + .and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt).or().le(LastlegEventOutbox::getNextRetryAt, now)) .set(LastlegEventOutbox::getStatus, "PROCESSING") - .set(LastlegEventOutbox::getLockedBy, worker) - .set(LastlegEventOutbox::getLockedAt, now)) == 1; + .set(LastlegEventOutbox::getLockedBy, worker).set(LastlegEventOutbox::getLockedAt, now)) == 1; } + public int expire(long id, String worker, LocalDateTime cutoff, int failures, LocalDateTime next, String error) { + return mapper.update(null, new LambdaUpdateWrapper() + .eq(LastlegEventOutbox::getId, id).in(LastlegEventOutbox::getEventType, OVERSEAS_TO_CN_TYPES) + .eq(LastlegEventOutbox::getStatus, "PROCESSING").eq(LastlegEventOutbox::getLockedBy, worker) + .le(LastlegEventOutbox::getLockedAt, cutoff) + .set(LastlegEventOutbox::getStatus, next == null ? "DEAD" : "PENDING") + .set(LastlegEventOutbox::getRetryCount, failures).set(LastlegEventOutbox::getNextRetryAt, next) + .set(LastlegEventOutbox::getLockedBy, null).set(LastlegEventOutbox::getLockedAt, null) + .set(LastlegEventOutbox::getLastError, truncate(error))); + } public LastlegEventOutbox claimed(long id, String worker) { return mapper.selectOne(new LambdaQueryWrapper() .eq(LastlegEventOutbox::getId, id) @@ -54,8 +60,8 @@ public class UnloadCompletedOutboxRepository { .eq(LastlegEventOutbox::getLockedBy, worker)); } - public void published(long id, String worker, LocalDateTime now) { - mapper.update(null, claimedBase(id, worker) + public int published(long id, String worker, LocalDateTime now) { + return mapper.update(null, claimedBase(id, worker) .set(LastlegEventOutbox::getStatus, "PUBLISHED") .set(LastlegEventOutbox::getPublishedAt, now) .set(LastlegEventOutbox::getNextRetryAt, null) @@ -64,8 +70,16 @@ public class UnloadCompletedOutboxRepository { .set(LastlegEventOutbox::getLastError, null)); } - public void retry(long id, String worker, LocalDateTime retryAt, String error) { - mapper.update(null, claimedBase(id, worker) + public int dead(long id, String worker, int failures, String error) { + return mapper.update(null, claimedBase(id, worker) + .set(LastlegEventOutbox::getStatus, "DEAD") + .set(LastlegEventOutbox::getRetryCount, failures) + .set(LastlegEventOutbox::getNextRetryAt, null) + .set(LastlegEventOutbox::getLockedBy, null).set(LastlegEventOutbox::getLockedAt, null) + .set(LastlegEventOutbox::getLastError, truncate(error))); + } + public int retry(long id, String worker, LocalDateTime retryAt, String error) { + return mapper.update(null, claimedBase(id, worker) .setSql("retry_count = retry_count + 1") .set(LastlegEventOutbox::getStatus, "PENDING") .set(LastlegEventOutbox::getNextRetryAt, retryAt) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryOutboundMovement.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryOutboundMovement.java new file mode 100644 index 000000000..747901a7b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryOutboundMovement.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.returnreceipt.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +/** Immutable SKU stock deltas written in the outbound pick transaction. */ +@Data +@TableName("ll_return_inventory_outbound_movement") +public class ReturnInventoryOutboundMovement { + @TableId(type = IdType.INPUT) private Long id; + private Long outboundPickId; + private String customerId; + private String overseasWarehouseId; + private Long cartonId; + private String cartonMark; + private String outboundNo; + private String skuCode; + private String productNameZh; + private Long quantity; + private Long totalBefore; + private Long totalAfter; + private Long availableBefore; + private Long availableAfter; + private Long cartonsBefore; + private Long cartonsAfter; + private LocalDateTime occurredAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryBalanceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryBalanceMapper.java index 9aaeeb487..caf327ce9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryBalanceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryBalanceMapper.java @@ -12,6 +12,18 @@ import java.util.List; @Mapper public interface ReturnInventoryBalanceMapper extends BaseMapper { + @Select("SELECT * FROM ll_return_inventory_balance WHERE customer_id=#{customerId} " + + "AND overseas_warehouse_id=#{warehouseId} AND sku_code=#{skuCode} FOR UPDATE") + ReturnInventoryBalance lockSku(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId, + @Param("skuCode") String skuCode); + + @Update("UPDATE ll_return_inventory_balance SET total_quantity=total_quantity-#{quantity}, " + + "available_quantity=available_quantity-#{quantity},carton_quantity=carton_quantity-1,updated_at=NOW(3) " + + "WHERE id=#{id} AND customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} " + + "AND total_quantity>=#{quantity} AND available_quantity>=#{quantity} AND carton_quantity>=1") + int deductOutbound(@Param("id") long id, @Param("customerId") String customerId, + @Param("warehouseId") String warehouseId, @Param("quantity") long quantity); + @Insert("INSERT INTO ll_return_inventory_balance (id,customer_id,overseas_warehouse_id,sku_code,product_name_zh,total_quantity,available_quantity,carton_quantity,created_at,updated_at) " + "VALUES (#{id},#{customerId},#{warehouseId},#{skuCode},#{productNameZh},#{totalQuantity},#{availableQuantity},#{cartons},NOW(3),NOW(3)) " + "ON DUPLICATE KEY UPDATE total_quantity=total_quantity+VALUES(total_quantity),available_quantity=available_quantity+VALUES(available_quantity),carton_quantity=carton_quantity+VALUES(carton_quantity), " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryMovementMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryMovementMapper.java index d4aa8999b..16d780a55 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryMovementMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryMovementMapper.java @@ -7,15 +7,17 @@ import org.apache.ibatis.annotations.Select; import java.util.List; -/** Read-only return inventory movement ledger reconstructed from signed receipts and merged cartons. */ +/** Receipt/merge projections plus immutable outbound SKU movements. */ @Mapper -public interface ReturnInventoryMovementMapper { +public interface ReturnInventoryMovementMapper extends com.baomidou.mybatisplus.core.mapper.BaseMapper { String MOVEMENTS = "SELECT o.customer_id customerId,o.overseas_warehouse_id overseasWarehouseId,ri.sku_code skuCode,oi.product_name_zh productNameZh," + "'RECEIPT' movementType,r.receipt_no referenceNo,ri.received_quantity totalDelta,ri.received_quantity availableDelta,r.signed_at occurredAt " + "FROM ll_return_receipt r JOIN ll_return_order o ON o.id=r.return_order_id JOIN ll_return_receipt_item ri ON ri.receipt_id=r.id " + "JOIN ll_return_order_item oi ON oi.id=ri.return_order_item_id " + "UNION ALL SELECT c.customer_id,c.overseas_warehouse_id,i.sku_code,i.product_name_zh,'MERGE',c.carton_mark,0,-i.quantity,c.created_at " + - "FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id WHERE c.source_batch_no='MERGED'"; + "FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id WHERE c.source_batch_no='MERGED'" + + " UNION ALL SELECT customer_id,overseas_warehouse_id,sku_code,product_name_zh,'OUTBOUND',outbound_no," + + "-quantity,-quantity,occurred_at FROM ll_return_inventory_outbound_movement"; @Select("')), }, token) context.addStep(MODULE, '伪造图片内容安全扫描返回A0400', malicious, malicious?.http === 200 && code(malicious) === 'A0400' && traced(malicious), trace(malicious), OPTIONS) const photos = [] for (const role of PHOTO_ROLES) { - const response = await context.requestOverseasMultipart('POST', '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files', { + const response = await context.requestMultipart('POST', '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files', { name: `${context.marker}-${role.toLowerCase()}.png`, type: 'image/png', content: PNG, }, token) const fileId = id(response?.body?.data?.fileId) @@ -282,7 +475,7 @@ export async function uploadPhotos(context, token) { return photos } -async function awaitProducerConsumption(context, producers) { +export async function awaitProducerConsumption(context, producers) { const expected = [...new Set(producers.map((item) => id(item?.containerNo)).filter(nonBlank))] if (!expected.length) return true const consumed = new Set() @@ -290,8 +483,7 @@ async function awaitProducerConsumption(context, producers) { for (let attempt = 1; attempt <= 90; attempt += 1) { const missing = expected.filter((containerNo) => !consumed.has(containerNo)) const results = await Promise.all(missing.map(async (containerNo) => { - const response = await context.requestOverseas('GET', - `/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`) + const response = await context.request('GET', `/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(containerNo)}`) return { containerNo, response } })) for (const { containerNo, response } of results) { @@ -313,12 +505,10 @@ async function awaitProducerConsumption(context, producers) { return expected.every((containerNo) => consumed.has(containerNo)) } -async function convertReceivingContainers(context, producers) { +export async function convertReceivingContainers(context, producers) { for (const producer of producers) { const containerNo = id(producer?.containerNo) - const summaryResponse = await context.requestOverseas('GET', - `/api/platform/v1/last-leg/orders/container-summaries?page=1&size=20&containerNo=${encodeURIComponent(containerNo)}`, - undefined, {}, context.overseasToken) + const summaryResponse = await context.request('GET', `/api/platform/v1/last-leg/orders/container-summaries?page=1&size=20&containerNo=${encodeURIComponent(containerNo)}`, undefined, context.token, {}) const summaries = success(summaryResponse) && Array.isArray(summaryResponse.body?.data?.records) ? summaryResponse.body.data.records : [] const summary = summaries.find((item) => id(item?.containerNo) === containerNo) @@ -336,9 +526,7 @@ async function convertReceivingContainers(context, producers) { `sourceContainerBookingId=${id(summary?.sourceContainerBookingId) || 'missing'}, allPlansReady=${summary?.allPlansReady === true}, orders=${totalOrders}, inboundCodes=${inboundCodes}, plans=${existingPlans}, pending=${pendingPlans}; ${trace(summaryResponse)}`, OPTIONS) if (!convertible) return false const bookingId = id(summary.sourceContainerBookingId) - const arrivalQueryResponse = await context.requestOverseas('POST', - '/api/platform/v1/last-leg/orders/container-arrival-times/query', - { sourceContainerBookingIds: [bookingId] }, {}, context.overseasToken) + const arrivalQueryResponse = await context.request('POST', '/api/platform/v1/last-leg/orders/container-arrival-times/query', { sourceContainerBookingIds: [bookingId] }, context.token, {}) const arrivalTimes = success(arrivalQueryResponse) && Array.isArray(arrivalQueryResponse.body?.data) ? arrivalQueryResponse.body.data : [] let arrivalTime = arrivalTimes.find((item) => id(item?.sourceContainerBookingId) === bookingId) @@ -349,12 +537,8 @@ async function convertReceivingContainers(context, producers) { if (!arrivalTime) return false if (!nonBlank(arrivalTime.estimatedWarehouseArrivalAt)) { const estimatedWarehouseArrivalAt = new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 19) - const arrivalUpdateResponse = await context.requestOverseas('PUT', - `/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(bookingId)}/expected-arrival`, - { estimatedWarehouseArrivalAt, expectedVersion: Number(arrivalTime.version), - remark: `${context.marker}-overseas-inbound-plan-prerequisite` }, - { 'Idempotency-Key': `${context.marker}-expected-arrival-${containerNo}`.slice(0, 128) }, - context.overseasToken) + const arrivalUpdateResponse = await context.request('PUT', `/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(bookingId)}/expected-arrival`, { estimatedWarehouseArrivalAt, expectedVersion: Number(arrivalTime.version), + remark: `${context.marker}-overseas-inbound-plan-prerequisite` }, context.token, { 'Idempotency-Key': `${context.marker}-expected-arrival-${containerNo}`.slice(0, 128) }) arrivalTime = success(arrivalUpdateResponse) ? arrivalUpdateResponse.body?.data : null context.addStep(MODULE, `维护整柜预计到仓时间 ${containerNo}`, arrivalUpdateResponse, id(arrivalTime?.sourceContainerBookingId) === bookingId @@ -364,8 +548,7 @@ async function convertReceivingContainers(context, producers) { if (!arrivalTime || !nonBlank(arrivalTime.estimatedWarehouseArrivalAt)) return false } const previewPath = `/api/platform/v1/last-leg/inbound-plans/container-preview?sourceContainerBookingId=${encodeURIComponent(bookingId)}` - const previewResponse = await context.requestOverseas('GET', previewPath, - undefined, {}, context.overseasToken) + const previewResponse = await context.request('GET', previewPath, undefined, context.token, {}) const preview = success(previewResponse) ? previewResponse.body?.data : null context.addStep(MODULE, `读取PDA前柜级计划与自动托盘预览 ${containerNo}`, previewResponse, preview?.ready === true && Number(preview?.totalOrderCount) === totalOrders @@ -377,9 +560,7 @@ async function convertReceivingContainers(context, producers) { const body = { sourceContainerBookingId: bookingId, snapshotVersion: Number(preview.snapshotVersion), snapshotHash: String(preview.snapshotHash) } const key = `${context.marker}-pda-convert-${containerNo}`.slice(0, 128) - const convertedResponse = await context.requestOverseas('POST', - '/api/platform/v1/last-leg/inbound-plans/convert-container', body, - { 'Idempotency-Key': key }, context.overseasToken) + const convertedResponse = await context.request('POST', '/api/platform/v1/last-leg/inbound-plans/convert-container', body, context.token, { 'Idempotency-Key': key }) const plans = success(convertedResponse) && Array.isArray(convertedResponse.body?.data) ? convertedResponse.body.data : [] const convertedInboundCodes = new Set(plans @@ -412,9 +593,7 @@ async function convertReceivingContainers(context, producers) { OPTIONS) } rememberGeneratedPlans(context, plans) - const replayResponse = await context.requestOverseas('POST', - '/api/platform/v1/last-leg/inbound-plans/convert-container', body, - { 'Idempotency-Key': key }, context.overseasToken) + const replayResponse = await context.request('POST', '/api/platform/v1/last-leg/inbound-plans/convert-container', body, context.token, { 'Idempotency-Key': key }) context.addStep(MODULE, `整柜转换幂等重放零新增 ${containerNo}`, replayResponse, success(replayResponse) && JSON.stringify(replayResponse.body?.data) === JSON.stringify(plans), trace(replayResponse), OPTIONS) @@ -428,9 +607,7 @@ async function convertReceivingContainers(context, producers) { `plans=${existingPlans}, plannedPallets=${preview?.existingPlannedPalletCount ?? 'missing'}; ${trace(previewResponse)}`, OPTIONS) } - const progressResponse = await context.requestOverseas('GET', - `/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(summary.sourceContainerBookingId)}`, - undefined, {}, context.overseasToken) + const progressResponse = await context.request('GET', `/api/platform/v1/last-leg/orders/container-summaries/${encodeURIComponent(summary.sourceContainerBookingId)}`, undefined, context.token, {}) const progress = success(progressResponse) ? progressResponse.body?.data?.summary : null context.addStep(MODULE, `PDA前整柜计划完整度闭环 ${containerNo}`, progressResponse, progress?.allPlansReady === true && Number(progress?.planCount) === totalOrders @@ -452,8 +629,7 @@ async function awaitPickupReadyCandidates(context, token, producers, allProducer let response = null let candidatesByContainer = new Map() for (let attempt = 1; attempt <= 30; attempt += 1) { - response = await context.requestOverseas('GET', - '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, token) + response = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, token, {}) const candidateRows = success(response) && Array.isArray(response.body?.data) ? response.body.data : [] candidatesByContainer = new Map(candidateRows .map((item) => [id(item?.containerNo), item]).filter(([containerNo]) => nonBlank(containerNo))) @@ -499,8 +675,7 @@ export async function resolveCartonFixtures(context, token, inbounds, producerCa } const cartonFixtures = [] for (const [index, cartonMark] of cartonCandidates.entries()) { - const response = await context.requestOverseas('POST', - '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark }, {}, token) + const response = await context.request('POST', '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark }, token, {}) if (!strictDropship && overseasCartons.includes(cartonMark) && a0404Message(response, /^Carton mark was not found$/)) { context.addStep(MODULE, `排除未实装箱码候选${index + 1}`, response, true, @@ -534,7 +709,7 @@ export async function resolveCartonFixtures(context, token, inbounds, producerCa return cartonFixtures } -async function probeCartonResolveNegatives(context, token, current) { +export async function probeCartonResolveNegatives(context, token, current, runCrossContainerProbe = true) { const fba = current.inbounds.find((item) => item.destinationType === 'FBA') await expected(context, token, 'POST', '无#箱码被服务端拒绝', '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', @@ -546,18 +721,19 @@ async function probeCartonResolveNegatives(context, token, current) { '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark: `UNMATCHED-${markerSuffix(context)}#ANY-SUFFIX` }, cartonResolveRejected) + if (runCrossContainerProbe) { const allProducers = context.createdResources?.phase4InboundProducerFixtures?.containers || [] - const crossContainerMark = allProducers - .filter((item) => id(item?.containerNo) !== id(current.containerNo)) - .flatMap((item) => Array.isArray(item?.cartonMarks) ? item.cartonMarks : [item?.cartonMark]) - .map(id).find(nonBlank) - if (crossContainerMark) { + const crossContainerProbe = crossContainerCartonProbe(allProducers,current) + if (crossContainerProbe) { + context.addStep(MODULE,'跨柜解析反证来源为另一真实实装柜及入仓码',null,true, + JSON.stringify(crossContainerProbe),OPTIONS) await expected(context, token, 'POST', '其他柜箱码前缀被当前锁定柜拒绝', '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', - { cartonMark: crossContainerMark }, cartonResolveRejected) + { cartonMark: crossContainerProbe.cartonMark }, cartonResolveRejected) } else { blockerGap(context, '箱码跨柜负向', '缺少另一真实柜的冻结箱码,不能伪造跨柜归属反证') } + } const overseas = current.inbounds.find((item) => item.destinationType === 'OVERSEAS') if (overseas) { @@ -668,16 +844,46 @@ async function receiveDropship(context, token, producer, inbounds) { return accepted === expectedPending } -async function runReceiving(context, token, secondaryToken, merchantToken, preferredProducer = null, runAuthorizationProbes = true) { - if (runAuthorizationProbes && context.overseasToken) { - const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, context.overseasToken) - context.addStep(MODULE, '平台管理员海外令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS) +export async function awaitReceivingCandidate(context, token, producer, { attempts = 30, intervalMs = 1000, + delay = ms => new Promise(resolve => setTimeout(resolve, ms)) } = {}) { + const containerNo = id(producer?.containerNo), bookingId = id(producer?.containerBookingId) + if (!nonBlank(containerNo) || !longId(bookingId) || !Number.isInteger(attempts) || attempts < 1 || attempts > 30 + || !Number.isInteger(intervalMs) || intervalMs < 0 || intervalMs > 1000) throw Error('Invalid exact receiving candidate scope or wait bound') + const proof = { containerNo, sourceContainerBookingId:bookingId, attempts:[], outcome:'PENDING' } + const resource = context.createdResources.phase4OverseasPda ||= {} + ;(resource.candidateWaitEvidence ||= []).push(proof) + let result + for (let attempt = 1; attempt <= attempts; attempt++) { + result = await pda(context, token, 'GET', `查询本轮原柜候选收敛第${attempt}次`, '/api/pda/v1/last-leg/receiving-containers/candidates') + const rows = Array.isArray(result.data) ? result.data : [] + const matchingCandidates = rows.filter(item => item.containerNo === containerNo && id(item.sourceContainerBookingId) === bookingId) + proof.attempts.push({ attempt, http:result.response?.http, code:code(result.response), requestId:result.response?.requestId, + traceId:result.response?.traceId, candidateCount:rows.length, exactMatches:matchingCandidates.length, + sameNumberCandidates:rows.filter(item => item.containerNo === containerNo).map(item => ({ + containerNo:item.containerNo, sourceContainerBookingId:id(item.sourceContainerBookingId), + operable:item.operable, blockReason:item.blockReason })) }) + if (!success(result.response) || !Array.isArray(result.data)) { proof.outcome='QUERY_FAILED'; break } + if (matchingCandidates.length > 1) { proof.outcome='AMBIGUOUS'; break } + if (matchingCandidates.length === 1 && matchingCandidates[0].operable === true) { + proof.outcome='READY' + return {...result,candidate:matchingCandidates[0]} + } + if (attempt < attempts) await delay(intervalMs) + } + if (proof.outcome === 'PENDING') proof.outcome='TIMED_OUT' + return {...result,candidate:null,reason:proof.outcome} +} + +export async function runReceiving(context, token, secondaryToken, merchantToken, preferredProducer = null, runAuthorizationProbes = true, candidateWait = {}) { + if (runAuthorizationProbes && context.token) { + const denied = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, context.token, {}) + context.addStep(MODULE, '平台管理员访问令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS) } if (runAuthorizationProbes && merchantToken) { - const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, merchantToken) - context.addStep(MODULE, '客户海外令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS) + const denied = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, merchantToken, {}) + context.addStep(MODULE, '客户访问令牌不能冒充仓库PDA操作员', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS) } - const before = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, {}, token) + const before = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, token, {}) const noCurrent = notFound(before) context.addStep(MODULE, '无当前柜返回404属于正常空态', before, noCurrent || success(before), `${noCurrent ? 'empty=true' : `resumeStatus=${before?.body?.data?.status || 'missing'}`}; ${trace(before)}`, OPTIONS) @@ -685,20 +891,19 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe blockerGap(context, '可重复的完整柜号验收', `账号已有未完成柜 ${before.body?.data?.containerNo || 'unknown'},本轮不覆盖或篡改遗留现场会话`, 'dirty-fixture', before) return } - const candidates = await pda(context, token, 'GET', '查询当前客户待收柜号', '/api/pda/v1/last-leg/receiving-containers/candidates') - if (!Array.isArray(candidates.data) || candidates.data.length === 0) { - blockerGap(context, 'PDA柜号候选', `当前客户没有由三期装柜完成事件生成的待收入仓计划;${trace(candidates.response)}`, 'producer-chain', candidates.response) - return - } const preferredContainer = id(preferredProducer?.containerNo) || context.createdResources?.phase3Loading?.containerNo - const preferredBookingId = id(preferredProducer?.containerBookingId) - const matchingCandidates = candidates.data.filter(item => item.containerNo === preferredContainer - && (!preferredBookingId || id(item.sourceContainerBookingId) === preferredBookingId)) - const candidate = matchingCandidates.length === 1 ? matchingCandidates[0] : null + const preferredBookingId = id(preferredProducer?.containerBookingId || context.createdResources?.phase3Loading?.containerBookingId) + if (!nonBlank(preferredContainer) || !longId(preferredBookingId)) { + blockerGap(context, '本轮原柜候选身份', '缺少本轮containerNo或containerBookingId,禁止仅按柜号匹配历史柜', 'producer-chain') + return + } + const candidates = await awaitReceivingCandidate(context,token, + {containerNo:preferredContainer,containerBookingId:preferredBookingId},candidateWait) + const candidate = candidates.candidate if (!candidate) { blockerGap(context, `本轮柜号${preferredContainer || 'missing'}候选`, - '三期装柜完成生产者已落地,但海外候选尚未出现该柜;禁止回退处理历史或其他柜', 'producer-chain', candidates.response) + `本轮原柜候选未在有界等待内唯一可操作:${candidates.reason};禁止回退处理历史或其他柜`, 'producer-chain', candidates.response) return } const containerNo = id(candidate?.containerNo) @@ -749,7 +954,9 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe const current = await pda(context, token, 'GET', '签收后按账号恢复当前柜', '/api/pda/v1/last-leg/receiving-containers/current') if (!success(current.response)) return const beforeResolve = receivingSnapshot(current.data) - await probeCartonResolveNegatives(context, token, current.data) + // Single-container completion/cancellation continuation does not provision a second + // loaded source. The default full receiving chain still requires its cross-container proof. + await probeCartonResolveNegatives(context, token, current.data, runAuthorizationProbes) const producerCartonMarks = preferredProducer?.cartonMarks?.length ? preferredProducer.cartonMarks : [preferredProducer?.cartonMark] const cartonFixtures = await resolveCartonFixtures(context, token, current.data.inbounds, producerCartonMarks, preferredProducer) @@ -843,12 +1050,53 @@ async function runReceiving(context, token, secondaryToken, merchantToken, prefe return } const completeBody = { idempotencyKey: key(context, 'complete') } + const sourceInboundIds = [...new Set([id(preferredProducer?.sourceInboundId), + ...(preferredProducer?.palletLabelManifest?.groups || []).flatMap(group => (group.labels || []).map(label => id(label.inboundId)))].filter(longId))].sort() + const scope = { customerId: id(preferredProducer?.tenantId), warehouseId: id(refreshed.data?.overseasWarehouseId), + bookingId: sourceContainerBookingId, containerNo, + inboundCodes: refreshed.data.inbounds.map(inbound => inbound.inboundCode).sort(), sourceInboundIds, + expectedPackagesByInbound: Object.fromEntries(refreshed.data.inbounds.map(inbound => [inbound.inboundCode, Number(inbound.totalPackages)])), + expectedTransitPackages: refreshed.data.inbounds.filter(inbound => inbound.orderType === 'FIRST_LEG').reduce((sum, inbound) => sum + Number(inbound.totalPackages), 0), + completeKey: completeBody.idempotencyKey, freshKey: key(context, 'complete-fresh-repeat') } + const receivingProof = { scope, stages: {}, proof: null, status: 'UNPROVEN' } + context.createdResources.phase4OverseasPda.receivingCompletionEvidence ||= {} + context.createdResources.phase4OverseasPda.receivingCompletionEvidence[sourceContainerBookingId] = receivingProof + if (typeof context.context.beforeReceivingComplete === 'function') { + await context.context.beforeReceivingComplete(scope) + } + await captureReceivingEvidence(context, scope, 'BEFORE_COMPLETE', receivingProof) const completed = await pda(context, token, 'POST', '完成当前柜并写入库存与卸柜事件', '/api/pda/v1/last-leg/receiving-containers/current/complete', completeBody) + await captureReceivingEvidence(context, scope, 'AFTER_COMPLETE', receivingProof) if (!success(completed.response)) return const completeReplay = await pda(context, token, 'POST', '完成当前柜同键同载荷重放', '/api/pda/v1/last-leg/receiving-containers/current/complete', completeBody) context.assert(MODULE, '完成幂等重放仍返回COMPLETED', completeReplay.response, - success(completeReplay.response) && completeReplay.data?.status === 'COMPLETED', trace(completeReplay.response), OPTIONS) - const after = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, {}, token) + success(completeReplay.response) && completeReplay.data?.status === 'COMPLETED' && sameEvidence(completeReplay.data, completed.data), trace(completeReplay.response), OPTIONS) + await captureReceivingEvidence(context, scope, 'AFTER_SAME_KEY_REPLAY', receivingProof) + const freshComplete = await expected(context, token, 'POST', '已完成柜新键重复完成被拒绝且不能再次写库存', + '/api/pda/v1/last-leg/receiving-containers/current/complete', { idempotencyKey: scope.freshKey }, + response => a0404Message(response, /^Current receiving container session was not found$/)) + await captureReceivingEvidence(context, scope, 'AFTER_FRESH_KEY_REJECTION', receivingProof) + receivingProof.proof = receivingCompletionProof(scope, Object.fromEntries(Object.entries(receivingProof.stages).map(([stage, row]) => [stage, row.evidence]))) + receivingProof.commandResults = { firstCompleteSucceeded: success(completed.response), sameKeyReturnedExactOriginal: success(completeReplay.response) && sameEvidence(completeReplay.data, completed.data), + freshKeyRejected: a0404Message(freshComplete, /^Current receiving container session was not found$/) } + receivingProof.status = receivingProof.proof.passed && Object.values(receivingProof.commandResults).every(Boolean) ? 'VERIFIED' : 'UNPROVEN' + for (const [check, passed] of Object.entries(receivingProof.proof.checks)) context.assert(MODULE, `收货完成库存守恒 ${check}`, null, passed, + `bookingId=${sourceContainerBookingId}, proofStatus=${receivingProof.status}`, OPTIONS) + if (typeof context.context.afterReceivingComplete === 'function') { + if (receivingProof.status !== 'VERIFIED') throw new Error('收货完成库存证据未闭合,禁止继续故障恢复验收') + const eventRecovery = await context.context.afterReceivingComplete(scope) + if (eventRecovery) { + receivingProof.eventRecovery = eventRecovery + const afterRecovery = await captureReceivingEvidence(context, scope, 'AFTER_EVENT_RECOVERY', receivingProof) + const unchanged = eventRecovery.restored === true && receivingEventRecoveryInventoryUnchanged(scope, + receivingProof.stages.AFTER_FRESH_KEY_REJECTION.evidence, afterRecovery) + receivingProof.eventRecoveryInventoryUnchanged = unchanged + context.assert(MODULE, '原卸柜事件DEAD恢复后库存位置托盘组成及不可变流水零漂移', null, unchanged, + `bookingId=${sourceContainerBookingId}`, OPTIONS) + if (!unchanged) throw new Error('卸柜事件恢复后库存证据变化或服务恢复未验证') + } + } + const after = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, token, {}) context.addStep(MODULE, '完成后current返回404正常空态', after, notFound(after), trace(after), OPTIONS) context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null if (context.createdResources.phase4OverseasPda.finalStatus === 'COMPLETED') { @@ -925,8 +1173,8 @@ export async function run(context) { } context.context.phase4OverseasPdaOperations = { marker: context.marker, - primary: primary ? { membershipId: primary.membershipId, username: primary.username, token: primary.token } : null, - secondary: secondary ? { membershipId: secondary.membershipId, username: secondary.username, token: secondary.token } : null, + primary: pickerSessionHandoff(primary), + secondary: pickerSessionHandoff(secondary), overseasWarehouseId: sharedWarehouse.id, merchantToken, completedFixtures: [], @@ -942,9 +1190,9 @@ export async function run(context) { const standardFbaProducers = (Array.isArray(matrix?.containers) ? matrix.containers : []) .filter((item) => item?.destinationType === 'FBA') .filter((item) => item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP') - .filter((item) => /-P4-(?:FBA-STANDARD|FBA-SECOND|FBA-OPERATIONS)(?:-R\d+)?:/.test(String(item?.resourceKey || ''))) + .filter((item) => /-P4-(?:FBA-STANDARD|FBA-SECOND|FBA-OPERATIONS|FBA-ROUTE)(?:-R\d+)?:/.test(String(item?.resourceKey || ''))) .sort((left, right) => String(left?.resourceKey).localeCompare(String(right?.resourceKey))) - .slice(0, 3) + .slice(0, 4) // The sealed dropship pallet proves the indivisible handover. A separate standard FBA // fixture supplies multiple cartons for reversible operations. The second standard FBA // fixture remains available for the multi-destination dispatch and delivery split proof. @@ -992,7 +1240,7 @@ export async function run(context) { } } -async function resolveSharedWarehouse(context) { +export async function resolveSharedWarehouse(context) { const path = '/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE' const response = await context.request('GET', path) const rows = records(response?.body?.data) diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs index 36eecc1db..da71efdfc 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-return-followups.mjs @@ -42,7 +42,7 @@ function add(context, name, response, passed, evidence = '') { } async function overseas(context, token, method, name, path, body = undefined) { - const response = await context.requestOverseas(method, path, body, {}, token) + const response = await context.request(method, path, body, token, {}) add(context, name, response, success(response)) return { response, data: success(response) ? response.body?.data : null } } @@ -70,7 +70,7 @@ async function loginMerchant(context) { const response = await context.request('POST', '/api/auth/login', { loginId: fixture.username, password: fixture.password, }, '') - const token = success(response) ? response.body?.data?.overseasAccessToken : null + const token = success(response) ? response.body?.data?.accessToken : null add(context, '客户登录并取得海外访问令牌', response, nonBlank(token), `tokenPresent=${nonBlank(token)}`) return nonBlank(token) ? token : null } @@ -136,7 +136,7 @@ export async function run(context) { const form = new FormData() form.append('file', new Blob([new TextEncoder().encode(`%PDF-1.4\n${context.marker}\n%%EOF`)], { type: 'application/pdf' }), `${suffix}-return-relabel.pdf`) - const uploaded = await context.requestOverseasMultipart('POST', + const uploaded = await context.requestMultipart('POST', `/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}/files?scope=CARTON_TOTAL`, form, merchantToken) add(context, '客户编辑退货换标总资料', uploaded, success(uploaded)) @@ -150,7 +150,7 @@ export async function run(context) { } } - const outboundCandidates = await overseas(context, context.overseasToken, 'GET', '平台查询本轮退货出库候选', + const outboundCandidates = await overseas(context, context.token, 'GET', '平台查询本轮退货出库候选', query('/api/platform/v1/last-leg/outbound-orders/return/candidates', { customerId, warehouseId: warehouse.id, })) @@ -165,7 +165,7 @@ export async function run(context) { returnNo, cartonMarks: [outboundCandidate.cartonMark], remark: context.marker, idempotencyKey: key(context, 'OUTBOUND'), } - const outbound = await overseas(context, context.overseasToken, 'POST', '平台创建独立退货出库单', + const outbound = await overseas(context, context.token, 'POST', '平台创建独立退货出库单', query('/api/platform/v1/last-leg/outbound-orders/return', { customerId }), outboundBody) const taskNo = id(outbound.data?.taskNo) const outboundNo = id(outbound.data?.outboundNo) @@ -173,28 +173,10 @@ export async function run(context) { nonBlank(taskNo) && nonBlank(outboundNo) && outbound.data?.status === 'PENDING', `outboundNo=${outboundNo || 'missing'}, taskNo=${taskNo || 'missing'}`, OPTIONS) if (taskNo) { - const task = await overseas(context, picker.token, 'GET', 'PDA读取退货出库任务明细', - `/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}`) - const taskItem = task.data?.items?.find?.(row => String(row?.cartonMarks || '').includes(outboundCandidate.cartonMark)) - || task.data?.items?.[0] - const pickBody = { outboundPalletItemId: taskItem?.outboundPalletItemId, - inventoryPositionId: null, packages: 1, cartonMarks: [outboundCandidate.cartonMark], - idempotencyKey: key(context, 'OUTBOUND-PICK') } - const picked = await overseas(context, picker.token, 'POST', 'PDA一次扫描退货箱完成拣货', - `/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}/pick`, pickBody) - context.assert(MODULE, '退货出库拣货推进为PICKED且任务进行中', picked.response, - picked.data?.outboundNo === outboundNo && picked.data?.status === 'IN_PROGRESS', trace(picked.response), OPTIONS) - const completed = await overseas(context, picker.token, 'POST', 'PDA完成退货出库', - `/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}/complete`, - { idempotencyKey: key(context, 'OUTBOUND-COMPLETE') }) - const outboundDetail = await overseas(context, context.overseasToken, 'GET', '平台回查退货出库最终状态', - query(`/api/platform/v1/last-leg/outbound-orders/${encodeURIComponent(outboundNo)}`, { customerId })) - context.assert(MODULE, 'RETURN_OUTBOUND真实完成且箱唛只出库一次', outboundDetail.response, - completed.data?.status === 'COMPLETED' - && outboundDetail.data?.outbound?.outboundType === 'RETURN_OUTBOUND' - && outboundDetail.data?.outbound?.status === 'OUTBOUNDED' - && outboundDetail.data?.pallets?.some?.(row => row.cartonMarks === outboundCandidate.cartonMark - && Number(row.pickedPackageCount) === 1), trace(outboundDetail.response), OPTIONS) + await verifyReturnOutbound(context, merchantToken, picker.token, { + taskNo, outboundNo, returnNo, warehouseId: warehouse.id, cartonMark: outboundCandidate.cartonMark, + action: 'OUTBOUND', + }) context.createdResources.phase4OverseasReturnReceipt.returnOutboundNo = outboundNo } } @@ -220,7 +202,7 @@ export async function outboundRelabelledReturn(context, merchantToken, pdaToken, context.assert(MODULE, name, null, Boolean(value), `relabelNo=${relabelNo}`, OPTIONS) if (!value) throw new Error(name) } - const candidates = await command(context.overseasToken, 'GET', '查询换标完成后的退货出库候选', + const candidates = await command(context.token, 'GET', '查询换标完成后的退货出库候选', query(`${root}/return/candidates`, { customerId, warehouseId })) const selected = [] for (const row of (Array.isArray(candidates) ? candidates : []).filter(row => row.returnNo === returnNo)) { @@ -236,33 +218,148 @@ export async function outboundRelabelledReturn(context, merchantToken, pdaToken, const mark = selected[0].cartonMark const body = { recipient: `${context.marker} RELABELLED RETURN`, overseasWarehouseId: warehouseId, returnNo, cartonMarks: [mark], remark: context.marker, idempotencyKey: key(context, 'RELABELLED-OUTBOUND') } - const task = await command(context.overseasToken, 'POST', '创建本轮已换标库存出库', query(`${root}/return`, { customerId }), body) - const replay = await command(context.overseasToken, 'POST', '已换标库存出库创建原键重放', query(`${root}/return`, { customerId }), body) + const task = await command(context.token, 'POST', '创建本轮已换标库存出库', query(`${root}/return`, { customerId }), body) + const replay = await command(context.token, 'POST', '已换标库存出库创建原键重放', query(`${root}/return`, { customerId }), body) require('出库重放保持同一任务和业务单', nonBlank(task.taskNo) && nonBlank(task.outboundNo) && replay.taskNo === task.taskNo && replay.outboundNo === task.outboundNo) - const path = `/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(task.taskNo)}` - const detail = await command(pdaToken, 'GET', 'PDA核对已换标库存出库任务', path) - const frozen = await command(context.overseasToken, 'GET', '平台核对本轮冻结出库箱号', - query(`${root}/${encodeURIComponent(task.outboundNo)}`, { customerId })) - const items = detail.items || [] - require('出库任务只包含本轮已换标箱', detail.task?.outboundNo === task.outboundNo - && items.length === 1 && Number(items[0].packageCount) === 1 - && frozen.outbound?.outboundNo === task.outboundNo && frozen.pallets?.length === 1 - && frozen.pallets[0].cartonMarks === mark - && id(frozen.pallets[0].id) === id(items[0].outboundPalletItemId)) - await command(pdaToken, 'POST', 'PDA拣取已换标库存', `${path}/pick`, { - outboundPalletItemId: items[0].outboundPalletItemId, inventoryPositionId: null, packages: 1, - cartonMarks: [mark], idempotencyKey: key(context, 'RELABELLED-PICK'), + await verifyReturnOutbound(context, merchantToken, pdaToken, { + taskNo: task.taskNo, outboundNo: task.outboundNo, returnNo, warehouseId, cartonMark: mark, + action: 'RELABELLED', }) - const completed = await command(pdaToken, 'POST', 'PDA完成已换标库存出库', `${path}/complete`, { idempotencyKey: key(context, 'RELABELLED-COMPLETE') }) - const outbound = await command(context.overseasToken, 'GET', '平台回查已换标出库终态', query(`${root}/${encodeURIComponent(task.outboundNo)}`, { customerId })) - const after = await command(merchantToken, 'GET', '客户回查已换标库存出库终态', `${inventory}/${encodeURIComponent(mark)}`) - require('已换标库存出库完成且来源和换标关联不漂移', completed.status === 'COMPLETED' - && outbound.outbound?.status === 'OUTBOUNDED' && after.status === 'OUTBOUNDED' - && after.relabelNo === relabelNo && after.sourceReturnNo === returnNo && Number(after.totalQuantity) === 1) context.createdResources.phase4OverseasReturnReceipt.relabelOutboundNo = task.outboundNo } +// Compare persisted business facts, retaining duplicates and ignoring only response ordering. +function facts(value) { + if (Array.isArray(value)) return JSON.stringify(value.map(facts).sort()) + if (value && typeof value === 'object') return JSON.stringify(Object.fromEntries( + Object.keys(value).sort().map(name => [name, facts(value[name])]))) + return value +} + +export async function verifyReturnOutbound(context, merchantToken, pdaToken, { + taskNo, outboundNo, returnNo, warehouseId, cartonMark, action, +}) { + const customerId = id(context.context.merchantQualificationFixture.tenantId) + const path = `/api/pda/v1/last-leg/outbound-tasks/${encodeURIComponent(taskNo)}` + const platform = '/api/platform/v1/last-leg/return-inventory' + const outboundPath = query(`/api/platform/v1/last-leg/outbound-orders/${encodeURIComponent(outboundNo)}`, { customerId }) + const require = (name, passed, evidence = '') => { + context.assert(MODULE, name, null, Boolean(passed), `outboundNo=${outboundNo}; ${evidence}`, OPTIONS) + if (!passed) throw new Error(name) + } + const command = async (token, method, name, url, body) => { + const result = await overseas(context, token, method, name, url, body) + if (!success(result.response)) throw new Error(`${name}: ${trace(result.response)}`) + return result.data + } + const pageAll = async (url) => { + const rows = [] + let expectedTotal + for (let page = 1; page <= 100; page += 1) { + const data = await command(context.token, 'GET', '读取本轮客户仓库退货记账事实', + query(url, { customerId, overseasWarehouseId: warehouseId, page, size: 200 })) + const total = Number(data?.total) + require('退货记账分页完整且作用域明确', Array.isArray(data?.records) && Number.isSafeInteger(total) + && total >= 0 && (expectedTotal === undefined || expectedTotal === total) + && data.records.every(row => id(row.customerId) === customerId && id(row.overseasWarehouseId) === warehouseId)) + expectedTotal = total + rows.push(...data.records) + require('退货记账分页无溢出或空页截断', rows.length <= total && (rows.length === total || data.records.length > 0)) + if (rows.length === total) return rows + } + throw new Error('退货记账分页超过安全上限,不能以部分结果验收') + } + // Only these three stock counters are normalized; IDs and other string facts stay exact. + const normalizeBalances = rows => rows.map(row => { + const normalized = { ...row } + for (const field of ['totalQuantity', 'availableQuantity', 'cartonQuantity']) { + const value = row[field] + require(`库存${field}必须为非负安全整数`, + (typeof value === 'number' || (typeof value === 'string' && /^(0|[1-9][0-9]*)$/.test(value))) + && Number.isSafeInteger(Number(value)) && Number(value) >= 0) + normalized[field] = Number(value) + } + return normalized + }) + const snapshot = async () => ({ + balances: normalizeBalances(await pageAll(platform)), + movements: await pageAll(`${platform}/movements`), + carton: await command(merchantToken, 'GET', '客户回查退货历史箱明细', `${RETURN_INVENTORY}/cartons/${encodeURIComponent(cartonMark)}`), + task: await command(pdaToken, 'GET', 'PDA回查退货出库事实', path), + outbound: await command(context.token, 'GET', '平台回查退货出库事实', outboundPath), + }) + const stockFacts = value => facts({ balances: value.balances, movements: value.movements, carton: value.carton }) + const before = await snapshot() + const carton = before.carton + require('出库箱历史明细归属及SKU数量有效', carton?.cartonMark === cartonMark && carton.customerId === customerId + && carton.overseasWarehouseId === warehouseId && carton.sourceReturnNo === returnNo && carton.status === 'AVAILABLE' + && carton.items?.length > 0 && new Set(carton.items.map(row => row.skuCode)).size === carton.items.length + && Number(carton.skuTypes) === carton.items.length + && carton.items.every(row => nonBlank(row.skuCode) && Number.isSafeInteger(Number(row.quantity)) && Number(row.quantity) > 0) + && carton.items.reduce((sum, row) => sum + Number(row.quantity), 0) === Number(carton.totalQuantity)) + const items = before.task?.items || [] + const item = items[0] + const position = item?.positions?.[0] + const frozen = before.outbound + require('PDA必须返回真实退货箱扫描位置且不得回退首行或伪造字段', items.length === 1 + && before.task.task?.taskNo === taskNo && before.task.task?.outboundNo === outboundNo + && item.positions?.length === 1 && nonBlank(id(item.outboundPalletItemId)) + && item.inboundCode === returnNo && Number(item.packageCount) === 1 && Number(item.pickedPackageCount) === 0 + && position.cartonMark === cartonMark && position.inboundCode === returnNo + && (position.inventoryPositionId === null || position.inventoryPositionId === undefined) && Number(position.availablePackages) === 1 && Number(position.pickedPackages) === 0 + && frozen.outbound?.outboundNo === outboundNo && frozen.outbound?.outboundType === 'RETURN_OUTBOUND' + && frozen.pallets?.length === 1 && frozen.pallets[0].cartonMarks === cartonMark + && id(frozen.pallets[0].id) === id(item.outboundPalletItemId)) + require('新出库单尚无OUTBOUND流水', !before.movements.some(row => row.movementType === 'OUTBOUND' && row.referenceNo === outboundNo)) + const quantities = new Map(carton.items.map(row => [row.skuCode, Number(row.quantity)])) + require('待出库SKU库存唯一且足够', new Set(before.balances.map(row => row.skuCode)).size === before.balances.length + && [...quantities].every(([sku, quantity]) => before.balances.some(row => row.skuCode === sku + && Number(row.totalQuantity) >= quantity && Number(row.availableQuantity) >= quantity && Number(row.cartonQuantity) >= 1))) + const pickBody = { outboundPalletItemId: item.outboundPalletItemId, inventoryPositionId: null, + packages: 1, cartonMarks: [position.cartonMark], idempotencyKey: key(context, `${action}-PICK`) } + await command(pdaToken, 'POST', 'PDA扫描真实退货箱', `${path}/pick`, pickBody) + const picked = await snapshot() + const expectedBalances = before.balances.map(row => { + const quantity = quantities.get(row.skuCode) + return quantity === undefined ? row : { ...row, totalQuantity: Number(row.totalQuantity) - quantity, + availableQuantity: Number(row.availableQuantity) - quantity, cartonQuantity: Number(row.cartonQuantity) - 1 } + }) + const withoutTime = rows => rows.map(({ updatedAt, ...row }) => row) + require('逐SKU扣减实际件数与一个可用箱且其他SKU不变', facts(withoutTime(picked.balances)) === facts(withoutTime(expectedBalances)), + JSON.stringify({ before: before.balances, after: picked.balances, quantities: [...quantities] })) + const outboundMovements = picked.movements.filter(row => row.movementType === 'OUTBOUND' && row.referenceNo === outboundNo) + require('每个出库SKU只有一条等量负OUTBOUND流水且历史流水不变', outboundMovements.length === quantities.size + && [...quantities].every(([sku, quantity]) => outboundMovements.filter(row => row.skuCode === sku + && Number(row.totalDelta) === -quantity && Number(row.availableDelta) === -quantity).length === 1) + && facts(picked.movements.filter(row => !(row.movementType === 'OUTBOUND' && row.referenceNo === outboundNo))) === facts(before.movements), + JSON.stringify(outboundMovements)) + require('出库后历史箱明细与来源保留', facts(picked.carton) === facts({ ...carton, status: 'OUTBOUNDED' })) + require('拣货任务和单箱进度持久化', picked.task.task?.status === 'IN_PROGRESS' + && picked.task.items?.length === 1 && picked.task.items[0].status === 'PICKED' + && Number(picked.task.items[0].pickedPackageCount) === 1 + && Number(picked.task.items[0].positions?.[0]?.availablePackages) === 0) + await command(pdaToken, 'POST', '退货拣货同键同载荷重放', `${path}/pick`, pickBody) + require('同键重放后全部记账与任务事实不变', facts(await snapshot()) === facts(picked)) + for (const [label, body] of [ + ['同键错误箱唛', { ...pickBody, cartonMarks: [`${cartonMark}-WRONG`] }], + ['同键错误件数', { ...pickBody, packages: 2 }], + ['新键重复拣货', { ...pickBody, idempotencyKey: key(context, `${action}-PICK-AGAIN`) }], + ]) { + const rejected = await context.request('POST', `${path}/pick`, body, pdaToken, {}) + add(context, `${label}拒绝`, rejected, conflict(rejected)) + require(`${label}必须返回带诊断的冲突`, conflict(rejected), trace(rejected)) + require(`${label}后全部事实不变`, facts(await snapshot()) === facts(picked)) + } + const completeBody = { idempotencyKey: key(context, `${action}-COMPLETE`) } + await command(pdaToken, 'POST', 'PDA完成退货出库', `${path}/complete`, completeBody) + const completed = await snapshot() + require('完成退货出库不二次扣库存且历史箱明细流水保留', stockFacts(completed) === stockFacts(picked) + && completed.task.task?.status === 'COMPLETED' && completed.outbound.outbound?.status === 'OUTBOUNDED') + await command(pdaToken, 'POST', '退货出库完成同键重放', `${path}/complete`, completeBody) + require('完成重放后全部事实不变', facts(await snapshot()) === facts(completed)) +} + export async function completeReturnRelabel(context, merchantToken, pdaToken, relabelNo, returnNo) { const platform = `/api/platform/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}` const merchant = `/api/merchant/v1/last-leg/relabel-applications/${encodeURIComponent(relabelNo)}` @@ -277,7 +374,7 @@ export async function completeReturnRelabel(context, merchantToken, pdaToken, re context.assert(MODULE, name, null, Boolean(passed), `relabelNo=${relabelNo}`, OPTIONS) if (!passed) throw new Error(name) } - const reviewed = await command(context.overseasToken, 'PUT', '审核本轮退货换标申请', `${platform}/status`, + const reviewed = await command(context.token, 'PUT', '审核本轮退货换标申请', `${platform}/status`, { status: 'PENDING_PROCESSING', remark: context.marker }) require('换标审核后等待PDA作业', reviewed.application?.relabelNo === relabelNo && reviewed.application.status === 'PENDING_PROCESSING') let task = await command(pdaToken, 'POST', 'PDA认领本轮退货换标', `${work}/claim`, { idempotencyKey: key(context, 'RETURN-CLAIM') }) @@ -311,7 +408,7 @@ export async function completeReturnRelabel(context, merchantToken, pdaToken, re task = await command(pdaToken, 'GET', '组托后回查换标任务版本', work) const form = new FormData() form.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), 'synthetic-return-relabel.png') - const photo = await context.requestOverseasMultipart('POST', `${work}/photos/files`, form, pdaToken) + const photo = await context.requestMultipart('POST', `${work}/photos/files`, form, pdaToken) add(context, '上传合成换标完工照片', photo, success(photo)) if (!success(photo) || !photo.body.data?.fileId) throw new Error('换标完工照片失败') task = await command(pdaToken, 'POST', 'PDA提交退货换标完工', `${work}/complete`, { diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs index e6a42f051..0d5799df5 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs @@ -3,16 +3,16 @@ import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import { validationFailure, inventorySnapshot } from './phase4-overseas-return-receipt.mjs' import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' -import { completeReturnRelabel, outboundRelabelledReturn } from './phase4-overseas-return-followups.mjs' +import { completeReturnRelabel, outboundRelabelledReturn, verifyReturnOutbound } from './phase4-overseas-return-followups.mjs' const moduleUrl = new URL('./phase4-overseas-return-receipt.mjs', import.meta.url) const acceptanceUrl = new URL('../admin-test-env-acceptance.mjs', import.meta.url) test('relabelled outbound refuses another relabel application before creating an outbound', async () => { const calls = [] - const context = { marker: 'fixture', overseasToken: 'admin', context: { merchantQualificationFixture: { tenantId: '123' } }, + const context = { marker: 'fixture', token: 'admin', context: { merchantQualificationFixture: { tenantId: '123' } }, addStep() {}, assert() {}, - async requestOverseas(method, path) { + async request(method,path) { calls.push({ method, path }) return { http: 200, body: { code: '200', data: path.includes('/candidates') ? [{ returnNo: 'RT-1', cartonMark: 'BOX' }] : { relabelNo: 'RL-OTHER' } } } @@ -21,25 +21,142 @@ test('relabelled outbound refuses another relabel application before creating an assert.ok(calls.every(call => call.method === 'GET')) }) -test('return outbound binds platform frozen carton to PDA item without requiring ordinary inventory positions', async () => { - const context = { marker: 'fixture', overseasToken: 'admin', context: { merchantQualificationFixture: { tenantId: '123' } }, +function outboundHarness(fault = '', quantity = 5, multiSku = false, wire = true) { + const calls = [] + let picked = false, completed = false, duplicate = false, drift = false + const context = { marker: 'fixture', token: 'admin', context: { merchantQualificationFixture: { tenantId: '123' } }, createdResources: { phase4OverseasReturnReceipt: {} }, addStep() {}, assert(_m, name, _r, passed) { assert.ok(passed, name) }, - async requestOverseas(method, path, body) { + async request(method, rawPath, body, token) { + calls.push({ method, path: rawPath, body, token }) + const url = new URL(rawPath, 'https://offline.invalid'), path = url.pathname let data - if (path.includes('/candidates')) data = [{ returnNo: 'RT-1', cartonMark: 'BOX' }] - else if (path.includes('/return-inventory/cartons/')) data = { cartonMark: 'BOX', customerId: '123', - overseasWarehouseId: '42', sourceReturnNo: 'RT-1', relabelNo: 'RL-1', relabelStatus: 'COMPLETED', - status: this.done ? 'OUTBOUNDED' : 'AVAILABLE', totalQuantity: 1, items: [{ skuCode: 'SKU', quantity: 1 }] } - else if (path.includes('/outbound-orders/return?')) data = { taskNo: 'TASK', outboundNo: 'OUT' } - else if (path.endsWith('/complete')) { this.done = true; data = { status: 'COMPLETED' } } - else if (path.endsWith('/pick')) { - assert.deepEqual(body.cartonMarks, ['BOX']); assert.equal(body.outboundPalletItemId, '9') + if (path.endsWith('/pick')) { + if (!picked) { + assert.deepEqual(body.cartonMarks, ['BOX']); assert.equal(body.outboundPalletItemId, '9') + assert.equal(body.inventoryPositionId, null); assert.equal(body.packages, 1); picked = true + } else if (body.packages !== 1 || body.cartonMarks[0] !== 'BOX' || body.idempotencyKey.endsWith('AGAIN')) { + if (fault === 'rejected write') drift = true + return { http: 200, body: { code: fault === 'accept wrong payload' ? '200' : 'A0409' }, + requestId: 'request', traceId: fault === 'missing diagnostics' ? '' : 'trace' } + } else if (fault === 'replay deducts') duplicate = true data = { status: 'IN_PROGRESS' } - } else if (path.includes('/outbound-orders/OUT?')) data = { outbound: { outboundNo: 'OUT', status: this.done ? 'OUTBOUNDED' : 'WAIT_PICK' }, pallets: [{ id: '9', cartonMarks: 'BOX' }] } - else data = { task: { outboundNo: 'OUT' }, items: [{ outboundPalletItemId: '9', packageCount: 1, positions: [] }] } + } else if (path.endsWith('/complete')) { completed = true; data = { status: 'COMPLETED' } } + else if (path.endsWith('/candidates')) data = [{ returnNo: 'RT-1', cartonMark: 'BOX' }] + else if (path.endsWith('/outbound-orders/return')) data = { taskNo: 'TASK', outboundNo: 'OUT' } + else if (path.endsWith('/return-inventory')) { + assert.equal(url.searchParams.get('customerId'), '123'); assert.equal(url.searchParams.get('overseasWarehouseId'), '42') + let deducted = picked ? (fault === 'package count deduction' ? 1 : quantity) : 0 + if (duplicate || (completed && fault === 'complete deducts')) deducted += quantity + const stock = { customerId: fault === 'wrong scope' ? 'other' : '123', overseasWarehouseId: '42', skuCode: 'SKU', + totalQuantity: quantity - deducted, availableQuantity: quantity - deducted, + cartonQuantity: picked && fault !== 'carton not deducted' ? 0 : 1, updatedAt: picked ? 'after' : 'before' } + const unrelated = { ...stock, skuCode: 'OTHER', totalQuantity: drift ? 8 : 9, availableQuantity: 9, cartonQuantity: 1 } + data = { total: fault === 'truncated page' ? 3 : 2, records: url.searchParams.get('page') === '1' ? [stock] + : url.searchParams.get('page') === '2' ? [unrelated] : [] } + } else if (path.endsWith('/movements')) { + const receipt = { customerId: '123', overseasWarehouseId: '42', skuCode: 'SKU', movementType: 'RECEIPT', referenceNo: 'RECEIPT', totalDelta: quantity, availableDelta: quantity, occurredAt: 'before' } + const movement = { ...receipt, movementType: 'OUTBOUND', referenceNo: 'OUT', totalDelta: -quantity, + availableDelta: fault === 'wrong movement quantity' ? -1 : -quantity, occurredAt: 'after' } + const rows = [receipt, ...(picked && fault !== 'missing movement' ? [movement] : [])] + if (picked && fault === 'duplicate movement') rows.push(movement) + data = { records: rows.reverse(), total: rows.length } + } else if (path.includes('/return-inventory/cartons/')) data = { cartonMark: 'BOX', customerId: '123', + overseasWarehouseId: '42', sourceReturnNo: 'RT-1', relabelNo: 'RL-1', relabelStatus: 'COMPLETED', + status: picked ? 'OUTBOUNDED' : 'AVAILABLE', skuTypes: 1, totalQuantity: quantity, + receivedAt: picked && fault === 'history time drift' ? '2026-10-02T17:54:38.305' : '2026-10-02T17:52:56.794', + items: [{ skuCode: 'SKU', quantity: picked && fault === 'history erased' ? 0 : quantity }] } + else if (path.endsWith('/outbound-orders/OUT')) data = { outbound: { outboundNo: 'OUT', outboundType: 'RETURN_OUTBOUND', status: completed ? 'OUTBOUNDED' : 'WAIT_PICK' }, pallets: [{ id: '9', cartonMarks: 'BOX', pickedPackageCount: picked ? 1 : 0 }] } + else if (path.endsWith('/outbound-tasks/TASK')) data = { task: { taskNo: 'TASK', outboundNo: 'OUT', status: completed ? 'COMPLETED' : picked ? 'IN_PROGRESS' : 'PENDING' }, + items: [{ outboundPalletItemId: '9', inboundCode: 'RT-1', packageCount: 1, pickedPackageCount: picked ? 1 : 0, + status: picked ? 'PICKED' : 'PENDING', positions: fault === 'missing positions' ? [] : [{ inventoryPositionId: null, + cartonMark: fault === 'wrong PDA carton' ? 'OTHER' : 'BOX', inboundCode: 'RT-1', availablePackages: picked ? 0 : 1, pickedPackages: picked ? 1 : 0 }] }] } + else throw new Error(`unexpected request: ${method} ${rawPath}`) + if (multiSku && path.endsWith('/return-inventory')) { + data.total += 1 + if (url.searchParams.get('page') === '2') data.records.push({ customerId: '123', overseasWarehouseId: '42', + skuCode: 'SKU-B', totalQuantity: picked ? 0 : 2, availableQuantity: picked ? 0 : 2, cartonQuantity: picked ? 0 : 1 }) + } + if (multiSku && path.includes('/return-inventory/cartons/')) { + data.skuTypes = 2; data.totalQuantity += 2; data.items.push({ skuCode: 'SKU-B', quantity: 2 }) + } + if (multiSku && picked && path.endsWith('/movements') && fault !== 'second SKU movement missing') { + data.records.push({ customerId: '123', overseasWarehouseId: '42', skuCode: 'SKU-B', movementType: 'OUTBOUND', + referenceNo: 'OUT', totalDelta: -2, availableDelta: -2, occurredAt: 'after' }); data.total += 1 + } + if (wire) { + if ('total' in data) data.total = String(data.total) + if (path.endsWith('/return-inventory')) for (const row of data.records) { + for (const field of ['totalQuantity', 'availableQuantity', 'cartonQuantity']) row[field] = String(row[field]) + } + if (path.endsWith('/movements')) for (const row of data.records) { + row.totalDelta = String(row.totalDelta); row.availableDelta = String(row.availableDelta) + } + if (path.includes('/return-inventory/cartons/')) { + data.totalQuantity = String(data.totalQuantity) + for (const row of data.items) row.quantity = String(row.quantity) + } + if (path.endsWith('/outbound-tasks/TASK')) for (const item of data.items) for (const position of item.positions) { + delete position.inventoryPositionId + position.availablePackages = String(position.availablePackages); position.pickedPackages = String(position.pickedPackages) + } + } + if (path.endsWith('/return-inventory') && fault && typeof fault === 'object' && data.records.length) { + data.records[0][fault.field] = fault.value + } + if (path.endsWith('/outbound-tasks/TASK') && fault === 'nonempty position') data.items[0].positions[0].inventoryPositionId = '9007199254740993' + if (path.endsWith('/return-inventory') && picked && fault === 'identifier drift') { + for (const row of data.records) row.customerId = 123 + } return { http: 200, body: { code: '200', data } } } } + return { context, calls } +} +const outboundInput = { taskNo: 'TASK', outboundNo: 'OUT', returnNo: 'RT-1', warehouseId: '42', cartonMark: 'BOX', action: 'OUTBOUND' } + +test('return outbound proves one box five units accounting history replay and completion using current DTOs', async () => { + const { context, calls } = outboundHarness() + await verifyReturnOutbound(context, 'merchant', 'pda', outboundInput) + assert.equal(calls.filter(c => c.path.endsWith('/pick')).length, 5) + assert.equal(calls.filter(c => c.path.endsWith('/complete')).length, 2) + assert.ok(calls.some(c => c.path.includes('page=2'))) + assert.ok(calls.filter(c => c.method === 'POST').every(c => c.token === 'pda')) +}) + +for (const fault of ['package count deduction', 'carton not deducted', 'missing movement', 'duplicate movement', + 'wrong movement quantity', 'history erased', 'replay deducts', 'rejected write', 'accept wrong payload', + 'missing diagnostics', 'complete deducts', 'wrong scope', 'truncated page', 'missing positions', 'wrong PDA carton', + 'history time drift', 'nonempty position', 'identifier drift']) { + test(`return outbound fails closed on ${fault}`, async () => { + const { context, calls } = outboundHarness(fault) + await assert.rejects(verifyReturnOutbound(context, 'merchant', 'pda', outboundInput)) + if (['wrong scope', 'truncated page', 'missing positions', 'wrong PDA carton', 'nonempty position'].includes(fault)) assert.ok(calls.every(c => c.method === 'GET')) + if (fault !== 'complete deducts') assert.ok(!calls.some(c => c.path.endsWith('/complete'))) + }) +} + +test('explicit null positions and numeric counters remain accepted', async () => { + await verifyReturnOutbound(outboundHarness('', 5, false, false).context, 'merchant', 'pda', outboundInput) +}) + +for (const field of ['totalQuantity', 'availableQuantity', 'cartonQuantity']) { + test(`stock ${field} rejects malformed or unsafe integer values before picking`, async () => { + for (const value of ['', ' ', '1.5', '1e2', '0x10', '-1', '01', '9007199254740993', null, undefined, true, 1.5, -1, NaN, Infinity, 9007199254740992]) { + const { context, calls } = outboundHarness({ field, value }) + await assert.rejects(verifyReturnOutbound(context, 'merchant', 'pda', outboundInput), /必须为非负安全整数/) + assert.ok(calls.every(call => call.method === 'GET')) + } + }) +} + +test('mixed SKU carton verifies each SKU quantity and one carton deduction per SKU', async () => { + await verifyReturnOutbound(outboundHarness('', 5, true).context, 'merchant', 'pda', outboundInput) + await assert.rejects(verifyReturnOutbound(outboundHarness('second SKU movement missing', 5, true).context, + 'merchant', 'pda', outboundInput), /每个出库SKU只有一条/) +}) + +test('relabelled outbound shares accounting and strict PDA scan checks', async () => { + const { context } = outboundHarness('', 1) await outboundRelabelledReturn(context, 'merchant', 'pda', 'RL-1', 'RT-1', '42') assert.equal(context.createdResources.phase4OverseasReturnReceipt.relabelOutboundNo, 'OUT') }) @@ -47,9 +164,9 @@ test('return outbound binds platform frozen carton to PDA item without requiring test('return relabel lifecycle requires review, quantities, binding, photo and customer confirmation', async () => { const calls = [] const task = { relabelNo: 'RL-1', version: 1, items: [{ itemId: '1', cartonMark: 'BOX', operationType: 'RETURN_BULK_SKU_LABEL', expectedPackages: 1 }] } - const context = { marker: 'fixture', overseasToken: 'admin', createdResources: { phase4OverseasReturnReceipt: {} }, + const context = { marker: 'fixture', token: 'admin', createdResources: { phase4OverseasReturnReceipt: {} }, addStep() {}, assert(_m, name, _r, passed) { assert.ok(passed, name) }, - async requestOverseas(method, path, body, _headers, token) { + async request(method,path,body,token,_headers) { calls.push({ method, path, body, token }) let data = task if (path.endsWith('/status')) data = { application: { relabelNo: 'RL-1', status: 'PENDING_PROCESSING' } } @@ -68,7 +185,7 @@ test('return relabel lifecycle requires review, quantities, binding, photo and c if (path.endsWith('/return-orders/RT-1')) data = { order: { status: 'COMPLETED', relabelNo: 'RL-1', relabelStatus: 'COMPLETED' } } return { http: 200, body: { code: '200', data } } }, - async requestOverseasMultipart(method, path, form, token) { + async requestMultipart(method, path, form, token) { assert.equal(token, 'pda'); assert.ok(form instanceof FormData) calls.push({ method, path }) return { http: 200, body: { code: '200', data: { fileId: '7' } } } @@ -101,7 +218,7 @@ test('return receipt acceptance covers both lookup paths, partial/final receipt assert.match(source, /palletCode: cartonA/) assert.match(source, /最后一批缺少打托照片拒绝且库存不变/) assert.match(source, /photos\/files/) - assert.match(source, /requestOverseasMultipart\('POST',[^\n]+photoForm, picker\.token\)/) + assert.match(source, /requestMultipart\('POST',[^\n]+photoForm, picker\.token\)/) assert.doesNotMatch(source, /photoForm, \{\}, picker\.token/) assert.match(source, /不存在SKU不是业务错误/) assert.match(source, /已完成退货单不再进入待签收流程/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs b/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs index 3fbdfa1a1..016f68832 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-return-receipt.mjs @@ -55,10 +55,10 @@ function add(context, name, response, passed, evidence = '') { } async function overseas(context, token, method, name, path, body = undefined) { - let response = await context.requestOverseas(method, path, body, {}, token) + let response = await context.request(method, path, body, token, {}) for (let attempt = 1; attempt < 3 && response?.http === 0; attempt += 1) { await new Promise((resolve) => setTimeout(resolve, 250 * attempt)) - response = await context.requestOverseas(method, path, body, {}, token) + response = await context.request(method, path, body, token, {}) } add(context, name, response, success(response)) return { response, data: success(response) ? response.body?.data : null } @@ -87,7 +87,7 @@ async function loginMerchant(context) { const response = await context.request('POST', '/api/auth/login', { loginId: fixture.username, password: fixture.password, }, '') - const token = success(response) ? response.body?.data?.overseasAccessToken : null + const token = success(response) ? response.body?.data?.accessToken : null add(context, '客户登录并取得海外访问令牌', response, nonBlank(token), `tokenPresent=${nonBlank(token)}`) return nonBlank(token) ? token : null } @@ -107,8 +107,7 @@ export async function approveReturnFixture(context, merchantToken, created) { if (!success(owned.response) || owned.data?.order?.returnNo !== order.returnNo || owned.data?.order?.status !== 'PENDING_REVIEW') throw new Error('客户不可读取当前待审核退货单') const path = `/api/platform/v1/last-leg/return-orders/${encodeURIComponent(order.returnNo)}/review` - const response = await context.requestOverseas('POST', path, - { decision: 'APPROVE', remark: `${context.marker} 退货验收审核` }, { 'X-Customer-Id': expectedCustomer }) + const response = await context.request('POST', path, { decision: 'APPROVE', remark: `${context.marker} 退货验收审核` }, undefined, { 'X-Customer-Id': expectedCustomer }) add(context, '平台审核本轮退货预报', response, success(response)) if (!success(response)) throw new Error(`退货审核失败:${trace(response)}`) const detail = await overseas(context, merchantToken, 'GET', '客户回查退货审核状态', @@ -193,8 +192,7 @@ export async function run(context) { const createReplay = await overseas(context, merchantToken, 'POST', '退货预报同键同载荷重放', RETURN_ORDERS, createBody) context.assert(MODULE, '预报幂等重放复用同一退货单', createReplay.response, id(createReplay.data?.order?.returnNo) === returnNo, trace(createReplay.response), OPTIONS) - await reject('退货预报同键异载荷返回冲突', conflict, () => context.requestOverseas('POST', RETURN_ORDERS, - { ...createBody, remark: `${context.marker}-CHANGED` }, {}, merchantToken)) + await reject('退货预报同键异载荷返回冲突', conflict, () => context.request('POST', RETURN_ORDERS, { ...createBody, remark: `${context.marker}-CHANGED` }, merchantToken, {})) await approveReturnFixture(context, merchantToken, created.data) @@ -205,8 +203,7 @@ export async function run(context) { ['小数预报数量', [{ skuCode: skuA, expectedQuantity: 1.5 }]], ['重复预报SKU', [createBody.items[0], createBody.items[0]]], ]) { - await reject(`拒绝${name}`, validationFailure, () => context.requestOverseas('POST', RETURN_ORDERS, - { ...createBody, items, idempotencyKey: key(context, `INVALID-CREATE-${encodeURIComponent(name)}`) }, {}, merchantToken)) + await reject(`拒绝${name}`, validationFailure, () => context.request('POST', RETURN_ORDERS, { ...createBody, items, idempotencyKey: key(context, `INVALID-CREATE-${encodeURIComponent(name)}`) }, merchantToken, {})) } const onlyOrder = await overseas(context, merchantToken, 'GET', '非法预报后回查本轮订单数量', query(RETURN_ORDERS, { page: 1, size: 20, skuCode: skuA })) @@ -214,8 +211,7 @@ export async function run(context) { Number(onlyOrder.data?.total) === 1 && records(onlyOrder.data)[0]?.returnNo === returnNo && records(onlyOrder.data)[0]?.status === 'PENDING_RECEIPT', trace(onlyOrder.response), OPTIONS) - await reject('不存在退货单返回可诊断未找到', notFound, () => context.requestOverseas('POST', `${PDA_RECEIPTS}/start`, - { returnNo: `MISSING-${suffix}` }, {}, picker.token)) + await reject('不存在退货单返回可诊断未找到', notFound, () => context.request('POST', `${PDA_RECEIPTS}/start`, { returnNo: `MISSING-${suffix}` }, picker.token, {})) const emptySku = await overseas(context, picker.token, 'POST', 'PDA查询不存在SKU返回正常空候选', `${PDA_RECEIPTS}/sku-candidates`, { skuCode: `MISSING-${suffix}` }) @@ -260,9 +256,9 @@ export async function run(context) { && id(firstReplay.data?.receiptBatchNo) === firstBatchNo && id(firstReplay.data?.palletCode) === cartonA, trace(firstReplay.response), OPTIONS) - await reject('签收同键异载荷返回冲突', conflict, () => context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, { + await reject('签收同键异载荷返回冲突', conflict, () => context.request('POST', `${PDA_RECEIPTS}/confirm`, { ...firstBody, trackingNo: `TRACK-${suffix}-CHANGED`, - }, {}, picker.token)) + }, picker.token, {})) await reject('超收被拒绝且不得生成费用批次', conflict, async () => { const body = { @@ -271,7 +267,7 @@ export async function run(context) { } let response = null for (let attempt = 1; attempt <= 3; attempt += 1) { - response = await context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, body, {}, picker.token) + response = await context.request('POST', `${PDA_RECEIPTS}/confirm`, body, picker.token, {}) if (response?.http !== 0 || attempt === 3) break await new Promise((resolve) => setTimeout(resolve, 400 * attempt)) } @@ -285,13 +281,12 @@ export async function run(context) { ['非法签收方式', { startedBy: 'INVALID' }, validationFailure], ['未预报SKU', { items: [{ skuCode: `UNKNOWN-${suffix}`, receivedQuantity: 1 }] }, conflict], ]) { - await reject(`拒绝${name}`, expected, () => context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, - { ...firstBody, ...changes, idempotencyKey: key(context, `INVALID-RECEIPT-${encodeURIComponent(name)}`) }, {}, picker.token)) + await reject(`拒绝${name}`, expected, () => context.request('POST', `${PDA_RECEIPTS}/confirm`, { ...firstBody, ...changes, idempotencyKey: key(context, `INVALID-RECEIPT-${encodeURIComponent(name)}`) }, picker.token, {})) } - await reject('部分签收退货单禁止合箱', conflict, () => context.requestOverseas('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge', { + await reject('部分签收退货单禁止合箱', conflict, () => context.request('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge', { returnNos: [returnNo], items: [{ skuCode: skuA, quantity: 1 }], length: 30, width: 20, height: 10, weight: 1, idempotencyKey: key(context, 'EARLY-MERGE'), - }, {}, picker.token)) + }, picker.token, {})) const afterPartial = await overseas(context, merchantToken, 'GET', '客户回查部分签收状态', `${RETURN_ORDERS}/${encodeURIComponent(returnNo)}`) @@ -310,7 +305,7 @@ export async function run(context) { await reject('最后一批缺少打托照片拒绝且库存不变', conflict, async () => { let response = null for (let attempt = 1; attempt <= 3; attempt += 1) { - response = await context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, secondBody, {}, picker.token) + response = await context.request('POST', `${PDA_RECEIPTS}/confirm`, secondBody, picker.token, {}) if (response?.http !== 0 || attempt === 3) break await new Promise((resolve) => setTimeout(resolve, 400 * attempt)) } @@ -319,7 +314,7 @@ export async function run(context) { const photoForm = new FormData() // Synthetic HTTP fixture, not a photograph of real warehouse activity. photoForm.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), 'return-receipt-test.png') - const photo = await context.requestOverseasMultipart('POST', `${PDA_RECEIPTS}/${encodeURIComponent(returnNo)}/photos/files`, photoForm, picker.token) + const photo = await context.requestMultipart('POST', `${PDA_RECEIPTS}/${encodeURIComponent(returnNo)}/photos/files`, photoForm, picker.token) add(context, '上传本轮合成打托测试照片', photo, success(photo)) if (!success(photo) || !photo.body?.data?.fileId) throw new Error('打托照片上传失败') const second = await overseas(context, picker.token, 'POST', 'PDA按单SKU完成乙2件签收', `${PDA_RECEIPTS}/confirm`, secondBody) @@ -338,7 +333,7 @@ export async function run(context) { context.createdResources.phase4OverseasReturnReceipt.cartonMarks.push(cartonA, cartonB) await reject('已完成退货单不再进入待签收流程', notFound, - () => context.requestOverseas('POST', `${PDA_RECEIPTS}/start`, { returnNo }, {}, picker.token)) + () => context.request('POST', `${PDA_RECEIPTS}/start`, { returnNo }, picker.token, {})) const completedCandidates = await overseas(context, picker.token, 'POST', '完成后SKU候选自动清空', `${PDA_RECEIPTS}/sku-candidates`, { skuCode: skuB }) context.assert(MODULE, '完成单不再出现在SKU候选', completedCandidates.response, @@ -358,13 +353,11 @@ export async function run(context) { && detailBatches.has(firstBatchNo) && detailBatches.has(secondBatchNo), `receipts=${detailReceipts.length}, billingBatches=${detailBatches.size}`, OPTIONS) - const merchantInventory = await context.requestOverseas('GET', - query(RETURN_INVENTORY, { page: 1, size: 20, overseasWarehouseId: warehouse.id, returnNo }), - undefined, {}, merchantToken) + const merchantInventory = await context.request('GET', query(RETURN_INVENTORY, { page: 1, size: 20, overseasWarehouseId: warehouse.id, returnNo }), undefined, merchantToken, {}) add(context, '客户权限可读取自身退货库存', merchantInventory, success(merchantInventory)) const customerId = id(context.context?.merchantQualificationFixture?.tenantId) - const inventory = await overseas(context, context.overseasToken, 'GET', '平台按客户和退货单回查全部退货箱库存', + const inventory = await overseas(context, context.token, 'GET', '平台按客户和退货单回查全部退货箱库存', query('/api/platform/v1/last-leg/return-inventory/cartons', { page: 1, size: 20, customerId, overseasWarehouseId: warehouse.id, returnNo, })) @@ -376,7 +369,7 @@ export async function run(context) { && sourceBatches.size === 2 && sourceBatches.has(firstBatchNo) && sourceBatches.has(secondBatchNo), `cartons=${inventoryRows.length}, quantity=${inventoryRows.reduce((sum, row) => sum + Number(row.totalQuantity || 0), 0)}`, OPTIONS) - const summary = await overseas(context, context.overseasToken, 'GET', '平台回查退货SKU汇总库存', + const summary = await overseas(context, context.token, 'GET', '平台回查退货SKU汇总库存', query('/api/platform/v1/last-leg/return-inventory', { page: 1, size: 20, customerId, overseasWarehouseId: warehouse.id, })) @@ -389,14 +382,14 @@ export async function run(context) { for (const [index, expected] of [{ cartonMark: cartonA, batchNo: firstBatchNo, quantity: 3 }, { cartonMark: cartonB, batchNo: secondBatchNo, quantity: 2 }].entries()) { - const carton = await overseas(context, context.overseasToken, 'GET', `平台回查第${index + 1}个退货箱明细`, + const carton = await overseas(context, context.token, 'GET', `平台回查第${index + 1}个退货箱明细`, `/api/platform/v1/last-leg/return-inventory/cartons/${encodeURIComponent(expected.cartonMark)}`) context.assert(MODULE, `第${index + 1}个退货箱来源费用批次和数量正确`, carton.response, carton.data?.sourceBatchNo === expected.batchNo && Number(carton.data?.totalQuantity) === expected.quantity && Array.isArray(carton.data?.items) && carton.data.items.length > 0, `batch=${carton.data?.sourceBatchNo || 'missing'}, quantity=${carton.data?.totalQuantity ?? 'missing'}`, OPTIONS) - const label = await overseas(context, context.overseasToken, 'GET', `平台生成第${index + 1}个退货箱标签`, + const label = await overseas(context, context.token, 'GET', `平台生成第${index + 1}个退货箱标签`, `/api/platform/v1/last-leg/return-inventory/cartons/${encodeURIComponent(expected.cartonMark)}/label`) context.assert(MODULE, `第${index + 1}个箱标签保留来源批次`, label.response, label.data?.cartonMark === expected.cartonMark && label.data?.sourceBatchNo === expected.batchNo @@ -454,9 +447,7 @@ export async function run(context) { context.assert(MODULE, '合箱重放复用同一新箱且不重复扣减', mergeReplay.response, success(mergeReplay.response) && nonBlank(merged.data?.cartonMark) && id(mergeReplay.data?.cartonMark) === id(merged.data?.cartonMark), trace(mergeReplay.response), OPTIONS) - await reject('合箱同键异载荷返回冲突', conflict, () => context.requestOverseas('POST', - '/api/pda/v1/last-leg/return-inventory/cartons/merge', - { ...mergeBody, weight: 3 }, {}, picker.token)) + await reject('合箱同键异载荷返回冲突', conflict, () => context.request('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge', { ...mergeBody, weight: 3 }, picker.token, {})) const beforeInvalid = await overseas(context, merchantToken, 'GET', '冻结合箱异常检查前库存快照', query(`${RETURN_INVENTORY}/cartons`, { page: 1, size: 200, returnNo })) @@ -468,8 +459,7 @@ export async function run(context) { ['超可用库存', { items: [{ skuCode: skuA, quantity: 999 }] }, conflict], ['不存在来源单', { returnNos: [`MISSING-${suffix}`] }, notFound], ]) { - await reject(`合箱拒绝${name}`, expected, () => context.requestOverseas('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge', - { ...mergeBody, ...changes, idempotencyKey: key(context, `INVALID-MERGE-${encodeURIComponent(name)}`) }, {}, picker.token)) + await reject(`合箱拒绝${name}`, expected, () => context.request('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge', { ...mergeBody, ...changes, idempotencyKey: key(context, `INVALID-MERGE-${encodeURIComponent(name)}`) }, picker.token, {})) } await overseas(context, picker.token, 'POST', '异常检查后再重放原合箱请求', '/api/pda/v1/last-leg/return-inventory/cartons/merge', mergeBody) diff --git a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost-contract.test.mjs index 6d447dff7..af1766291 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost-contract.test.mjs @@ -53,7 +53,7 @@ test('钱包闭环覆盖充值、授信、采购冻结结算和换标结算释 test('托盘汇总和拆柜成本验证总数、分摊及跨区金额守恒', () => { for (const token of [ '/api/platform/v1/pallets/summary', - '/api/platform/v1/warehouse-admin/pallets/summary', + '/api/platform/v1/overseas-warehouse-resources/pallets/summary', '/pallets/summary?page=1&size=20', 'OVERSEAS_CONTAINER', 'DESTUFF_PALLET', @@ -61,6 +61,7 @@ test('托盘汇总和拆柜成本验证总数、分摊及跨区金额守恒', () 'sourceFeeId', 'pollRequests=', ]) assert.ok(source.includes(token), token) + assert.doesNotMatch(source, /\/warehouse-admin\/pallets\/summary/) assert.match(source, /Number\(d\.totalCount\) === Number\(d\.idleCount\) \+ Number\(d\.occupiedCount\)/) assert.match(source, /allocated === money\(fact\.expectedPalletCost\)/) assert.match(source, /delivered\.reduce[\s\S]*fact\.expectedPalletCost/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs index 266d84482..9fb7d1a42 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs @@ -48,8 +48,8 @@ async function merchantLogin(context, fixture) { const response = await context.request('POST', '/api/auth/login', { loginId: fixture.username, password: fixture.password }, '') const auth = success(response) ? response.body.data : null assert(context, '客户账号同时取得国内和海外短时Token', response, - Boolean(auth?.accessToken) && Boolean(auth?.overseasAccessToken), - `customerId=${fixture.tenantId}, domestic=${Boolean(auth?.accessToken)}, overseas=${Boolean(auth?.overseasAccessToken)}`) + Boolean(auth?.accessToken) && Boolean(auth?.accessToken), + `customerId=${fixture.tenantId}, domestic=${Boolean(auth?.accessToken)}, overseas=${Boolean(auth?.accessToken)}`) return auth } @@ -96,7 +96,7 @@ async function prepareWallet(context, customerId) { return success(recharge) ? { before: before.account, funded: recharge.body.data, rechargeAmount, creditLimit } : null } -async function procurementFlow(context, fixture, overseasToken) { +async function procurementFlow(context, fixture, token) { const merchantPath = '/api/merchant/v1/last-leg/overseas-procurements' const payload = { customerName: fixture.tenantName, purchaseCategory: 'OFFICE_SUPPLIES', productName: `${context.marker} 钱包采购`, @@ -105,7 +105,7 @@ async function procurementFlow(context, fixture, overseasToken) { contactName: '全量验收联系人', contactPhone: '13800000002', remark: context.marker, idempotencyKey: `${context.marker}-wallet-procurement`.slice(0, 255), } - const created = await context.requestOverseas('POST', merchantPath, payload, {}, overseasToken) + const created = await context.request('POST', merchantPath, payload, token, {}) const detail = created?.body?.data const orderNo = detail?.summary?.orderNo assert(context, '客户创建钱包采购单', created, @@ -117,7 +117,7 @@ async function procurementFlow(context, fixture, overseasToken) { `orderNo=${orderNo}, expectedPrefix=${fixture.expectedProcurementPrefix || 'custom-customer'}`) const customerId = id(fixture.tenantId) - const approved = await context.requestOverseas('POST', query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/review`, { customerId }), { + const approved = await context.request('POST', query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/review`, { customerId }), { version: Number(detail.summary.version), decision: 'APPROVE', idempotencyKey: `${context.marker}-wallet-procurement-review`.slice(0, 255), remark: context.marker, }) @@ -128,7 +128,7 @@ async function procurementFlow(context, fixture, overseasToken) { const files = [] for (const category of ['VOUCHER', 'RECEIPT']) { - const uploaded = await context.requestOverseasMultipart('POST', + const uploaded = await context.requestMultipart('POST', query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/files/${category}`, { customerId }), { content: PNG, type: 'image/png', name: `${category.toLowerCase()}-${orderNo}.png` }) assert(context, `采购单上传${category}文件`, uploaded, @@ -138,7 +138,7 @@ async function procurementFlow(context, fixture, overseasToken) { } if (files.length !== 2) return null - const fee = await context.requestOverseas('POST', query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/fees`, { customerId }), { + const fee = await context.request('POST', query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}/fees`, { customerId }), { feeItemCode: 'PROCUREMENT_SERVICE', feeItemName: '采购服务费', amount: 0.01, voucherFileId: id(files.find((item) => item.category === 'VOUCHER')?.fileId), remark: context.marker, }) @@ -149,7 +149,7 @@ async function procurementFlow(context, fixture, overseasToken) { if (!success(fee) || !feeNo) return null const detailPath = query(`${PROCUREMENT}/${encodeURIComponent(orderNo)}`, { customerId }) - const frozen = await poll(async () => context.requestOverseas('GET', detailPath), + const frozen = await poll(async () => context.request('GET', detailPath), (response) => success(response) && response.body.data?.fees?.find((item) => item.feeNo === feeNo)?.walletStatus === 'FROZEN') const frozenFee = frozen.result?.body?.data?.fees?.find((item) => item.feeNo === feeNo) assert(context, '采购费用跨区冻结到账', frozen.result, @@ -157,13 +157,13 @@ async function procurementFlow(context, fixture, overseasToken) { if (frozenFee?.walletStatus !== 'FROZEN') return null const current = frozen.result.body.data.summary - const settledOrder = await context.requestOverseas('PATCH', detailPath, { + const settledOrder = await context.request('PATCH', detailPath, { version: Number(current.version), inboundQuantity: Number(current.purchaseQuantity), }) assert(context, '采购全量入库推进待出库并触发结算', settledOrder, success(settledOrder) && settledOrder.body.data?.summary?.status === 'PENDING_OUTBOUND', `status=${settledOrder.body?.data?.summary?.status || 'missing'}`) - const settled = await poll(async () => context.requestOverseas('GET', detailPath), + const settled = await poll(async () => context.request('GET', detailPath), (response) => success(response) && response.body.data?.fees?.find((item) => item.feeNo === feeNo)?.walletStatus === 'SETTLED') const settledFee = settled.result?.body?.data?.fees?.find((item) => item.feeNo === feeNo) assert(context, '采购费用跨区结算到账', settled.result, @@ -191,10 +191,9 @@ function relabelPayload(context, warehouseId, source, suffix, chargeAmount) { } } -async function relabelFlow(context, overseasToken, warehouseId, suffix, terminalStatus) { - const candidatesResponse = await context.requestOverseas('GET', query( - '/api/merchant/v1/last-leg/relabel-applications/inventory-candidates', { operationWarehouseId: warehouseId }), - undefined, {}, overseasToken) +async function relabelFlow(context, token, warehouseId, suffix, terminalStatus) { + const candidatesResponse = await context.request('GET', query( + '/api/merchant/v1/last-leg/relabel-applications/inventory-candidates', { operationWarehouseId: warehouseId }), undefined, token, {}) const candidates = success(candidatesResponse) && Array.isArray(candidatesResponse.body?.data) ? candidatesResponse.body.data : [] const currentPda = context.createdResources?.phase4OverseasPda @@ -229,7 +228,7 @@ async function relabelFlow(context, overseasToken, warehouseId, suffix, terminal Boolean(source?.position), `warehouseId=${warehouseId}, candidates=${candidates.length}`) if (!source?.position) return null const payload = relabelPayload(context, warehouseId, source, suffix, 0.01) - const created = await context.requestOverseas('POST', '/api/merchant/v1/last-leg/relabel-applications', payload, {}, overseasToken) + const created = await context.request('POST', '/api/merchant/v1/last-leg/relabel-applications', payload, token, {}) const relabelNo = created?.body?.data?.application?.relabelNo assert(context, `${suffix}换标单创建并携带收费金额`, created, success(created) && Boolean(relabelNo) && money(created.body.data?.application?.chargeAmount) === 0.01, @@ -237,31 +236,37 @@ async function relabelFlow(context, overseasToken, warehouseId, suffix, terminal if (!success(created) || !relabelNo) return null const detailPath = `${RELABEL}/${encodeURIComponent(relabelNo)}` - const frozen = await poll(async () => context.requestOverseas('GET', detailPath), + const frozen = await poll(async () => context.request('GET', detailPath), (response) => success(response) && response.body.data?.application?.walletStatus === 'FROZEN') assert(context, `${suffix}换标费用跨区冻结到账`, frozen.result, frozen.result?.body?.data?.application?.walletStatus === 'FROZEN', `relabelNo=${relabelNo}, pollRequests=${frozen.attempts}, walletStatus=${frozen.result?.body?.data?.application?.walletStatus || 'missing'}`) if (frozen.result?.body?.data?.application?.walletStatus !== 'FROZEN') return null + const reviewStatus = terminalStatus === 'COMPLETED' ? 'PENDING_PROCESSING' : 'CANCELLED' + const review = await context.request('PUT', query( + `/api/platform/v1/last-leg/orders/${encodeURIComponent(id(created.body.data.application.lastlegOrderId))}/relabel-review`, + { customerId: id(context.context.merchantQualificationFixture.tenantId) }), { status: reviewStatus, remark: context.marker }) + assert(context, `${suffix}尾程订单审核直接贴标申请`, review, + success(review) && review.body.data?.application?.status === reviewStatus) + if (!success(review)) return null + if (terminalStatus === 'COMPLETED') { - const processing = await context.requestOverseas('PUT', `${detailPath}/status`, { status: 'PROCESSING', remark: context.marker }) + const processing = await context.request('PUT', `${detailPath}/status`, { status: 'PROCESSING', remark: context.marker }) assert(context, '换标单冻结后才允许进入处理中', processing, success(processing) && processing.body.data?.application?.status === 'PROCESSING') - const confirmPending = await context.requestOverseas('PUT', `${detailPath}/status`, - { status: 'CUSTOMER_CONFIRM_PENDING', remark: context.marker }) + const confirmPending = await context.request('PUT', `${detailPath}/status`, { status: 'CUSTOMER_CONFIRM_PENDING', remark: context.marker }) assert(context, '换标处理完成后进入客户确认', confirmPending, success(confirmPending) && confirmPending.body.data?.application?.status === 'CUSTOMER_CONFIRM_PENDING') - const readyForOutbound = await context.requestOverseas('PUT', `${detailPath}/status`, - { status: 'READY_FOR_OUTBOUND', remark: context.marker }) + const readyForOutbound = await context.request('PUT', `${detailPath}/status`, { status: 'READY_FOR_OUTBOUND', remark: context.marker }) assert(context, '换标客户确认后进入待出库', readyForOutbound, success(readyForOutbound) && readyForOutbound.body.data?.application?.status === 'READY_FOR_OUTBOUND') } - const terminal = await context.requestOverseas('PUT', `${detailPath}/status`, { status: terminalStatus, remark: context.marker }) + const terminal = terminalStatus === 'CANCELLED' ? review : await context.request('PUT', `${detailPath}/status`, { status: terminalStatus, remark: context.marker }) assert(context, `${suffix}换标单进入${terminalStatus}`, terminal, success(terminal) && terminal.body.data?.application?.status === terminalStatus) const walletTerminal = terminalStatus === 'COMPLETED' ? 'SETTLED' : 'RELEASED' - const wallet = await poll(async () => context.requestOverseas('GET', detailPath), + const wallet = await poll(async () => context.request('GET', detailPath), (response) => success(response) && response.body.data?.application?.walletStatus === walletTerminal) assert(context, `${suffix}换标费用跨区${walletTerminal}`, wallet.result, wallet.result?.body?.data?.application?.walletStatus === walletTerminal, @@ -299,14 +304,13 @@ async function verifyPalletSummaries(context, warehouseId) { success(domestic) && summaryValid(d, ['totalCount', 'idleCount', 'occupiedCount']) && Number(d.totalCount) === Number(d.idleCount) + Number(d.occupiedCount), `total=${d?.totalCount}, idle=${d?.idleCount}, occupied=${d?.occupiedCount}`) - const overseas = await context.requestOverseas('GET', '/api/platform/v1/warehouse-admin/pallets/summary?page=1&size=20') + const overseas = await context.request('GET', '/api/platform/v1/overseas-warehouse-resources/pallets/summary?page=1&size=20') const o = overseas?.body?.data assert(context, '海外托盘汇总含闲置、占用和报废数量', overseas, success(overseas) && summaryValid(o, ['totalCount', 'idleCount', 'occupiedCount', 'scrappedCount']) && Number(o.totalCount) === Number(o.idleCount) + Number(o.occupiedCount) + Number(o.scrappedCount), `total=${o?.totalCount}, idle=${o?.idleCount}, occupied=${o?.occupiedCount}, scrapped=${o?.scrappedCount}`) - const warehouse = await context.requestOverseas('GET', - `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/summary?page=1&size=20`) + const warehouse = await context.request('GET', `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets/summary?page=1&size=20`) assert(context, '海外仓级托盘汇总可用', warehouse, success(warehouse) && summaryValid(warehouse.body.data, ['totalCount', 'idleCount', 'occupiedCount', 'scrappedCount'])) } @@ -315,7 +319,7 @@ async function verifyDestuffCosts(context, completedFacts) { const evidence = [] for (const fact of completedFacts) { const businessNo = `DESTUFF-${fact.containerBookingId}` - const lastleg = await poll(async () => context.requestOverseas('GET', query('/api/platform/v1/last-leg/finance-fee-sources', { + const lastleg = await poll(async () => context.request('GET', query('/api/platform/v1/last-leg/finance-fee-sources', { businessType: 'OVERSEAS_CONTAINER', businessNo, })), (response) => success(response) && rows(response.body.data).length > 0, 75) const sources = success(lastleg.result) ? rows(lastleg.result.body.data) : [] @@ -354,9 +358,9 @@ export async function run(context) { const auth = await merchantLogin(context, fixture) const wallet = auth ? await prepareWallet(context, id(fixture.tenantId)) : null - const procurement = wallet ? await procurementFlow(context, fixture, auth.overseasAccessToken) : null - const settledRelabel = procurement ? await relabelFlow(context, auth.overseasAccessToken, warehouseId, 'SETTLE', 'COMPLETED') : null - const releasedRelabel = settledRelabel ? await relabelFlow(context, auth.overseasAccessToken, warehouseId, 'RELEASE', 'CANCELLED') : null + const procurement = wallet ? await procurementFlow(context, fixture, auth.accessToken) : null + const settledRelabel = procurement ? await relabelFlow(context, auth.accessToken, warehouseId, 'SETTLE', 'COMPLETED') : null + const releasedRelabel = settledRelabel ? await relabelFlow(context, auth.accessToken, warehouseId, 'RELEASE', 'CANCELLED') : null const relabels = [settledRelabel, releasedRelabel].filter(Boolean) const finalAccount = procurement && relabels.length === 2 ? await verifyClientWallet(context, auth.accessToken, fixture, wallet, procurement, relabels) : null diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs index 8b889591a..05c8e17db 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs @@ -16,22 +16,15 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou const calls = []; const steps = []; const gaps = []; let createdPallet = null; let createdLocation = null const context = { context: {}, createdResources: {}, marker: 'ACC-PHASE4-TEST', calls, steps, gaps, - async request(method, requestPath, body) { - calls.push({ channel: 'domestic', method, path: requestPath, body }) - if (requestPath.startsWith('/api/platform/v1/iam/tenants?')) return response(page(tenants)) - if (method === 'GET' && requestPath.startsWith('/api/platform/v1/warehouse-admin?')) return response(page(warehouseRecords)) - if (method === 'GET' && requestPath.includes('/warehouse-admin/20001?')) return response({ warehouse: { id: '20001', customerId: '0', warehouseType: 'OVERSEAS', status: 'ACTIVE' } }) - if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001') - if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return response(null) - throw new Error(`Unexpected domestic path: ${requestPath}`) - }, - async requestOverseas(method, requestPath, body) { + async request(...args) { + const route = /\/(?:last-leg|overseas-warehouse-resources)(?:\/|\?|$)/.test(String(args[1])); + const handler = route ? async function(method,requestPath,body) { calls.push({ channel: 'overseas', method, path: requestPath, body }) - if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/locations')) { createdLocation = { id: '72002', customerId: '0', warehouseId: '20001', ...body }; return response('72002') } - if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = { ...createdLocation, ...body }; return voidResponse() } - if (method === 'DELETE' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = null; return voidResponse() } - if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/pallets')) { createdPallet = { id: '71001', customerId: '0', warehouseId: '20001', locationId: '72001', version: 0, ...body }; return response('71001') } - if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) { + if (method === 'POST' && requestPath.endsWith('/overseas-warehouse-resources/20001/locations')) { createdLocation = { id: '72002', customerId: '0', warehouseId: '20001', ...body }; return response('72002') } + if (method === 'PUT' && requestPath.endsWith('/overseas-warehouse-resources/20001/locations/72002')) { createdLocation = { ...createdLocation, ...body }; return voidResponse() } + if (method === 'DELETE' && requestPath.endsWith('/overseas-warehouse-resources/20001/locations/72002')) { createdLocation = null; return voidResponse() } + if (method === 'POST' && requestPath.endsWith('/overseas-warehouse-resources/20001/pallets')) { createdPallet = { id: '71001', customerId: '0', warehouseId: '20001', locationId: '72001', version: 0, ...body }; return response('71001') } + if (method === 'PUT' && requestPath.endsWith('/overseas-warehouse-resources/20001/pallets/71001')) { if (Number(body.version) !== Number(createdPallet.version)) return response(null, 'A0409') createdPallet = { ...createdPallet, ...body, version: createdPallet.version + 1 }; return voidResponse() } @@ -45,16 +38,27 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou if (requestPath.includes('/inventory/balances?')) return response(page([{ id: '60001', customerId: '10001', skuCode: 'SKU-1', totalPackages: 10, availablePackages: 10, updatedAt: '2026-08-21T10:00:00' }])) if (requestPath.includes('/inventory/balances/60001/positions?')) return response([{ id: '61001', balanceId: '60001', totalPackages: 10 }]) if (requestPath.includes('/inventory/ledger?')) return response(page([{ id: '62001', customerId: '10001', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-21T10:00:00' }])) - if (requestPath.endsWith('/warehouse-admin/20001/locations?page=1&size=20')) return response(page([{ id: '72001', customerId: '0', warehouseId: '20001', locationCode: 'A-01', locationType: 'STORAGE', maxLoadKg: 1000, status: 'ACTIVE' }])) - if (requestPath.endsWith('/warehouse-admin/20001/locations/72001')) return response({ id: '72001', customerId: '0', warehouseId: '20001', locationCode: 'A-01', status: 'ACTIVE' }) - if (requestPath.endsWith('/warehouse-admin/20001/locations/72002')) return createdLocation == null + if (requestPath.endsWith('/overseas-warehouse-resources/20001/locations?page=1&size=20')) return response(page([{ id: '72001', customerId: '0', warehouseId: '20001', locationCode: 'A-01', locationType: 'STORAGE', maxLoadKg: 1000, status: 'ACTIVE' }])) + if (requestPath.endsWith('/overseas-warehouse-resources/20001/locations/72001')) return response({ id: '72001', customerId: '0', warehouseId: '20001', locationCode: 'A-01', status: 'ACTIVE' }) + if (requestPath.endsWith('/overseas-warehouse-resources/20001/locations/72002')) return createdLocation == null ? response(undefined, 'A0404') : response(createdLocation) - if (requestPath.endsWith('/warehouse-admin/20001/pallets?page=1&size=20')) return response(page([{ id: '70001', customerId: '0', warehouseId: '20001', palletCode: 'PLT-1', status: 'IDLE' }])) - if (requestPath.endsWith('/warehouse-admin/20001/pallets/70001')) return response({ id: '70001', customerId: '0', warehouseId: '20001' }) - if (requestPath.endsWith('/warehouse-admin/20001/pallets/71001')) return response(createdPallet) + if (requestPath.endsWith('/overseas-warehouse-resources/20001/pallets?page=1&size=20')) return response(page([{ id: '70001', customerId: '0', warehouseId: '20001', palletCode: 'PLT-1', status: 'IDLE' }])) + if (requestPath.endsWith('/overseas-warehouse-resources/20001/pallets/70001')) return response({ id: '70001', customerId: '0', warehouseId: '20001' }) + if (requestPath.endsWith('/overseas-warehouse-resources/20001/pallets/71001')) return response(createdPallet) throw new Error(`Unexpected path: ${requestPath}`) - }, - async requestOverseasBinary(method, requestPath) { calls.push({ channel: 'overseas-binary', method, path: requestPath }); return { http: 200, bytes: Buffer.from('id,status\n'), contentType: 'text/csv', requestId: 'request-binary', traceId: 'trace-binary' } }, + } : async function(method,requestPath,body) { + calls.push({ channel: 'domestic', method, path: requestPath, body }) + if (requestPath.startsWith('/api/platform/v1/iam/tenants?')) return response(page(tenants)) + if (method === 'GET' && requestPath.startsWith('/api/platform/v1/warehouse-admin?')) return response(page(warehouseRecords)) + if (method === 'GET' && requestPath.includes('/warehouse-admin/20001?')) return response({ warehouse: { id: '20001', customerId: '0', warehouseType: 'OVERSEAS', status: 'ACTIVE' } }) + if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001') + if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return response(null) + throw new Error(`Unexpected domestic path: ${requestPath}`) + }; + return handler.apply(this,args); + }, + + async requestBinary(method, requestPath) { calls.push({ channel: 'overseas-binary', method, path: requestPath }); return { http: 200, bytes: Buffer.from('id,status\n'), contentType: 'text/csv', requestId: 'request-binary', traceId: 'trace-binary' } }, apiSuccess(_module, _name, result) { return result?.http === 200 && String(result?.body?.code) === '200' ? result.body.data : null }, assert(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed: Boolean(passed), evidence, options }) }, addStep(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed: Boolean(passed), evidence, options }) }, @@ -65,10 +69,10 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou test('模块只依赖注入请求,不读取环境或自行联网', () => { assert.match(source, /export async function run\(context\)/) - assert.match(source, /context\.requestOverseas\('GET'/) - assert.match(source, /context\.requestOverseasBinary\('GET'/) + assert.match(source, /context\.request\('GET'/) + assert.match(source, /context\.requestBinary\('GET'/) assert.doesNotMatch(source, /https?:\/\//) - assert.doesNotMatch(source, /process\.env|fetch\(|requestBinary/) + assert.doesNotMatch(source, /process\.env|fetch\(/) }) test('既有仓库场景只写本轮库位与托盘生命周期', () => { @@ -83,9 +87,9 @@ test('既有仓库场景只写本轮库位与托盘生命周期', () => { assert.ok(context.calls.filter((call) => call.channel === 'overseas').every((call) => call.path.startsWith('/api/platform/v1/'))) const writes = context.calls.filter((call) => call.method !== 'GET') assert.ok(writes.some((call) => call.method === 'DELETE' && call.path.includes('/locations/72002'))) - assert.ok(writes.some((call) => call.path.includes('/warehouse-admin/20001/pallets'))) + assert.ok(writes.some((call) => call.path.includes('/overseas-warehouse-resources/20001/pallets'))) assert.equal(writes.some((call) => call.path.includes('/relabel-applications')), false) - assert.ok(writes.every((call) => call.path.includes('/warehouse-admin/20001/'))) + assert.ok(writes.every((call) => call.path.includes('/overseas-warehouse-resources/20001/'))) for (const name of ['编辑本轮公共库位', '删除本轮公共库位', '报废本轮公共托盘']) { const step = context.steps.find((item) => item.name === name) assert.ok(step?.passed, name) diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs index 7328e5768..58430d4c6 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs @@ -31,12 +31,12 @@ async function domesticGet(context, name, path) { return { response, data: context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) } } async function overseasGet(context, name, path) { - const response = await context.requestOverseas('GET', path) + const response = await context.request('GET', path) assertEnvelope(context, name, response) return { response, data: context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) } } async function overseasMutation(context, method, name, path, body) { - const response = await context.requestOverseas(method, path, body) + const response = await context.request(method, path, body) const passed = response?.http === 200 && String(response?.body?.code) === '200' context.addStep(MODULE, name, response, passed, trace(response), OPTIONS) return { response, data: passed ? response.body?.data : null } @@ -48,7 +48,7 @@ async function domesticMutation(context, method, name, path, body) { return { response, data: passed ? response.body?.data : null } } async function overseasCsv(context, name, path) { - const response = await context.requestOverseasBinary('GET', path) + const response = await context.requestBinary('GET', path) context.addStep(MODULE, name, response, response?.http === 200 && Number(response?.bytes?.length) > 0 && String(response?.contentType || '').toLowerCase().includes('csv'), `bytes=${response?.bytes?.length ?? 0}, contentType=${response?.contentType || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`, OPTIONS) @@ -164,7 +164,7 @@ export async function run(context) { const warehouseId = id(warehouse.id) context.createdResources.phase4OverseasWarehouse.customerId = customerId context.createdResources.phase4OverseasWarehouse.warehouseId = warehouseId - const locations = await overseasGet(context, '查询公共库位列表', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, { page: 1, size: 20 })) + const locations = await overseasGet(context, '查询公共库位列表', query(`/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/locations`, { page: 1, size: 20 })) assertPage(context, '公共库位列表', locations) const location = records(locations.data)[0] if (location) { @@ -172,12 +172,12 @@ export async function run(context) { longId(location.id) && id(location.warehouseId) === warehouseId && id(location.customerId) === '0' && nonBlank(location.locationCode) && nonBlank(location.status), `id=${id(location.id) || 'missing'}, customerId=${id(location.customerId) || 'missing'}, status=${location.status || 'missing'}; ${trace(locations.response)}`, OPTIONS) - const detail = await overseasGet(context, '查询公共库位详情', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(id(location.id))}`) + const detail = await overseasGet(context, '查询公共库位详情', `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(id(location.id))}`) context.assert(MODULE, '公共库位详情对应列表记录', detail.response, id(detail.data?.id) === id(location.id) && id(detail.data?.warehouseId) === warehouseId && id(detail.data?.customerId) === '0', `listId=${id(location.id)}, detailId=${id(detail.data?.id) || 'missing'}; ${trace(detail.response)}`, OPTIONS) } - const pallets = await overseasGet(context, '查询公共托盘列表', query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, { page: 1, size: 20 })) + const pallets = await overseasGet(context, '查询公共托盘列表', query(`/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets`, { page: 1, size: 20 })) assertPage(context, '公共托盘列表', pallets) const pallet = records(pallets.data)[0] if (pallet) { @@ -185,7 +185,7 @@ export async function run(context) { longId(pallet.id) && id(pallet.warehouseId) === warehouseId && id(pallet.customerId) === '0' && nonBlank(pallet.palletCode) && ['IDLE', 'OCCUPIED', 'SCRAPPED'].includes(pallet.status), `id=${id(pallet.id) || 'missing'}, customerId=${id(pallet.customerId) || 'missing'}, status=${pallet.status || 'missing'}; ${trace(pallets.response)}`, OPTIONS) - const detail = await overseasGet(context, '查询公共托盘详情', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(id(pallet.id))}`) + const detail = await overseasGet(context, '查询公共托盘详情', `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(id(pallet.id))}`) context.assert(MODULE, '公共托盘详情对应列表记录', detail.response, id(detail.data?.id) === id(pallet.id) && id(detail.data?.warehouseId) === warehouseId && id(detail.data?.customerId) === '0', `listId=${id(pallet.id)}, detailId=${id(detail.data?.id) || 'missing'}; ${trace(detail.response)}`, OPTIONS) @@ -209,7 +209,7 @@ async function verifyLocationLifecycle(context, warehouseId) { let locationId = '' try { const created = await overseasMutation(context, 'POST', '创建本轮公共库位', - `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, { + `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/locations`, { locationCode, locationType: 'STORAGE', maxLoadKg: 1000, status: 'ACTIVE', remark: context.marker, }) @@ -218,7 +218,7 @@ async function verifyLocationLifecycle(context, warehouseId) { context.assert(MODULE, '公共库位创建返回长ID', created.response, longId(locationId), `locationId=${locationId || 'missing'}, marker=${context.marker}; ${trace(created.response)}`, OPTIONS) if (!locationId) return - const detailPath = `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}` + const detailPath = `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}` const detail = await overseasGet(context, '查询本轮公共库位详情', detailPath) context.assert(MODULE, '本轮公共库位创建事实正确', detail.response, id(detail.data?.id) === locationId && id(detail.data?.customerId) === '0' @@ -237,9 +237,8 @@ async function verifyLocationLifecycle(context, warehouseId) { } finally { if (locationId) { const removed = await overseasMutation(context, 'DELETE', '删除本轮公共库位', - `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`) - const deletedDetail = await context.requestOverseas('GET', - `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`) + `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`) + const deletedDetail = await context.request('GET', `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/locations/${encodeURIComponent(locationId)}`) const deleted = voidMutationSucceeded(removed) && businessNotFound(deletedDetail) context.createdResources.phase4OverseasWarehouse.locationTerminalStatus = deleted ? 'DELETED' : null context.assert(MODULE, '本轮公共库位已删除且回查不存在', deletedDetail, deleted, @@ -287,7 +286,7 @@ async function verifyPalletLifecycle(context, warehouseId) { let palletId = '' try { const created = await overseasMutation(context, 'POST', '创建本轮公共托盘', - `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, { + `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets`, { palletCode, palletType: '木托盘', capacityCbm: 2, unitPrice: 0, status: 'IDLE', remark: context.marker, }) palletId = id(created.data) @@ -296,7 +295,7 @@ async function verifyPalletLifecycle(context, warehouseId) { `palletId=${palletId || 'missing'}, marker=${context.marker}; ${trace(created.response)}`, OPTIONS) if (!palletId) return - const detailPath = `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}` + const detailPath = `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}` const detail = await overseasGet(context, '查询本轮公共托盘详情', detailPath) context.assert(MODULE, '本轮公共托盘创建事实正确', detail.response, id(detail.data?.id) === palletId && id(detail.data?.warehouseId) === warehouseId @@ -314,7 +313,7 @@ async function verifyPalletLifecycle(context, warehouseId) { id(after.data?.id) === palletId && Number(after.data?.capacityCbm) === 2.5 && Number(after.data?.unitPrice) === 1 && after.data?.remark === `${context.marker}-edited`, `capacity=${after.data?.capacityCbm ?? 'missing'}, unitPrice=${after.data?.unitPrice ?? 'missing'}, remark=${after.data?.remark || 'missing'}; ${trace(after.response)}`, OPTIONS) - const stale = await context.requestOverseas('PUT', detailPath, { + const stale = await context.request('PUT', detailPath, { palletCode, palletType: '木托盘', capacityCbm: 9, unitPrice: 9, status: 'IDLE', remark: 'STALE-DO-NOT-APPLY', version: Number(detail.data?.version), }) @@ -323,17 +322,17 @@ async function verifyPalletLifecycle(context, warehouseId) { } finally { if (palletId) { const retired = await overseasMutation(context, 'PUT', '报废本轮公共托盘', - `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`, { + `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`, { palletCode, palletType: '木托盘', capacityCbm: 2.5, unitPrice: 1, status: 'SCRAPPED', remark: `${context.marker}-acceptance-retired`, version: Number((await overseasGet(context, '读取公共托盘报废前最新版本', - `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`)).data?.version), + `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`)).data?.version), }) context.createdResources.phase4OverseasWarehouse.terminalStatus = voidMutationSucceeded(retired) ? 'SCRAPPED' : null context.assert(MODULE, '本轮公共托盘已报废收口', retired.response, voidMutationSucceeded(retired), `palletId=${palletId}, terminalStatus=${context.createdResources.phase4OverseasWarehouse.terminalStatus || 'missing'}; ${trace(retired.response)}`, OPTIONS) const retiredDetail = await overseasGet(context, '回查本轮公共托盘报废状态', - `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`) + `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}/pallets/${encodeURIComponent(palletId)}`) context.assert(MODULE, '本轮公共托盘终态为已报废', retiredDetail.response, id(retiredDetail.data?.id) === palletId && retiredDetail.data?.status === 'SCRAPPED', `palletId=${id(retiredDetail.data?.id) || 'missing'}, status=${retiredDetail.data?.status || 'missing'}; ${trace(retiredDetail.response)}`, OPTIONS) diff --git a/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs b/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs index 6225b8682..bd552627d 100644 --- a/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs @@ -42,7 +42,7 @@ test('module covers the public tail-order write surface before PDA receiving', ( ]) assert.ok(source.includes(evidence), `missing ${evidence}`) assert.match(source, /nodes\.data\?\.nodes\?\.length === 8/) assert.match(source, /creationMode === 'AUTO'/) - assert.match(source, /requestOverseasBinary\('GET'/) + assert.match(source, /requestBinary\('GET'/) assert.match(source, /palletMergeBatchId/) assert.match(source, /feeItemCode/) assert.match(source, /feeItemName/) @@ -89,3 +89,181 @@ test('container conversion preserves long identifiers and generated pallets for assert.match(source, /AUTO_CONTAINER_PLANS_READY/) assert.match(source, /remain as marker-owned prerequisites consumed by overseas PDA receiving/) }) + +function holdModel(fault='') { + const buckets={availablePackages:'4',holdPackages:'0',allocatedPackages:'0',pickedPackages:'0',receivingPackages:'0',inTransitPackages:'0',procurementPackages:'0'} + const order={id:'9007199254740993',customerId:'C1',overseasWarehouseId:'WH1',inboundCode:'IN-1',currentStatus:'INBOUND_COMPLETED',detained:false,heldChargeable:false,version:4} + let positions=[1,3].map((n,i)=>({id:String(21+i),balanceId:'20',customerId:'C1',overseasWarehouseId:'WH1',inboundOrderId:order.id, + palletId:String(31+i),palletNo:`PALLET-${i}`,batchNo:'PLAN',totalPackages:String(n),availablePackages:String(n),holdPackages:'0',allocatedPackages:'0',pickedPackages:'0',receivingPackages:'0'})) + const original=structuredClone(positions) + const balance={id:'20',customerId:'C1',overseasWarehouseId:'WH1',inboundOrderId:order.id,inboundCode:'IN-1',totalPackages:'4',...buckets} + const receipt=positions.map(row=>({customerId:'C1',orderId:order.id,inboundCode:'IN-1',inventoryBalanceId:'20',inventoryPositionId:row.id,receivedPackages:row.availablePackages})) + const commands=new Map(),calls=[];let tick=0,cycle=0,other=false,replayRead=false,failedRead=false + const context={marker:'RUN',token:'admin',createdResources:{ + phase4TailOrderControls:{marker:'RUN',representativeOrderId:order.id}, + phase4InboundProducerFixtures:{marker:'RUN',containers:[{fulfillmentType:'ONE_CLICK_DROPSHIP',tenantId:'C1',containerNo:'BOX'}]}, + phase4OverseasPda:{marker:'RUN',receivingCompletionEvidence:{booking:{status:'VERIFIED',proof:{passed:true},scope:{customerId:'C1',warehouseId:'WH1',containerNo:'BOX'}, + stages:{AFTER_COMPLETE:{captured:true,evidence:{balances:[structuredClone(balance)],positions:original,stockReceipts:receipt}}}}}}}, + addStep(){},assert(_m,name,_r,passed){assert.ok(passed,name)},apiSuccess(_m,name,r){assert.equal(r.body.code,'200',name);return r.body.data}, + async request(method,raw,body,_token,headers={}) { + calls.push({method,path:raw,body:structuredClone(body),headers}) + const path=new URL(raw,'https://offline.invalid').pathname + const result=data=>({http:200,body:{code:'200',data:structuredClone(data)}}) + const conflict=()=>({http:200,body:{code:'A0409'},requestId:'req',traceId:'trace'}) + const counts=()=>{ + for(const field of ['availablePackages','holdPackages','allocatedPackages','pickedPackages','receivingPackages']) balance[field]=String(positions.reduce((sum,row)=>sum+Number(row[field]),0)) + balance.updatedAt=String(tick) + } + if(method==='GET' && path.endsWith('/positions')) { + if(replayRead && ['read failure','foreign hold'].includes(fault) && !failedRead){failedRead=true;if(fault==='foreign hold'){order.version++;order.holdReason='ANOTHER-OPERATION'}throw new Error('read failed')} + return result(fault==='missing position'?positions.slice(1):positions.slice().reverse()) + } + if(method==='GET' && path.includes('/inventory/balances/')) {counts();return result(balance)} + if(method==='GET' && path.includes('/orders/')) return result({order}) + if(path.endsWith('/manual')) {if(fault==='ambiguous plan')return {http:0};return conflict()} + if(path.includes('/orders/') && method==='PUT') { + const key=headers['Idempotency-Key'],prior=commands.get(key) + if(prior) { + if(JSON.stringify(body)!==JSON.stringify(prior.body)) return conflict() + replayRead=true + if(fault==='replay drift'){positions[0].allocatedPackages='1';positions[0].availablePackages='0';tick++} + return result(prior.response) + } + if(body.held) { + cycle++ + if(!(fault==='second cycle no movement' && cycle===2))for(const row of positions){row.holdPackages=String(Number(row.holdPackages)+Number(row.availablePackages));row.availablePackages='0'} + } else { + for(const row of positions){const retained=other&&row.id==='22'&&fault!=='release steals independent'?1:0;row.availablePackages=String(Number(row.availablePackages)+Number(row.holdPackages)-retained);row.holdPackages=String(retained)} + } + order.detained=body.held;order.heldChargeable=body.chargeable;order.holdReason=body.held?body.reason:null;order.version++;tick++ + const response={orderId:order.id,held:body.held,chargeable:body.chargeable,reason:order.holdReason,version:order.version} + commands.set(key,{body:structuredClone(body),response}) + if(fault==='ambiguous order hold' && body.held)return {http:0} + return result(response) + } + if(method==='POST' && path.includes('/inventory/')) { + const row=positions.find(p=>p.id===body.positionId),held=path.endsWith('/hold') + row.availablePackages=String(Number(row.availablePackages)+(held?-1:1));row.holdPackages=String(Number(row.holdPackages)+(held?1:-1));other=held;tick++ + if(fault==='ambiguous independent hold' && held)return {http:0} + return result({replayed:false}) + } + throw new Error(`unexpected ${method} ${raw}`) + }} + if(fault==='stale marker')context.createdResources.phase4OverseasPda.marker='OLD' + if(fault==='no evidence')delete context.createdResources.phase4OverseasPda.receivingCompletionEvidence + if(fault==='wrong immutable customer')receipt[0].customerId='OTHER' + if(fault==='unsafe bucket')balance.procurementPackages='9007199254740993' + if(fault==='moved source')positions[1].palletNo='MOVED' + if(fault==='not enough stock'){ + positions[1].availablePackages='1';positions[1].totalPackages='1' + const e=context.createdResources.phase4OverseasPda.receivingCompletionEvidence.booking.stages.AFTER_COMPLETE.evidence + e.positions[1].availablePackages='1';e.positions[1].totalPackages='1';e.stockReceipts[1].receivedPackages='1' + e.balances[0].availablePackages='2';e.balances[0].totalPackages='2';balance.totalPackages='2' + } + return {context,calls,order,positions} +} + +test('a failed repeated verification clears previous success evidence before any write',async()=>{ + const {context,calls}=holdModel('no evidence') + context.createdResources.phase4TailOrderControls.holdVerifiedAfterReceipt=true + context.createdResources.phase4TailOrderControls.holdInventoryProof={status:'VERIFIED'} + await assert.rejects(verifyReceivedOrderHold(context)) + assert.equal(context.createdResources.phase4TailOrderControls.holdVerifiedAfterReceipt,false) + assert.equal(context.createdResources.phase4TailOrderControls.holdInventoryProof,undefined) + assert.ok(calls.every(call=>call.method==='GET')) +}) + +test('fresh immutable IDs drive two full cycles and preserve independent HOLD across every raw position',async()=>{ + const {context,calls,order}=holdModel() + await verifyReceivedOrderHold(context) + const proof=context.createdResources.phase4TailOrderControls.holdInventoryProof + assert.equal(proof.status,'VERIFIED');assert.equal(proof.restored,true);assert.equal(order.version,8) + assert.equal(proof.concurrentCommands.length,2) + for(const command of proof.concurrentCommands) { + assert.equal(command.copies,2) + assert.deepEqual(command.replies[0],command.replies[1]) + assert.equal(command.replies[0].version,command.expectedVersion+1) + } + assert.equal(proof.snapshots.C2_RELEASED.positions.find(row=>row.id==='22').holdPackages,1) + assert.equal(proof.snapshots.AFTER.balances[0].availablePackages,4) + assert.equal(calls.filter(c=>c.path.endsWith('/manual')).length,2) + assert.ok(!calls.some(c=>/inventory\/(balances|summary)\?/.test(c.path))) + assert.equal(calls.filter(c=>c.method==='POST'&&c.path.endsWith('/inventory/release')).length,1) +}) + +test('hold duplicate requests are in flight together, with bounded concurrency of two',async()=>{ + const {context}=holdModel() + const originalRequest=context.request + let active=0,peak=0 + context.request=async(method,path,...args)=>{ + if(method!=='PUT'||!path.includes('/orders/')) return originalRequest(method,path,...args) + active++;peak=Math.max(peak,active) + try { await new Promise(resolve=>setImmediate(resolve));return await originalRequest(method,path,...args) } + finally {active--} + } + await verifyReceivedOrderHold(context) + assert.equal(peak,2) + assert.equal(active,0) +}) + +test('ambiguous parallel hold waits for its sibling and never starts recovery writes',async()=>{ + const {context,calls}=holdModel() + const originalRequest=context.request + let holdRequests=0,siblingSettled=false + context.request=async(method,path,body,...args)=>{ + if(method==='PUT' && body?.held && ++holdRequests<=2) { + if(holdRequests===1) throw new Error('lost response') + await new Promise(resolve=>setImmediate(resolve)) + const response=await originalRequest(method,path,body,...args) + siblingSettled=true + return response + } + return originalRequest(method,path,body,...args) + } + await assert.rejects(verifyReceivedOrderHold(context),/lost response/) + assert.equal(siblingSettled,true) + assert.equal(context.createdResources.phase4TailOrderControls.holdInventoryProof.uncertainWrite,true) + assert.ok(!calls.some(call=>call.path.endsWith('/inventory/release')||call.body?.held===false)) +}) + +for(const fault of ['stale marker','no evidence','wrong immutable customer','missing position','unsafe bucket','not enough stock','moved source']) { + test(`missing or unsafe fresh hold prerequisite blocks all writes: ${fault}`,async()=>{ + const {context,calls}=holdModel(fault) + await assert.rejects(verifyReceivedOrderHold(context)) + assert.ok(calls.every(c=>c.method==='GET')) + }) +} +for(const fault of ['second cycle no movement','release steals independent','replay drift']) { + test(`inventory regression is detected and never declared verified: ${fault}`,async()=>{ + const {context}=holdModel(fault) + await assert.rejects(verifyReceivedOrderHold(context)) + assert.equal(context.createdResources.phase4TailOrderControls.holdInventoryProof.status,'FAILED') + assert.notEqual(context.createdResources.phase4TailOrderControls.holdVerifiedAfterReceipt,true) + }) +} +for(const fault of ['ambiguous independent hold','ambiguous order hold','ambiguous plan']) { + test(`unknown mutation response never triggers a blind recovery release: ${fault}`,async()=>{ + const {context,calls}=holdModel(fault) + await assert.rejects(verifyReceivedOrderHold(context)) + const proof=context.createdResources.phase4TailOrderControls.holdInventoryProof + assert.equal(proof.uncertainWrite,true);assert.equal(proof.restored,false) + assert.match(proof.recoveryError,/禁止自动释放/) + assert.ok(!calls.some(c=>c.path.endsWith('/inventory/release')||c.body?.held===false)) + }) +} +test('read failure after confirmed hold recovers only the exact known order cycle and independent one box',async()=>{ + const {context,calls}=holdModel('read failure') + await assert.rejects(verifyReceivedOrderHold(context),/read failed/) + const proof=context.createdResources.phase4TailOrderControls.holdInventoryProof + assert.equal(proof.status,'FAILED');assert.equal(proof.restored,true) + assert.equal(calls.filter(c=>c.body?.held===false).length,1) + assert.equal(proof.snapshots.RECOVERED.balances[0].holdPackages,0) +}) + +test('recovery refuses to release a concurrently changed order hold',async()=>{ + const {context,calls}=holdModel('foreign hold') + await assert.rejects(verifyReceivedOrderHold(context),/read failed/) + const proof=context.createdResources.phase4TailOrderControls.holdInventoryProof + assert.equal(proof.restored,false);assert.match(proof.recoveryError,/只恢复本轮/) + assert.ok(!calls.some(c=>c.path.endsWith('/inventory/release')||c.body?.held===false)) +}) diff --git a/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs b/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs index cb53399f0..c54e81d68 100644 --- a/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs +++ b/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs @@ -26,7 +26,7 @@ function query(path, values) { return params.size ? `${path}?${params}` : path } async function api(context, method, name, path, body, headers = {}) { - const response = await context.requestOverseas(method, path, body, headers, context.overseasToken) + const response = await context.request(method, path, body, context.token, headers) const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) return { response, data } } @@ -123,12 +123,9 @@ async function verifyReceivingWarehouseChange(context, producer, details) { context.assert(MODULE, '接柜仓变更重放版本零漂移', replay.response, Number(replay.data?.assignmentVersion) === Number(changed.data?.assignmentVersion) && id(replay.data?.overseasWarehouseId) === id(alternate.id), trace(replay.response), OPTIONS) - const hashConflict = await context.requestOverseas('PUT', changePath, { ...changeBody, remark: `${context.marker}-DIFFERENT` }, - { 'Idempotency-Key': changeKey }, context.overseasToken) + const hashConflict = await context.request('PUT', changePath, { ...changeBody, remark: `${context.marker}-DIFFERENT` }, context.token, { 'Idempotency-Key': changeKey }) context.addStep(MODULE, '接柜仓变更同键异载荷返回A0409', hashConflict, conflict(hashConflict), trace(hashConflict), OPTIONS) - const stale = await context.requestOverseas('PUT', changePath, - { overseasWarehouseId: originalWarehouseId, expectedVersion: Number(summary.assignmentVersion), remark: context.marker }, - { 'Idempotency-Key': `${context.marker}-receiving-warehouse-stale`.slice(0, 128) }, context.overseasToken) + const stale = await context.request('PUT', changePath, { overseasWarehouseId: originalWarehouseId, expectedVersion: Number(summary.assignmentVersion), remark: context.marker }, context.token, { 'Idempotency-Key': `${context.marker}-receiving-warehouse-stale`.slice(0, 128) }) context.addStep(MODULE, '接柜仓变更旧版本返回A0409', stale, conflict(stale), trace(stale), OPTIONS) const restored = await api(context, 'PUT', '整柜恢复原公共接柜仓', changePath, { overseasWarehouseId: originalWarehouseId, expectedVersion: Number(changed.data.assignmentVersion), remark: `${context.marker}-restore` }, @@ -210,7 +207,7 @@ async function verifyDestinationMergeFeeAndExport(context, details, producer) { && row?.originalCurrency === 'USD' && Number(row?.originalAmount) === 0.01 && row?.currency === 'CNY' && Number(row?.amount) > 0), trace(feeRead.response), OPTIONS) - const exported = await context.requestOverseasBinary('GET', query(`${ORDERS}/export`, { + const exported = await context.requestBinary('GET', query(`${ORDERS}/export`, { customerId: id(representative.customerId), containerNo: producer.containerNo, })) const csv = new TextDecoder().decode(exported.bytes || new Uint8Array()) @@ -309,7 +306,7 @@ async function convertContainerPlans(context, producer, details) { for (const { plan, pallet } of pallets) { const line = plan.lines[0] - const palletPath = query(`/api/platform/v1/warehouse-admin/${encodeURIComponent(id(preview.data.overseasWarehouseId))}/pallets`, { + const palletPath = query(`/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(id(preview.data.overseasWarehouseId))}/pallets`, { page: 1, size: 10, keyword: pallet.palletNo, }) const listed = await api(context, 'GET', `回查自动托盘${pallet.palletNo}`, palletPath) @@ -337,44 +334,221 @@ async function convertContainerPlans(context, producer, details) { export async function verifyReceivedOrderHold(context) { const owned = context.createdResources.phase4TailOrderControls if (owned?.marker !== context.marker || !owned.representativeOrderId) throw new Error('缺少本轮暂扣目标订单') + owned.holdVerifiedAfterReceipt = false + delete owned.holdInventoryProof const producer = targetProducer(context) const loaded = await detail(context, { id: owned.representativeOrderId, customerId: producer?.tenantId }, '入库后读取本轮暂扣目标') const representative = loaded.data?.order if (!success(loaded.response) || id(representative?.id) !== id(owned.representativeOrderId) || id(representative?.customerId) !== id(producer?.tenantId) || representative?.currentStatus !== 'INBOUND_COMPLETED') throw new Error('暂扣正向验收必须使用本轮已完成入库订单') - const orderPath = query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/hold`, { - customerId: id(representative.customerId), - }) - const holdKey = `${context.marker}-tail-hold`.slice(0, 128) - const holdBody = { held: true, chargeable: true, reason: `${context.marker} 暂扣验收`, expectedVersion: Number(representative.version) } - const held = await api(context, 'PUT', '暂扣本轮单票尾程订单', orderPath, holdBody, { 'Idempotency-Key': holdKey }) - context.assert(MODULE, '暂扣只推进单票版本并保留计费原因', held.response, - held.data?.held === true && held.data?.chargeable === true - && id(held.data?.orderId) === id(representative.id) && Number(held.data?.version) === Number(representative.version) + 1, - trace(held.response), OPTIONS) - if (!success(held.response)) return - const heldReplay = await api(context, 'PUT', '暂扣命令同键同载荷重放', orderPath, holdBody, { 'Idempotency-Key': holdKey }) - context.assert(MODULE, '暂扣重放版本零漂移', heldReplay.response, - JSON.stringify(heldReplay.data) === JSON.stringify(held.data), trace(heldReplay.response), OPTIONS) - const heldConflict = await context.requestOverseas('PUT', orderPath, - { ...holdBody, reason: `${holdBody.reason}-DIFFERENT` }, { 'Idempotency-Key': holdKey }, context.overseasToken) - context.addStep(MODULE, '暂扣同键异载荷返回A0409', heldConflict, conflict(heldConflict), trace(heldConflict), OPTIONS) + const require = (name, passed) => { + context.assert(MODULE, name, null, Boolean(passed), `orderId=${representative.id}`, OPTIONS) + if (!passed) throw new Error(name) + } + const customerId = id(representative.customerId), orderId = id(representative.id) + const attemptKey = `${context.marker}-V${representative.version}` + const pda = context.createdResources.phase4OverseasPda + require('暂扣库存前置必须为本轮已验证收货证据', pda?.marker === context.marker) + const matches = Object.values(pda?.receivingCompletionEvidence || {}).filter(value => value.status === 'VERIFIED' + && value.proof?.passed === true && id(value.scope?.customerId) === customerId + && value.scope?.containerNo === producer.containerNo && value.stages?.AFTER_COMPLETE?.captured === true + && value.stages.AFTER_COMPLETE.evidence?.stockReceipts?.some(row => id(row.orderId) === orderId)) + require('暂扣目标必须唯一命中本轮不可变实收事实', matches.length === 1) + const evidence = matches[0].stages.AFTER_COMPLETE.evidence + const warehouseId = id(matches[0].scope.warehouseId) + require('暂扣目标实际仓与收货证据一致', warehouseId && warehouseId === id(representative.overseasWarehouseId)) + const receipts = evidence.stockReceipts.filter(row => id(row.orderId) === orderId) + require('暂扣实收事实具有完整客户来源和原始库存标识', receipts.length > 0 + && receipts.every(row => id(row.customerId) === customerId && row.inboundCode === representative.inboundCode + && id(row.inventoryBalanceId) && id(row.inventoryPositionId))) + const balanceIds = [...new Set(receipts.map(row => id(row.inventoryBalanceId)))].sort() + const positionIds = [...new Set(receipts.map(row => id(row.inventoryPositionId)))].sort() + require('暂扣收货证据包含原始余额与位置全集', Array.isArray(evidence.balances) && Array.isArray(evidence.positions)) + const originalBalances = evidence.balances.filter(row => id(row.inboundOrderId) === orderId) + const originalPositions = evidence.positions.filter(row => balanceIds.includes(id(row.balanceId))) + require('暂扣原始ID不得由公共SKU汇总推导或漏掉位置', sameHoldFacts(originalBalances.map(row=>id(row.id)),balanceIds) + && sameHoldFacts(originalPositions.map(row=>id(row.id)),positionIds) + && originalBalances.every(row=>id(row.customerId)===customerId && id(row.overseasWarehouseId)===warehouseId) + && originalPositions.every(row=>id(row.customerId)===customerId && id(row.overseasWarehouseId)===warehouseId)) + const proof = owned.holdInventoryProof = { marker:context.marker, orderId, customerId, warehouseId, + balanceIds, positionIds, snapshots:{}, concurrentCommands:[], status:'RUNNING', restored:false } + const orderPath = query(`${ORDERS}/${encodeURIComponent(orderId)}/hold`, {customerId}) + const inventoryPath = '/api/platform/v1/last-leg/inventory' + let uncertain = false, otherApplied = false, active = null, baseline, otherHeld, otherBody + const request = async (method,name,path,body,headers={}) => { + let response + try { response = await context.request(method,path,body,context.token,headers) } + catch(error) { if(method!=='GET') uncertain=true;throw error } + context.addStep(MODULE,name,response,success(response),trace(response),OPTIONS) + if (!success(response)) { + if(method!=='GET' && (!response?.http || response.http>=500)) uncertain=true + throw new Error(`${name}: ${trace(response)}`) + } + return response.body.data + } + const reject = async (name,path,body,headers,method) => { + let response + try { response=await context.request(method,path,body,context.token,headers) } + catch(error) {uncertain=true;throw error} + if(!response?.http || response.http>=500 || success(response)) uncertain=true + context.addStep(MODULE,name,response,conflict(response),trace(response),OPTIONS) + require(`${name}必须明确拒绝`,conflict(response) && nonBlank(response.requestId) && nonBlank(response.traceId)) + return response + } + const read = async label => { + const order = (await request('GET','暂扣回查订单事实',query(`${ORDERS}/${orderId}`,{customerId}))).order + require('暂扣回查订单身份和入库状态稳定', id(order?.id)===orderId && id(order.customerId)===customerId + && id(order.overseasWarehouseId)===warehouseId && order.currentStatus==='INBOUND_COMPLETED') + const balances=[],positions=[] + for(const balanceId of balanceIds) { + const balance=await request('GET','暂扣读取原始余额详情',query(`${inventoryPath}/balances/${balanceId}`,{customerId})) + require('暂扣原始余额归属正确',id(balance?.id)===balanceId && id(balance.inboundOrderId)===orderId + && id(balance.customerId)===customerId && id(balance.overseasWarehouseId)===warehouseId) + balances.push(holdNumbers(balance,HOLD_BALANCE_BUCKETS)) + const rows=await request('GET','暂扣读取原始余额所有位置',query(`${inventoryPath}/balances/${balanceId}/positions`,{customerId})) + require('暂扣位置归属正确',Array.isArray(rows) && rows.every(row=>id(row.balanceId)===balanceId + && id(row.inboundOrderId)===orderId && id(row.customerId)===customerId && id(row.overseasWarehouseId)===warehouseId)) + positions.push(...rows.map(row=>holdNumbers(row,HOLD_POSITION_BUCKETS))) + } + require('暂扣位置集合完整且无重复',sameHoldFacts(positions.map(row=>id(row.id)),positionIds)) + for(const balance of balances) for(const field of HOLD_POSITION_BUCKETS) { + require(`暂扣余额位置核平${field}`,balance[field]===positions.filter(row=>id(row.balanceId)===id(balance.id)).reduce((sum,row)=>sum+row[field],0)) + } + const value={order,balances,positions};proof.snapshots[label]=value;return value + } + const stock = value => ({balances:value.balances.map(holdStableStock),positions:value.positions.map(holdStableStock)}) + const expectStock = (actual,expected,label) => require(label,sameHoldFacts(stock(actual),stock(expected))) + const orderCommand = async (held,version,suffix,copies=1) => { + const body={held,chargeable:held,reason:`${context.marker} ${suffix}`,expectedVersion:Number(version)} + const headers={'Idempotency-Key':`${attemptKey}-${suffix}`} + // Settle both writes before inspecting or recovering: one rejected promise does not + // mean its sibling has stopped modifying the order. + const outcomes=await Promise.allSettled(Array.from({length:copies},()=> + request('PUT',held?'暂扣本轮单票尾程订单':'解除本轮单票尾程暂扣',orderPath,body,headers))) + const failed=outcomes.find(result=>result.status==='rejected') + if(failed) { if(copies>1) uncertain=true;throw failed.reason } + const replies=outcomes.map(result=>result.value),data=replies[0] + // A malformed successful response is not sufficient authority for a recovery mutation. + if(id(data?.orderId)!==orderId || data.held!==held || Number(data.version)!==Number(version)+1) uncertain=true + require('暂扣命令返回本订单预期版本及计费状态',!uncertain && data.chargeable===held && (held || data.reason==null)) + active=held?{version:Number(data.version),reason:body.reason}:null + if(copies>1) { + proof.concurrentCommands.push({held,copies,expectedVersion:Number(version),replies}) + require('并发同键命令返回相同订单状态和唯一新版本',replies.every(reply=>sameHoldFacts(reply,data))) + } + return {body,headers,data} + } + const releaseOther = async () => { + await request('POST','释放本轮独立库存暂扣',`${inventoryPath}/release`,{...otherBody, + idempotencyKey:`${attemptKey}-F32-OTHER-RELEASE`,remark:`${context.marker} independent release`}) + otherApplied=false + } + try { + baseline=await read('BEFORE') + require('暂扣前货物可用且无现存业务冻结或出库占用',baseline.order.detained===false + && baseline.positions.every(row=>row.holdPackages===0 && row.allocatedPackages===0 && row.pickedPackages===0 && row.receivingPackages===0) + && baseline.balances.every(row=>row.inTransitPackages===0 && row.procurementPackages===0)) + for(const row of baseline.balances) { + const original=originalBalances.find(ref=>id(ref.id)===id(row.id)) + require('暂扣当前余额仍等于本轮实收入账',HOLD_BALANCE_BUCKETS.every(field=>row[field]===holdInteger(original[field]))) + } + for(const row of baseline.positions) { + const original=originalPositions.find(ref=>id(ref.id)===id(row.id)) + require('暂扣原始位置仍绑定实收托盘库位批次', ['balanceId','palletId','palletNo','batchNo','warehouseLocationId','warehouseLocationCode','destinationWarehouseCode'] + .every(field=>id(row[field])===id(original[field])) + && receipts.filter(ref=>id(ref.inventoryPositionId)===id(row.id)).every(ref=>id(ref.inventoryBalanceId)===id(row.balanceId))) + require('暂扣当前位置仍等于本轮实收入账',HOLD_POSITION_BUCKETS.every(field=>row[field]===holdInteger(original[field])) + && row.availablePackages===receipts.filter(ref=>id(ref.inventoryPositionId)===id(row.id)).reduce((sum,ref)=>sum+holdInteger(ref.receivedPackages),0)) + } + const selected=baseline.positions.find(row=>row.availablePackages>=2) + require('暂扣必须有足够独立实收库存保留另一业务一箱',Boolean(selected)) + otherBody={positionId:selected.id,packages:1,businessOrderNo:`${context.marker}-F32-OTHER`, + sourceEventId:`${context.marker}-F32-OTHER`,idempotencyKey:`${attemptKey}-F32-OTHER-HOLD`,remark:`${context.marker} independent hold`} + await request('POST','建立本轮独立库存暂扣一箱',`${inventoryPath}/hold`,otherBody);otherApplied=true + otherHeld=shiftHoldStock(baseline,selected.id,false) + expectStock(await read('OTHER_HELD'),otherHeld,'独立暂扣只移动目标位置一箱且所有桶守恒') + const allHeld=shiftHoldStock(otherHeld,null,true) + for(let cycle=1;cycle<=2;cycle++) { + const before=await read(`C${cycle}_BEFORE`) + const held=await orderCommand(true,before.order.version,`F32-C${cycle}-HOLD`,cycle===1?2:1) + const during=await read(`C${cycle}_HELD`) + require('暂扣只推进单票版本并保留计费原因',during.order.detained===true && Number(during.order.version)===Number(held.data.version) + && during.order.holdReason===held.body.reason && during.order.heldChargeable===true) + expectStock(during,allHeld,`第${cycle}轮暂扣全部原始位置可用转HOLD`) + await request('PUT','暂扣命令同键同载荷重放',orderPath,held.body,held.headers) + require('暂扣重放版本及全部库存桶零漂移',sameHoldFacts(await read(`C${cycle}_HOLD_REPLAY`),during)) + await reject('暂扣同键异载荷返回A0409',orderPath,{...held.body,reason:`${held.body.reason}-DIFFERENT`},held.headers,'PUT') + require('暂扣异载荷拒绝且事实不变',sameHoldFacts(await read(`C${cycle}_CONFLICT`),during)) + await reject('暂扣尾程订单不能生成人工计划',`${PLANS}/manual`,manualPlanBody(representative,held.data.version,context.marker), + {'Idempotency-Key':`${attemptKey}-F32-C${cycle}-PLAN`},'POST') + require('人工计划拒绝后暂扣与库存不变',sameHoldFacts(await read(`C${cycle}_PLAN_REJECTED`),during)) + const released=await orderCommand(false,during.order.version,`F32-C${cycle}-RELEASE`,cycle===1?2:1) + const after=await read(`C${cycle}_RELEASED`) + require('解除暂扣清空计费与原因并推进版本',after.order.detained===false && after.order.heldChargeable===false + && after.order.holdReason==null && Number(after.order.version)===Number(released.data.version)) + expectStock(after,otherHeld,`第${cycle}轮解除保留另一业务HOLD一箱`) + await request('PUT','解除暂扣原键重放',orderPath,released.body,released.headers) + require('解除重放订单余额及所有位置零漂移',sameHoldFacts(await read(`C${cycle}_RELEASE_REPLAY`),after)) + } + await releaseOther() + const final=await read('AFTER') + expectStock(final,baseline,'最终库存完整恢复到本轮实收入账基线') + require('两轮暂扣后订单未冻结且版本精确增加四',final.order.detained===false && Number(final.order.version)===Number(baseline.order.version)+4) + proof.restored=true;proof.status='VERIFIED';owned.holdVerifiedAfterReceipt=true + } catch(error) {proof.status='FAILED';proof.error=error.message;throw error} + finally { + proof.uncertainWrite=uncertain + if(proof.status==='FAILED' && (uncertain || active || otherApplied)) { + try { + if(uncertain) throw new Error('写入响应不明,保留样本,禁止自动释放') + const current=await read('RECOVERY_BEFORE') + if(active) { + require('只恢复本轮已确认且版本原因一致的暂扣',current.order.detained===true + && Number(current.order.version)===active.version && current.order.holdReason===active.reason) + await orderCommand(false,active.version,'F32-RECOVERY-RELEASE') + } else require('恢复独立冻结前订单必须未暂扣',current.order.detained===false) + if(otherApplied) { + // Never release one arbitrary HOLD: all buckets must still equal our known independent hold. + expectStock(await read('RECOVERY_OTHER_CHECK'),otherHeld,'恢复只释放本轮已确认独立一箱') + await releaseOther() + } + const recovered=await read('RECOVERED');expectStock(recovered,baseline,'失败恢复后库存完整回到原基线') + require('失败恢复后订单未暂扣',recovered.order.detained===false);proof.restored=true + } catch(recoveryError) {proof.recoveryError=recoveryError.message} + proof.uncertainWrite=uncertain + } + } +} - const blockedPlanBody = manualPlanBody(representative, held.data.version, context.marker) - const blockedPlan = await context.requestOverseas('POST', `${PLANS}/manual`, blockedPlanBody, - { 'Idempotency-Key': `${context.marker}-held-plan`.slice(0, 128) }, context.overseasToken) - context.addStep(MODULE, '暂扣尾程订单不能生成人工计划', blockedPlan, conflict(blockedPlan), trace(blockedPlan), OPTIONS) - - const releaseKey = `${context.marker}-tail-release`.slice(0, 128) - const releaseBody = { held: false, chargeable: false, reason: `${context.marker} 解除暂扣`, expectedVersion: Number(held.data.version) } - const released = await api(context, 'PUT', '解除本轮单票尾程暂扣', orderPath, releaseBody, { 'Idempotency-Key': releaseKey }) - context.assert(MODULE, '解除暂扣清空计费与原因并推进版本', released.response, - released.data?.held === false && released.data?.chargeable === false && released.data?.reason == null - && Number(released.data?.version) === Number(held.data.version) + 1, trace(released.response), OPTIONS) - if (!success(released.response)) return - - owned.holdVerifiedAfterReceipt = true +const HOLD_POSITION_BUCKETS=['availablePackages','holdPackages','allocatedPackages','pickedPackages','receivingPackages'] +const HOLD_BALANCE_BUCKETS=[...HOLD_POSITION_BUCKETS,'inTransitPackages','procurementPackages'] +function holdInteger(value) { + if(!((typeof value==='number' || (typeof value==='string' && /^(0|[1-9][0-9]*)$/.test(value))) + && Number.isSafeInteger(Number(value)) && Number(value)>=0)) throw new Error('暂扣库存数量不是非负安全整数') + return Number(value) +} +function holdNumbers(row,fields) { + const value={...row} + for(const field of [...fields,'totalPackages']) value[field]=holdInteger(row[field]) + if(value.totalPackages!==fields.reduce((sum,field)=>sum+value[field],0)) throw new Error('暂扣库存总量与完整数量桶不守恒') + return value +} +function holdStableStock({updatedAt,version,...row}) {return row} +function sameHoldFacts(a,b) { + const normalize=value=>Array.isArray(value)?value.map(normalize).sort((x,y)=>JSON.stringify(x).localeCompare(JSON.stringify(y))) + :value&&typeof value==='object'?Object.fromEntries(Object.keys(value).sort().map(key=>[key,normalize(value[key])])):value + return JSON.stringify(normalize(a))===JSON.stringify(normalize(b)) +} +function shiftHoldStock(source,positionId,all) { + const value=structuredClone(source) + for(const position of value.positions) { + const quantity=all?position.availablePackages:id(position.id)===id(positionId)?1:0 + position.availablePackages-=quantity;position.holdPackages+=quantity + const balance=value.balances.find(row=>id(row.id)===id(position.balanceId)) + balance.availablePackages-=quantity;balance.holdPackages+=quantity + } + return value } export async function run(context) { @@ -412,10 +586,8 @@ export async function run(context) { refreshedDetails.push(refreshed.data.order) } const representative = refreshedDetails[0] - const rejectedHold = await context.requestOverseas('PUT', query( - `${ORDERS}/${encodeURIComponent(id(representative.id))}/hold`, { customerId: id(representative.customerId) }), - { held: true, chargeable: true, reason: `${context.marker} 入库前禁止暂扣`, expectedVersion: Number(representative.version) }, - { 'Idempotency-Key': `${context.marker}-pre-receipt-hold` }) + const rejectedHold = await context.request('PUT', query( + `${ORDERS}/${encodeURIComponent(id(representative.id))}/hold`, { customerId: id(representative.customerId) }), { held: true, chargeable: true, reason: `${context.marker} 入库前禁止暂扣`, expectedVersion: Number(representative.version) }, undefined, { 'Idempotency-Key': `${context.marker}-pre-receipt-hold` }) context.addStep(MODULE, '入库前暂扣必须拒绝', rejectedHold, conflict(rejectedHold), trace(rejectedHold), OPTIONS) const afterHold = await detail(context, representative, '入库前暂扣拒绝后回查订单') const unchanged = success(afterHold.response) && JSON.stringify(afterHold.data?.order) === JSON.stringify(representative) @@ -440,9 +612,7 @@ export async function run(context) { { 'Idempotency-Key': nodesKey }) context.assert(MODULE, '人工节点重放版本与八行快照零漂移', nodesReplay.response, JSON.stringify(nodesReplay.data) === JSON.stringify(nodes.data), trace(nodesReplay.response), OPTIONS) - const nodesConflict = await context.requestOverseas('PUT', nodesPath, - { ...nodesBody, nodes: nodesBody.nodes.map((node, index) => index === 0 ? { ...node, nodeAt: localIso(new Date()) } : node) }, - { 'Idempotency-Key': nodesKey }, context.overseasToken) + const nodesConflict = await context.request('PUT', nodesPath, { ...nodesBody, nodes: nodesBody.nodes.map((node, index) => index === 0 ? { ...node, nodeAt: localIso(new Date()) } : node) }, context.token, { 'Idempotency-Key': nodesKey }) context.addStep(MODULE, '人工节点同键异载荷返回A0409', nodesConflict, conflict(nodesConflict), trace(nodesConflict), OPTIONS) const nodesRead = await api(context, 'GET', '回查尾程人工节点来源', nodesPath) context.assert(MODULE, '人工节点读取固定为MANUAL且不回退柜级节点', nodesRead.response, diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs index 4d1b7b095..8ca8cf005 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs @@ -1,5 +1,7 @@ import test from 'node:test' -import { relabelOutboundMarksMatch } from './phase5-overseas-fulfillment.mjs' +import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, dispatchStockConservation, verifyDepartureReadiness, verifyPartialOrderCompletion, verifyFulfillmentRootStatus, + completeReturnedException, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer, + dispatchDestinationConservation } from './phase5-overseas-fulfillment.mjs' import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' @@ -8,6 +10,28 @@ import { parseArguments } from '../admin-test-env-acceptance.mjs' const source = await readFile(fileURLToPath(new URL('./phase5-overseas-fulfillment.mjs', import.meta.url)), 'utf8') const entry = await readFile(fileURLToPath(new URL('../admin-test-env-acceptance.mjs', import.meta.url)), 'utf8') +test('route equals actual item destinations and frozen selected candidates, then conserves split deliveries',()=>{ + const fixture={planPalletIds:['10','11','12'],destinationWarehouseCodes:['ONT8','LAX9']} + const detail={route:'ONT8,LAX9',items:[{planPalletId:'10',fbaWarehouseCode:'ONT8'}, + {planPalletId:'11',destinationWarehouseCode:'LAX9'},{planPalletId:'12',fbaWarehouseCode:'ONT8'}]} + const deliveries=[{destinationWarehouseCode:'LAX9'},{destinationWarehouseCode:'ONT8'}] + assert.equal(dispatchDestinationConservation(detail,fixture),true) + assert.equal(dispatchDestinationConservation(detail,fixture,deliveries),true) + for(const mutate of [ + value=>value.detail.route='ONT8',value=>value.detail.route='ONT8,LAX9,OTHER', + value=>value.detail.route='ONT8,ONT8,LAX9',value=>value.detail.items[1].destinationWarehouseCode='OTHER', + value=>value.detail.items[1].planPalletId='99',value=>value.detail.items[1].planPalletId='10', + value=>value.fixture.destinationWarehouseCodes.push('UNSELECTED'),value=>value.deliveries.pop(), + value=>value.deliveries.push({destinationWarehouseCode:'OTHER'}), + value=>value.deliveries.push({destinationWarehouseCode:'ONT8'}), + ]) { + const value=structuredClone({detail,fixture,deliveries});mutate(value) + assert.equal(dispatchDestinationConservation(value.detail,value.fixture,value.deliveries),false) + } + assert.match(source,/dispatchDestinationConservation\(beforeEdit.data,source.dispatch\)/) + assert.match(source,/dispatchDestinationConservation\(beforeEdit.data,source.dispatch,deliveries\)/) +}) + test('phase-five fulfillment is a registered default HTTP module after the real phase-four dispatch fixture', () => { assert.match(entry, /CONFIGURED_MODULES[^\n]*'phase4-overseas-dispatch', 'phase5-overseas-fulfillment'/) assert.match(entry, /'phase5-overseas-fulfillment', \['phase4-overseas-dispatch'\]/) @@ -57,7 +81,7 @@ test('phase-five module proves the client delivery create, list and nested detai test('phase-five module proves receipt-save completion, cross-region convergence, customer visit, exception and return', () => { for (const token of [ - '/documents/RECEIPT/files', 'requestOverseasMultipart', "category: 'RECEIPT'", + '/documents/RECEIPT/files', 'requestMultipart', "category: 'RECEIPT'", 'SAVE-RECEIPT', 'RECEIPT_AUTO_COMPLETE', '/customer-visits/${encodeURIComponent(visitId)}', "visitResult: 'SATISFIED'", '/exception-orders', @@ -73,7 +97,9 @@ test('phase-five module proves receipt-save completion, cross-region convergence assert.match(source, /query\('\/api\/platform\/inbounds'/) assert.match(source, /inboundCode: inbound\.inboundCode/) assert.match(source, /inboundSummary\?\.processStatus === 'DELIVERED'/) - assert.match(source, /\/api\/platform\/v1\/orders\/\$\{encodeURIComponent\(sourceOrderId\)\}/) + assert.match(source, /verifyFulfillmentRootStatus\(context, \{ sourceOrderId, customerId, merchantToken \}\)/) + assert.match(source, /\/api\/platform\/v1\/orders\/\$\{encodeURIComponent\(rootOrderId\)\}/) + assert.match(source, /\/fulfillment-tree/) assert.match(source, /mainStatus === 'COMPLETED'/) assert.doesNotMatch(source, /platform\(context, 'POST', '确认派送完成/) assert.match(source, /manualExceptionNo/) @@ -114,3 +140,543 @@ test('manual exception retries only a transport failure with the identical busin assert.match(source, /人工异常连接失败后使用同载荷重试/) assert.match(source, /manualExceptionBody/) }) + +test('quantity snapshots ignore timestamps but preserve every inventory bucket and source identity', () => { + const original = { id: '1', inboundCode: 'IN-1', totalPackages: 5, availablePackages: 3, + holdPackages: 2, allocatedPackages: 0, pickedPackages: 0, updatedAt: 'earlier' } + assert.deepEqual(inventoryQuantitySnapshot([original]), inventoryQuantitySnapshot([{ ...original, updatedAt: 'later' }])) + for (const field of ['id', 'inboundCode', 'totalPackages', 'availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages']) { + assert.notDeepEqual(inventoryQuantitySnapshot([original]), + inventoryQuantitySnapshot([{ ...original, [field]: field === 'id' || field === 'inboundCode' ? 'different' : 99 }])) + } +}) + +function contextFor(request) { + const assertions = [] + const calls = [] + return { marker: 'TAIL-CURRENT', options: { gateway: 'https://test.invalid' }, createdResources: {}, + assertions, calls, + async request(method, path, body, token) { calls.push({ method, path, body, token }); return request(method, path, body, token) }, + apiSuccess(module, name, response) { assertions.push({ name, passed: response?.body?.code === '200' }); return response.body?.data }, + assert(module, name, response, passed) { assertions.push({ name, passed }) }, + addStep(module, name, response, passed) { assertions.push({ name, passed }) }, + addCoverageGap(module, scenario, name) { assertions.push({ name, passed: false }) }, + } +} +const ok = data => ({ http: 200, body: { code: '200', data } }) + +test('partial completion rejects different Orders and an already delivered pending fragment', async () => { + for (const second of [{ orderId: 'OTHER', processStatus: 'WAIT_SHIP' }, + { orderId: 'ORDER', processStatus: 'DELIVERED' }]) { + const context = contextFor((method, path) => ok({ records: path.includes('inboundCode=IN-A') + ? [{ inboundCode: 'IN-A', orderId: 'ORDER', processStatus: 'DELIVERED' }] + : [{ inboundCode: 'IN-B', ...second }] })) + assert.equal(await verifyPartialOrderCompletion(context, { orderId: 'ORDER', customerId: '100', + deliveredInboundCode: 'IN-A', pendingInboundCodes: ['IN-B'] }), false) + assert.equal(context.calls.some(call => call.path.startsWith('/api/platform/v1/orders/')), false) + } +}) + +test('partial completion verifies an unfinished valid sibling and catches premature COMPLETED', async () => { + for (const mainStatus of ['IN_TRANSIT', 'COMPLETED']) { + const context = contextFor((method, path) => path.startsWith('/api/platform/v1/orders/') + ? ok({ summary: { mainStatus } }) : ok({ records: [{ inboundCode: path.includes('inboundCode=IN-A') ? 'IN-A' : 'IN-B', + orderId: 'ORDER', processStatus: path.includes('inboundCode=IN-A') ? 'DELIVERED' : 'WAIT_SHIP' }] })) + assert.equal(await verifyPartialOrderCompletion(context, { orderId: 'ORDER', customerId: '100', + deliveredInboundCode: 'IN-A', pendingInboundCodes: ['IN-B'] }), mainStatus !== 'COMPLETED') + } +}) + +test('same dispatch-return exception reaches actual restored inventory and retries without another ledger row', async () => { + for (const initialStatus of ['PENDING', 'PROCESSING']) { + let completed = false + let version = initialStatus === 'PROCESSING' ? 1 : 0 + let uploads = 0 + const source = { outboundPalletItemId: '51', inventoryPositionId: '61', maxReturnPackages: 1 } + const context = contextFor((method, path, body) => { + if (path.includes('/inventory/balances/1/positions')) return ok(path.includes('palletNo=') ? (completed ? [{ id: '3', balanceId: '1', + customerId: '100', overseasWarehouseId: 'WH-1', inboundCode: 'IN-A', palletId: '71', + palletNo: 'RET-TAIL-CURRENT', warehouseLocationId: '72', totalPackages: 1, availablePackages: 1, + holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0 }] : []) + : [{ id: '61', balanceId: '1', customerId: '100', overseasWarehouseId: 'WH-1', inboundCode: 'IN-A', + palletNo: 'SOURCE', totalPackages: 8, availablePackages: 8, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0 }, + ...(completed ? [{ id: '3', balanceId: '1', customerId: '100', overseasWarehouseId: 'WH-1', inboundCode: 'IN-A', palletId: '71', + palletNo: 'RET-TAIL-CURRENT', warehouseLocationId: '72', totalPackages: 1, availablePackages: 1, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0 }] : [])]) + if (path.includes('/inventory/balances')) { const balance = { id: '1', customerId: '100', + overseasWarehouseId: 'WH-1', totalPackages: completed ? 9 : 8, + availablePackages: completed ? 9 : 8, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0, inTransitPackages: 0, procurementPackages: 0 }; return ok(path.includes('/balances/1?') ? balance : { total: 1, records: [balance] }) } + if (path.includes('/inventory/ledger')) return ok({ total: completed ? 1 : 0, records: completed ? [{ id: '2', + customerId: '100', inboundCode: 'IN-A', businessType: 'RETURN_STOCK_IN', businessOrderNo: 'EX-1', + outboundOrderNo: 'OB-1', deltaPackages: 1, beforePackages: 0, afterPackages: 1 }] : [] }) + if (path.includes('/locations')) return ok({ total: 1, records: [{ id: '72', locationCode: 'L-1' }] }) + if (method === 'POST' && path.endsWith('/pallets')) return ok('71') + if (path.endsWith('/pallets/71')) return ok({ id: '71', warehouseId: 'WH-1', palletCode: 'RET-TAIL-CURRENT', locationId: '72' }) + if (path.includes('/start-processing')) { version = 1; return ok({ status: 'PROCESSING', version }) } + if (path.includes('/complete')) { + assert.deepEqual(body.receipts[0], { outboundPalletItemId: '51', inventoryPositionId: '61', receivedPackages: 1, + targetPalletId: '71', targetPalletNo: 'RET-TAIL-CURRENT', targetLocationId: '72', targetLocationCode: 'L-1' }) + const replayed = completed; completed = true + return ok({ status: 'COMPLETED', restoredPackages: 1, inventoryAdjustmentId: '81', replayed }) + } + if (path.includes('/delivery-orders/')) return ok({ order: { status: 'RETURNED', outboundNo: 'OB-1' } }) + return ok({ order: { status: completed ? 'COMPLETED' : initialStatus, exceptionType: 'RETURN', + outboundNo: 'OB-1', inboundCode: 'IN-A', deliveryNo: 'DEL-1', version, evidenceFileIds: ['91'] }, returnSources: [source] }) + }) + context.requestMultipart = async () => { uploads += 1; return ok({ fileId: '91' }) } + context.createdResources.phase5OverseasFulfillment = { marker: context.marker, returnExceptionNo: 'EX-1', outboundNo: 'OB-1', inventoryProof: { sourceBalanceIds: ['1'] } } + const resume = initialStatus === 'PROCESSING' + ? { targetPalletId: '71', targetPalletNo: 'RET-TAIL-CURRENT', evidenceFileId: '91', completeIdempotencyKey: 'ORIGINAL-COMPLETE' } : null + const result = await completeReturnedException(context, { customerId: '100', warehouseId: 'WH-1', exceptionNo: 'EX-1', outboundNo: 'OB-1', resume }) + assert.equal(result?.restoredPackages, 1) + assert.deepEqual(context.assertions.filter(row => !row.passed), []) + assert.equal(context.calls.filter(call => call.path.includes('/complete')).length, 2) + if (initialStatus === 'PROCESSING') { + assert.equal(context.calls.some(call => call.method === 'POST' && call.path.endsWith('/pallets')), false) + assert.equal(context.calls.some(call => call.path.includes('/start-processing')), false) + assert.equal(uploads, 0) + assert.ok(context.calls.filter(call => call.path.includes('/complete')).every(call => call.body.idempotencyKey === 'ORIGINAL-COMPLETE')) + } + assert.equal(context.createdResources.phase5OverseasFulfillment.returnReceiptAttempt.evidenceFileId, '91') + } +}) + +test('PROCESSING continuation without the original pallet and evidence remains blocked without creating replacements', async () => { + const context = contextFor(() => ok({ order: { status: 'PROCESSING', exceptionType: 'RETURN', outboundNo: 'OB-1', version: 1 }, + returnSources: [{ outboundPalletItemId: '51', inventoryPositionId: '61', maxReturnPackages: 1 }] })) + context.createdResources.phase5OverseasFulfillment = { marker: context.marker, returnExceptionNo: 'EX-1', outboundNo: 'OB-1', inventoryProof: { sourceBalanceIds: ['1'] } } + assert.equal(await completeReturnedException(context, + { customerId: '100', warehouseId: 'WH-1', exceptionNo: 'EX-1', outboundNo: 'OB-1' }), null) + assert.equal(context.calls.length, 1) + assert.ok(context.assertions.some(row => !row.passed)) +}) + +test('failed same-exception completion still checks zero inventory drift and retains the original resume evidence', async () => { + const context = contextFor((method, path) => { + if (path.includes('/inventory/ledger')) return ok({ records: [], total: 0 }) + if (path.includes('/inventory/balances')) { + const balance = { id: '1', customerId: '100', overseasWarehouseId: 'WH-1', totalPackages: 1, availablePackages: 1, + holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0, inTransitPackages: 0, procurementPackages: 0 } + return ok(path.includes('/positions') ? [{ ...balance, id: '61', balanceId: '1', inboundCode: 'IN-A' }] + : path.includes('/balances/1?') ? balance : { records: [balance], total: 1 }) + } + if (path.includes('/locations')) return ok({ records: [{ id: '72', locationCode: 'L-1' }], total: 1 }) + if (path.endsWith('/pallets/71')) return ok({ id: '71', warehouseId: 'WH-1', palletCode: 'RET-TAIL-CURRENT', locationId: '72' }) + if (path.includes('/complete')) return { http: 200, body: { code: 'A0400', message: 'Source rejected' } } + return ok({ order: { status: 'PROCESSING', exceptionType: 'RETURN', outboundNo: 'OB-1', inboundCode: 'IN-A', version: 1 }, + returnSources: [{ outboundPalletItemId: '51', inventoryPositionId: '61', maxReturnPackages: 1 }] }) + }) + context.createdResources.phase5OverseasFulfillment = { marker: context.marker, returnExceptionNo: 'EX-1', outboundNo: 'OB-1', inventoryProof: { sourceBalanceIds: ['1'] } } + const resume = { targetPalletId: '71', targetPalletNo: 'RET-TAIL-CURRENT', evidenceFileId: '91', completeIdempotencyKey: 'ORIGINAL-COMPLETE' } + assert.equal(await completeReturnedException(context, + { customerId: '100', warehouseId: 'WH-1', exceptionNo: 'EX-1', outboundNo: 'OB-1', resume }), null) + assert.equal(context.calls.filter(call => call.path.includes('/inventory/balances')).length, 6) + assert.equal(context.calls.filter(call => call.path.includes('/inventory/ledger')).length, 2) + assert.ok(context.assertions.some(row => row.name === '退回实收失败后库存及流水保持不变并保留原续验资料' && row.passed)) + assert.equal(context.createdResources.phase5OverseasFulfillment.returnReceiptAttempt.evidenceFileId, '91') + assert.equal(context.createdResources.phase5OverseasFulfillment.returnReceiptAttempt.completeIdempotencyKey, 'ORIGINAL-COMPLETE') + assert.equal(context.calls.some(call => call.method === 'POST' && !call.path.includes('/complete')), false) +}) + +test('customer receipt acceptance downloads exact bytes and rejects a foreign customer without a platform fallback', async () => { + const context = contextFor((method, path, body, token) => path.endsWith('/iam/tenant/profile') + ? ok({ id: token === 'FOREIGN' ? '200' : '100' }) : token === 'FOREIGN' + ? { http: 404, body: { code: 'A0404' } } + : ok({ deliveryNo: 'DEL-1', files: [{ fileId: '91' }] })) + const previousFetch = globalThis.fetch + globalThis.fetch = async (url, options) => options.headers.Authorization === 'Bearer FOREIGN' + ? new Response(JSON.stringify({ code: 'A0404' }), { status: 404, headers: { 'content-type': 'application/json' } }) + : new Response('CURRENT-RECEIPT', { status: 200, headers: { 'content-type': 'application/octet-stream' } }) + try { + assert.equal(await verifyCustomerSignedDocuments(context, 'OWN', + [{ deliveryNo: 'DEL-1', fileId: '91', content: 'CURRENT-RECEIPT' }], 'FOREIGN'), true) + assert.deepEqual(context.assertions.filter(row => !row.passed), []) + assert.equal(context.calls.some(call => call.path.includes('/api/platform/')), false) + } finally { globalThis.fetch = previousFetch } +}) + +test('SIGNED-B fixture keeps primary options and credentials unchanged and records no password or token', async () => { + const context = contextFor((method, path, body, token) => { + assert.equal(path, '/api/auth/login') + assert.equal(token, '') + assert.deepEqual(body, { loginId: 'SECONDARY', password: 'SECONDARY-IN-MEMORY' }) + return ok({ accessToken: 'SECONDARY-TOKEN' }) + }) + context.options.customerName = 'PRIMARY' + context.context = { merchantQualificationFixture: { tenantId: '100', username: 'PRIMARY', password: 'PRIMARY-IN-MEMORY' } } + context.createdResources.phase5OverseasFulfillment = { marker: context.marker } + const primary = context.context.merchantQualificationFixture + const options = context.options + const create = async (secondary, flags) => { + assert.equal(flags.publishEnvironment, false) + assert.equal(secondary.marker, `${context.marker}-SIGNED-B`) + assert.notEqual(secondary.options, context.options) + assert.notEqual(secondary.context, context.context) + assert.notEqual(secondary.createdResources, context.createdResources) + assert.equal(secondary.options.customerName, '') + secondary.context.merchantQualificationFixture = { tenantId: '200', tenantName: 'SECONDARY', username: 'SECONDARY', + password: 'SECONDARY-IN-MEMORY', marker: secondary.marker } + // Even unexpected producer fields cannot enter the report's explicit metadata whitelist. + secondary.createdResources.merchantQualificationFixture = { ...secondary.context.merchantQualificationFixture, token: 'UNEXPECTED' } + } + assert.equal(await createSignedDocumentIsolationCustomer(context, { create }), 'SECONDARY-TOKEN') + assert.equal(context.options, options) + assert.equal(context.options.customerName, 'PRIMARY') + assert.equal(context.context.merchantQualificationFixture, primary) + assert.equal(context.context.phase5TailVerification.foreignMerchantToken, 'SECONDARY-TOKEN') + const report = JSON.stringify(context.createdResources) + assert.doesNotMatch(report, /password|token|IN-MEMORY|UNEXPECTED|SECONDARY-TOKEN/) + assert.equal(context.createdResources.phase5OverseasFulfillment.signedDocumentIsolationCustomer.marker, 'TAIL-CURRENT-SIGNED-B') + assert.deepEqual(context.assertions.filter(row => !row.passed), []) +}) + +test('incomplete SIGNED-B producer remains a blocker and never logs in as the primary customer', async () => { + const context = contextFor(() => { throw new Error('must not log in') }) + context.context = { merchantQualificationFixture: { tenantId: '100', username: 'PRIMARY', password: 'PRIMARY-IN-MEMORY' } } + context.createdResources.phase5OverseasFulfillment = { marker: context.marker } + const token = await createSignedDocumentIsolationCustomer(context, { create: async () => {} }) + assert.equal(token, null) + assert.equal(context.calls.length, 0) + assert.ok(context.assertions.some(row => !row.passed)) + assert.equal(context.context.phase5TailVerification, undefined) +}) + +function inventoryFixture() { + const position = (positionId, inboundCode, packages) => ({ id: positionId, balanceId: '1', customerId: '100', + overseasWarehouseId: 'WH-1', inboundCode, inboundOrderId: `ORDER-${inboundCode}`, palletId: `PALLET-${positionId}`, + palletNo: `P-${positionId}`, batchNo: 'B-1', warehouseLocationId: 'LOCATION-1', warehouseLocationCode: 'L-1', + totalPackages: packages, availablePackages: packages, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0 }) + return { balance: { id: '1', customerId: '100', overseasWarehouseId: 'WH-1', skuCode: 'MIXED', + totalPackages: 9, availablePackages: 9, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, + receivingPackages: 0, inTransitPackages: 4, procurementPackages: 2 }, + positions: [position('11', 'IN-A', 3), position('12', 'IN-A-1', 2), position('13', 'IN-B', 4)] } +} +function inventoryContext(fixture) { + return contextFor((method, path) => path.includes('/positions') ? ok(fixture.positions) + : path.includes('/balances/1?') ? ok(fixture.balance) : ok({ records: [fixture.balance], total: 1 })) +} + +test('inventory evidence resolves missing Balance inboundCode through exact positions and retains an independent shared balance', async () => { + const fixture = inventoryFixture() + const context = inventoryContext(fixture) + const snapshot = await inventorySnapshot(context, '100', 'WH-1', ['IN-A', 'IN-A', 'IN-B'], 'stock', ['1']) + assert.equal(snapshot.length, 1) + assert.equal(snapshot[0].id, '1') + assert.equal(snapshot[0].totalPackages, 7) + assert.deepEqual(snapshot[0].inboundCodes, ['IN-A', 'IN-B']) + assert.deepEqual(snapshot[0].matchingPositionIds, ['11', '13']) + assert.equal(snapshot[0].balance.totalPackages, 9) + assert.equal(snapshot[0].balance.inTransitPackages, 4) + assert.equal(snapshot[0].balance.procurementPackages, 2) + assert.equal(snapshot[0].positions.length, 3) + assert.deepEqual(context.assertions.filter(row => !row.passed), []) + assert.equal(context.calls.filter(call => call.path.includes('/positions')).length, 1) + const childOnly = inventoryFixture() + childOnly.positions = [childOnly.positions[1]] + childOnly.balance.totalPackages = 2; childOnly.balance.availablePackages = 2 + const childSnapshot = await inventorySnapshot(inventoryContext(childOnly), '100', 'WH-1', ['IN-A'], 'stock', ['1']) + assert.equal(childSnapshot[0].totalPackages, 0) + assert.deepEqual(childSnapshot[0].matchingPositionIds, []) +}) + +test('inventory evidence rejects foreign balances and positions, mismatched balance IDs, duplicate identities, and broken bucket reconciliation', async () => { + for (const mutate of [ + fixture => { fixture.balance.customerId = 'OTHER' }, + fixture => { fixture.balance.overseasWarehouseId = 'OTHER' }, + fixture => { fixture.positions[0].customerId = 'OTHER' }, + fixture => { fixture.positions[0].overseasWarehouseId = 'OTHER' }, + fixture => { fixture.positions[0].balanceId = 'OTHER' }, + fixture => { fixture.positions[0].id = fixture.positions[1].id }, + fixture => { fixture.positions[0].inboundCode = '' }, + fixture => { fixture.positions[0].receivingPackages = 1 }, + fixture => { fixture.balance.inTransitPackages = null }, + fixture => { delete fixture.positions[0].receivingPackages }, + fixture => { fixture.balance.availablePackages = 8 }, + ]) { + const fixture = inventoryFixture(); mutate(fixture) + const context = inventoryContext(fixture) + assert.equal(await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'stock', ['1']), null) + assert.ok(context.assertions.some(row => !row.passed)) + } +}) + +test('inventory evidence captures zero drift independently for every balance bucket, position identity, and unselected sibling position', async () => { + const fixture = inventoryFixture() + const original = await inventorySnapshot(inventoryContext(fixture), '100', 'WH-1', ['IN-A'], 'stock', ['1']) + for (const field of ['inTransitPackages', 'procurementPackages']) { + const changed = inventoryFixture(); changed.balance[field] += 1 + assert.notDeepEqual(await inventorySnapshot(inventoryContext(changed), '100', 'WH-1', ['IN-A'], 'stock', ['1']), original) + } + for (const field of ['palletId', 'palletNo', 'batchNo', 'warehouseLocationId', 'warehouseLocationCode', 'inboundOrderId']) { + const changed = inventoryFixture(); changed.positions[2][field] = 'DIFFERENT' + assert.notDeepEqual(await inventorySnapshot(inventoryContext(changed), '100', 'WH-1', ['IN-A'], 'stock', ['1']), original) + } + for (const field of ['holdPackages', 'allocatedPackages', 'pickedPackages', 'receivingPackages']) { + const changed = inventoryFixture(); changed.positions[2][field] += 1; changed.balance[field] += 1 + assert.notDeepEqual(await inventorySnapshot(inventoryContext(changed), '100', 'WH-1', ['IN-A'], 'stock', ['1']), original) + } +}) + +test('departure gate requires the PDA task COMPLETED, server canDepart, and independent matching dispatch READY_TO_DEPART', async () => { + for (const [taskStatus, canDepart, dispatchStatus, dispatchNo, expected] of [ + ['COMPLETED', true, 'READY_TO_DEPART', 'DS-1', true], + ['READY_TO_DEPART', true, 'READY_TO_DEPART', 'DS-1', false], + ['PICKING', true, 'READY_TO_DEPART', 'DS-1', false], + ['COMPLETED', false, 'READY_TO_DEPART', 'DS-1', false], + ['COMPLETED', true, 'PENDING_DEPARTURE', 'DS-1', false], + ['COMPLETED', true, 'DEPARTED', 'DS-1', false], + ['COMPLETED', true, 'READY_TO_DEPART', 'DS-OTHER', false], + ]) { + const context = contextFor((method, path) => path.startsWith('/api/pda/') + ? ok({ dispatchNo: 'DS-1', status: taskStatus, canDepart }) : ok({ dispatchNo, status: dispatchStatus })) + assert.equal(Boolean(await verifyDepartureReadiness(context, 'PDA', 'DS-1')), expected) + assert.equal(context.calls.length, 2) + assert.equal(context.calls.some(call => call.method !== 'GET'), false) + assert.equal(context.assertions.some(row => !row.passed), !expected) + } +}) + + +test('G05 freezes actual PDA balance IDs and measures raw sources independently from grouped representatives without inbound filters', async () => { + const task = { dispatchNo: 'DS-1', warehouseId: 'WH-1', items: [ + { positions: [{ id: '11', balanceId: '1' }, { id: '12', balanceId: '2' }, { id: '11', balanceId: '1' }] }] } + assert.deepEqual(dispatchInventorySources(task, 'DS-1', 'WH-1'), { sourceBalanceIds: ['1', '2'], sourcePositionIds: ['11', '12'], + sourcePositionBalances: [{ positionId: '11', balanceId: '1' }, { positionId: '12', balanceId: '2' }] }) + assert.equal(dispatchInventorySources(task, 'OTHER', 'WH-1'), null) + assert.equal(dispatchInventorySources(task, 'DS-1', 'OTHER'), null) + assert.equal(dispatchInventorySources({ ...task, items: [{ positions: [{ id: '11' }] }] }, 'DS-1', 'WH-1'), null) + let shipped = false + const source = balanceId => ({ id: balanceId, customerId: '100', overseasWarehouseId: 'WH-1', + destinationWarehouseCode: 'FBA', skuCode: 'MIXED', totalPackages: balanceId === '1' ? 3 : 6, + availablePackages: balanceId === '1' ? 3 : 6, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, + receivingPackages: 0, inTransitPackages: 0, procurementPackages: 0 }) + const context = contextFor((method, path) => { + assert.equal(new URL(`https://test.invalid${path}`).searchParams.has('inboundCode'), false) + const match = path.match(/balances\/(1|2)(\/positions)?\?/) + if (!match) return ok({ records: [{ ...source(shipped ? '99' : '1'), totalPackages: shipped ? 3 : 12, availablePackages: shipped ? 3 : 12 }], total: 1 }) + const balance = source(match[1]) + if (match[2]) return ok([{ ...balance, id: match[1] === '1' ? '11' : '12', balanceId: match[1], inboundCode: 'IN-A', + totalPackages: shipped ? 0 : balance.totalPackages, availablePackages: shipped ? 0 : balance.availablePackages }]) + return shipped ? { http: 404, body: { code: 'A0404' } } : ok(balance) + }) + const frozenSources = dispatchInventorySources(task, 'DS-1', 'WH-1') + const frozenIds = frozenSources.sourceBalanceIds + const before = await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'before', frozenIds) + assert.equal(inventorySourcesPresent(before, frozenSources.sourcePositionBalances), true) + assert.equal(inventorySourcesPresent(before, [{ positionId: '11', balanceId: '2' }]), false) + assert.equal(inventorySourcesPresent(before, [{ positionId: 'UNKNOWN', balanceId: '1' }]), false) + assert.equal(before.reduce((sum, row) => sum + row.totalPackages, 0), 9) + assert.equal(before.reduce((sum, row) => sum + row.balance.totalPackages, 0), 9) + assert.equal(before[0].globalGroups[0].totalPackages, 12) + shipped = true + const after = await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'after', frozenIds) + assert.equal(inventorySourcesPresent(after, frozenSources.sourcePositionBalances), true) + assert.equal(after.reduce((sum, row) => sum + row.totalPackages, 0), 0) + assert.equal(after.reduce((sum, row) => sum + row.balance.totalPackages, 0), 0) + assert.equal(after[0].globalGroups[0].totalPackages, 3) + assert.deepEqual(after, await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'replay', frozenIds)) + assert.deepEqual(context.assertions.filter(row => !row.passed), []) +}) + +test('source snapshot never treats unknown or incompletely proven raw 404 as zero inventory', async () => { + const missing = { http: 404, body: { code: 'A0404' } } + for (const mode of ['unknown', 'positions-failure', 'position-nonzero', 'group-not-decreased']) { + let absent = false + const fixture = inventoryFixture() + fixture.balance.inTransitPackages = 0; fixture.balance.procurementPackages = 0 + const context = contextFor((method, path) => { + if (path.includes('/positions')) { + if (absent && mode === 'positions-failure') return missing + return ok(fixture.positions.map(row => absent && mode !== 'position-nonzero' + ? { ...row, totalPackages: 0, availablePackages: 0 } : row)) + } + if (path.includes('/balances/1?')) return absent ? missing : ok(fixture.balance) + return ok({ total: absent && mode !== 'group-not-decreased' ? 0 : 1, + records: absent && mode !== 'group-not-decreased' ? [] : [fixture.balance] }) + }) + if (mode !== 'unknown') assert.ok(await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'before', ['1'])) + absent = true + assert.equal(await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'after', ['1']), null) + assert.ok(context.assertions.some(row => !row.passed)) + } + const context = contextFor(() => { throw new Error('Missing fixed IDs cannot issue HTTP reads') }) + assert.equal(await inventorySnapshot(context, '100', 'WH-1', ['IN-A'], 'stock'), null) +}) + + +function representativePalletConservationFixture() { + const quantities = [1, 3, 1, 3, 1] + const codes = ['IN-A', 'IN-A-OTHER', 'IN-B', 'IN-B-OTHER', 'IN-C'] + const references = quantities.map((_, index) => ({ balanceId: String(index + 1), positionId: String(index + 11) })) + const groups = packages => [{ id: 'GROUP', customerId: '100', overseasWarehouseId: 'WH-1', skuCode: 'MIXED', + totalPackages: packages, availablePackages: packages, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, + receivingPackages: 0, inTransitPackages: 0, procurementPackages: 0 }] + const snapshot = after => quantities.map((quantity, index) => { + const balanceId = String(index + 1) + const position = { id: String(index + 11), balanceId, customerId: '100', overseasWarehouseId: 'WH-1', + inboundCode: codes[index], palletNo: index < 2 ? 'P-A' : index < 4 ? 'P-B' : 'P-C', + totalPackages: after ? 0 : quantity, availablePackages: after ? 0 : quantity, + holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0 } + const leftover = { ...position, id: '99', palletNo: 'UNSELECTED', totalPackages: 3, availablePackages: 3 } + const positions = index === 4 ? [position, leftover] : [position] + const exact = positions.filter(row => ['IN-A', 'IN-B', 'IN-C'].includes(row.inboundCode)) + const total = positions.reduce((sum, row) => sum + row.totalPackages, 0) + return { id: balanceId, totalPackages: exact.reduce((sum, row) => sum + row.totalPackages, 0), + balance: { id: balanceId, totalPackages: total }, positions, globalGroups: groups(after ? 3 : 12) } + }) + const ledger = quantities.map((quantity, index) => ({ id: String(index + 101), customerId: '100', + overseasWarehouseId: 'WH-1', businessType: 'SHIP', businessOrderNo: 'DS-1', outboundOrderNo: 'DS-1', + inboundCode: codes[index], palletNo: index < 2 ? 'P-A' : index < 4 ? 'P-B' : 'P-C', + beforePackages: quantity, deltaPackages: -quantity, afterPackages: 0 })) + return { customerId: '100', warehouseId: 'WH-1', dispatchNo: 'DS-1', expectedPackages: 9, + sourcePositionBalances: references, stockBefore: snapshot(false), stockAfter: snapshot(true), + ledgerBefore: [], ledgerAfter: ledger } +} + +test('G05 MIN inbound-code display does not filter real same-pallet sources: positions9→0/raw12→3/groups12→3/SHIP-9', () => { + const fixture = representativePalletConservationFixture() + const context = contextFor(() => { throw new Error('Offline arithmetic cannot issue HTTP') }) + context.createdResources.phase5OverseasFulfillment = { inventoryProof: {} } + assert.equal(fixture.stockBefore.reduce((sum, row) => sum + row.totalPackages, 0), 6) + assert.equal(fixture.stockAfter.reduce((sum, row) => sum + row.totalPackages, 0), 3) + const result = dispatchStockConservation(context, fixture) + assert.equal(result.passed, true) + assert.deepEqual(result.proof.totals, { positionBefore: 9, positionAfter: 0, rawBefore: 12, rawAfter: 3, + groupBefore: 12, groupAfter: 3, ledgerShipped: 9, expectedPackages: 9 }) + assert.equal(result.proof.ledgerRowChecks.length, 5) + assert.deepEqual(context.assertions.filter(row => !row.passed), []) + const saved = context.createdResources.phase5OverseasFulfillment.inventoryProof + assert.deepEqual(saved.snapshots.beforePick, fixture.stockBefore) + assert.deepEqual(saved.ledgers.afterPick, fixture.ledgerAfter) + assert.ok(JSON.stringify(saved).includes('IN-A-OTHER')) + assert.doesNotMatch(JSON.stringify(saved), /password|accessToken|Bearer|base64/) +}) + +test('G05 failures still independently review and preserve raw, group, each SHIP quantity/source, and unselected positions', () => { + for (const mutate of [ + fixture => { fixture.stockAfter[0].positions[0].id = 'WRONG-POSITION' }, + fixture => { fixture.stockAfter[0].balance.totalPackages = 1 }, + fixture => { for (const row of fixture.stockAfter) row.globalGroups[0].totalPackages = 4 }, + fixture => { fixture.ledgerAfter[0].deltaPackages = -2 }, + fixture => { fixture.ledgerAfter[0].customerId = 'OTHER' }, + fixture => { fixture.ledgerAfter[0].overseasWarehouseId = 'OTHER' }, + fixture => { fixture.ledgerAfter[0].outboundOrderNo = 'OTHER' }, + fixture => { fixture.ledgerAfter[0].palletNo = 'UNSELECTED' }, + fixture => { fixture.stockAfter[4].positions[1].availablePackages = 2 }, + ]) { + const fixture = representativePalletConservationFixture(); mutate(fixture) + const context = contextFor(() => { throw new Error('Offline only') }) + context.createdResources.phase5OverseasFulfillment = { inventoryProof: {} } + const result = dispatchStockConservation(context, fixture) + assert.equal(result.passed, false) + assert.equal(context.assertions.length, 9) + assert.equal(Object.keys(result.proof.checks).length, 8) + assert.equal(result.proof.ledgerRowChecks.length, 5) + assert.deepEqual(context.createdResources.phase5OverseasFulfillment.inventoryProof.snapshots.afterPick, fixture.stockAfter) + assert.deepEqual(context.createdResources.phase5OverseasFulfillment.inventoryProof.ledgers.afterPick, fixture.ledgerAfter) + } +}) + +function rootCompletionFixture({ childStatus = 'PROCESSING', rootStatus = 'PROCESSING', rootDelivered = true, + fullyLoaded = false, mainStatus = rootStatus === 'COMPLETED' ? 'COMPLETED' : 'DELIVERED', sourceOrderId = '1001' } = {}) { + const root = { order: { id: '1001', tenantId: '100', customerId: '100', orderRole: 'MASTER', rootOrderId: '1001', + orderNo: 'FL1001', status: rootStatus }, statusHistory: [ + ...(rootDelivered ? [{ action: 'OVERSEAS_DELIVERED' }] : []), + ...(rootStatus === 'COMPLETED' ? [{ action: 'FULFILLMENT_COMPLETED' }] : [])] } + const child = { order: { id: '1002', tenantId: '100', customerId: '100', orderRole: 'CHILD', + rootOrderId: '1001', parentOrderId: '1001', orderNo: 'FL1001-1', status: childStatus } } + const tree = { parentOrderId: '1001', parentOrderNo: 'FL1001', originalPackageCount: 4, + cumulativeLoadedPackageCount: fullyLoaded ? 4 : 1, currentRemainderPackageCount: fullyLoaded ? 0 : 3, + children: [{ orderId: '1001', orderNo: 'FL1001', splitRole: 'LOADED', sourceOrderId: '1001', packageCount: 1 }, + { orderId: '1002', orderNo: 'FL1001-1', splitRole: 'REMAINDER', sourceOrderId: '1001', packageCount: 3 }, + ...(fullyLoaded ? [{ orderId: '1002', orderNo: 'FL1001-1', splitRole: 'LOADED', sourceOrderId: '1002', packageCount: 3 }] : [])] } + const context = contextFor((method, path) => { + if (path.includes('/fulfillment-tree')) return ok(tree) + if (path.includes('/first-leg/1002')) return ok(child) + if (path.includes('/first-leg/1001')) return ok(root) + return ok({ summary: { orderId: '1001', mainStatus } }) + }) + context.createdResources.phase5OverseasFulfillment = {} + return { root, child, tree, context, + args: { sourceOrderId, customerId: '100', merchantToken: 'ONLY-IN-MEMORY', pollAttempts: 1, pollIntervalMs: 0 } } +} + +test('real root1 signed plus effective remainder3 keeps raw PROCESSING and both platform/client DELIVERED', async () => { + const fixture = rootCompletionFixture() + const result = await verifyFulfillmentRootStatus(fixture.context, fixture.args) + assert.equal(result.passed, true) + assert.equal(result.expectedCompleted, false) + assert.deepEqual(result.pendingChildren.map(row => row.orderId), ['1002']) + assert.deepEqual(fixture.context.assertions.filter(row => !row.passed), []) + const proof = fixture.context.createdResources.phase5OverseasFulfillment.orderCompletionProof + assert.equal(proof.tree.originalPackageCount, 4) + assert.equal(proof.tree.currentRemainderPackageCount, 3) + assert.deepEqual(proof.pendingChildOrderIds, ['1002']) + assert.doesNotMatch(JSON.stringify(proof), /ONLY-IN-MEMORY|password|accessToken|Bearer/) +}) + +test('missing, foreign, unknown or omitted real child cannot be treated as all fulfillment completed', async () => { + for (const mutate of [ + fixture => { fixture.child.order = null }, + fixture => { fixture.child.order.customerId = 'FOREIGN' }, + fixture => { fixture.child.order.rootOrderId = 'OTHER' }, + fixture => { fixture.child.order.parentOrderId = 'OTHER' }, + fixture => { fixture.child.order.orderNo = 'WRONG' }, + fixture => { fixture.child.order.status = undefined }, + fixture => { fixture.child.order.status = 'UNKNOWN' }, + fixture => { fixture.tree.children.pop() }, + fixture => { fixture.tree.children = undefined }, + ]) { + const fixture = rootCompletionFixture(); mutate(fixture) + assert.equal(await verifyFulfillmentRootStatus(fixture.context, fixture.args), null) + assert.ok(fixture.context.assertions.some(row => !row.passed)) + assert.equal(fixture.context.calls.some(call => call.path.startsWith('/api/merchant/')), false) + } +}) + +test('draft, rejected and pending child stay effective, and a premature root COMPLETED still fails', async () => { + for (const childStatus of ['DRAFT', 'SUBMITTED', 'APPROVED', 'ACCEPTED', 'REJECTED', 'PROCESSING']) { + const fixture = rootCompletionFixture({ childStatus, rootStatus: 'COMPLETED' }) + const result = await verifyFulfillmentRootStatus(fixture.context, fixture.args) + assert.equal(result.expectedCompleted, false) + assert.equal(result.passed, false) + assert.equal(result.pendingChildren.length, 1) + assert.ok(fixture.context.assertions.some(row => !row.passed)) + } +}) + +test('actual completed child or explicit CANCELLED permits completion only with root own receipt and raw completion fact', async () => { + for (const childStatus of ['COMPLETED', 'CANCELLED']) { + const fixture = rootCompletionFixture({ childStatus, rootStatus: 'COMPLETED', fullyLoaded: childStatus === 'COMPLETED' }) + const result = await verifyFulfillmentRootStatus(fixture.context, fixture.args) + assert.equal(result.expectedCompleted, true) + assert.equal(result.passed, true) + assert.deepEqual(result.pendingChildren, []) + assert.deepEqual(fixture.context.assertions.filter(row => !row.passed), []) + } + const fixture = rootCompletionFixture({ childStatus: 'COMPLETED', fullyLoaded: true, rootDelivered: false }) + const result = await verifyFulfillmentRootStatus(fixture.context, fixture.args) + assert.equal(result.expectedCompleted, false) + assert.equal(result.passed, true) +}) + +test('receipt of a real child resolves and checks its owning root instead of completing the child as the aggregate', async () => { + const fixture = rootCompletionFixture({ childStatus: 'COMPLETED', rootStatus: 'COMPLETED', fullyLoaded: true, sourceOrderId: '1002' }) + const result = await verifyFulfillmentRootStatus(fixture.context, fixture.args) + assert.equal(result.rootOrderId, '1001') + assert.equal(result.passed, true) + assert.ok(fixture.context.calls.some(call => call.path === '/api/merchant/v1/orders/1001')) + assert.equal(fixture.context.calls.some(call => call.path === '/api/merchant/v1/orders/1002'), false) +}) + +test('all fragments completed still requires raw aggregate history and matching platform/customer completion', async () => { + const missingHistory = rootCompletionFixture({ childStatus: 'COMPLETED', rootStatus: 'COMPLETED', fullyLoaded: true }) + missingHistory.root.statusHistory = [{ action: 'OVERSEAS_DELIVERED' }] + assert.equal((await verifyFulfillmentRootStatus(missingHistory.context, missingHistory.args)).passed, false) + const notProjected = rootCompletionFixture({ childStatus: 'COMPLETED', rootStatus: 'COMPLETED', fullyLoaded: true, mainStatus: 'DELIVERED' }) + assert.equal((await verifyFulfillmentRootStatus(notProjected.context, notProjected.args)).passed, false) + const foreignProjection = rootCompletionFixture({ childStatus: 'COMPLETED', rootStatus: 'COMPLETED', fullyLoaded: true }) + const originalRequest = foreignProjection.context.request + foreignProjection.context.request = async (...args) => args[1].startsWith('/api/merchant/') + ? ok({ summary: { orderId: '1002', mainStatus: 'COMPLETED' } }) : originalRequest(...args) + assert.equal((await verifyFulfillmentRootStatus(foreignProjection.context, foreignProjection.args)).passed, false) +}) diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs index 7e0d38940..559961135 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs @@ -1,4 +1,6 @@ import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs' +import { run as createMerchant } from './merchant-qualification-fixture.mjs' +import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './fleet-trip-fulfillment.mjs' const MODULE = 'phase5-overseas-fulfillment' const SCENARIO = 'phase5-dispatch-outbound-delivery-exception-visit' @@ -33,17 +35,17 @@ function blocker(context, name, reason, response = null) { context.assert(MODULE, `${name}覆盖前置`, response, false, `${reason}; ${trace(response)}`, OPTIONS) } async function platform(context, method, name, path, body = undefined) { - const response = await context.requestOverseas(method, path, body) + const response = await context.request(method, path, body) const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) return { response, data } } async function pda(context, token, method, name, path, body = undefined) { - const response = await context.requestOverseas(method, path, body, {}, token) + const response = await context.request(method, path, body, token, {}) context.addStep(MODULE, name, response, success(response), trace(response), OPTIONS) return { response, data: success(response) ? response.body?.data : null } } async function merchant(context, token, method, name, path, body = undefined) { - const response = await context.requestOverseas(method, path, body, {}, token) + const response = await context.request(method, path, body, token, {}) const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) return { response, data } } @@ -56,6 +58,25 @@ function deliveryNumbers(data) { return transferDeliveries(data).map((value) => id(value.deliveryNo)).sort() } +export function dispatchDestinationConservation(detail, fixture, deliveries = null) { + const codes = values => values.map(value => id(value).trim().toUpperCase()) + const sameSet = (left,right) => left.length > 0 && right.length > 0 && left.every(nonBlank) + && right.every(nonBlank) && JSON.stringify([...new Set(left)].sort()) === JSON.stringify([...new Set(right)].sort()) + const items = detail?.items + if (!Array.isArray(items) || !items.length || !Array.isArray(fixture?.planPalletIds) + || !Array.isArray(fixture?.destinationWarehouseCodes) || !nonBlank(detail?.route)) return false + const actualIds = items.map(row => id(row.planPalletId)), expectedIds=fixture.planPalletIds.map(id) + if (!actualIds.every(nonBlank) || actualIds.length !== expectedIds.length + || new Set(actualIds).size !== actualIds.length || !sameSet(actualIds,expectedIds)) return false + const actual = codes(items.map(row => row.fbaWarehouseCode || row.destinationWarehouseCode)) + const frozen = codes(fixture.destinationWarehouseCodes), route = codes(detail.route.split(',')) + if (!sameSet(actual,frozen) || !sameSet(route,actual) || route.length !== new Set(route).size) return false + if (deliveries == null) return true + if (!Array.isArray(deliveries) || !deliveries.length) return false + const delivered=codes(deliveries.map(row => row.destinationWarehouseCode)) + return delivered.length === new Set(delivered).size && sameSet(delivered,actual) +} + async function poll(action, predicate, attempts = 45, intervalMs = 1_000) { let result = null for (let attempt = 1; attempt <= attempts; attempt += 1) { @@ -66,6 +87,497 @@ async function poll(action, predicate, attempts = 45, intervalMs = 1_000) { return { result, attempts } } +async function allPages(context, path, values, name) { + const rows = [] + for (let page = 1; page <= 50; page += 1) { + const current = await platform(context, 'GET', `${name}第${page}页`, query(path, { ...values, page, size: 200 })) + if (!success(current.response) || !Array.isArray(current.data?.records)) return null + rows.push(...current.data.records) + const total = Number(current.data.total) + if (Number.isInteger(total) && rows.length >= total) { + const complete = rows.length === total && rows.every(row => nonBlank(id(row.id))) + && new Set(rows.map(row => id(row.id))).size === rows.length + context.assert(MODULE, `${name}无分页遗漏或重复`, current.response, + complete, + `rows=${rows.length}, total=${total}`, OPTIONS) + return complete ? rows : null + } + if (current.data.records.length === 0) break + } + blocker(context, name, '分页总数缺失或未读完,不将截断记录当作完整库存证据') + return null +} + +const POSITION_BUCKETS = ['totalPackages', 'availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages', 'receivingPackages'] +const BALANCE_BUCKETS = [...POSITION_BUCKETS, 'inTransitPackages', 'procurementPackages'] +const INVENTORY_IDENTITIES = ['id', 'balanceId', 'customerId', 'overseasWarehouseId', 'destinationWarehouseId', + 'destinationWarehouseCode', 'inboundOrderId', 'inboundOrderNo', 'inboundCode', 'skuCode', 'palletId', 'palletNo', + 'batchNo', 'warehouseLocationId', 'warehouseLocationCode'] + +export function inventoryQuantitySnapshot(rows) { + return (rows || []).map(row => Object.fromEntries([ + ...INVENTORY_IDENTITIES.filter(field => row[field] !== undefined).map(field => [field, id(row[field])]), + ...BALANCE_BUCKETS.filter(field => row[field] !== undefined).map(field => [field, Number(row[field])]), + ])).sort((a, b) => a.id.localeCompare(b.id)) +} + +const balanceDimension = row => JSON.stringify([id(row.customerId), id(row.overseasWarehouseId), + id(row.destinationWarehouseId), id(row.destinationWarehouseCode), id(row.skuCode)]) +const validBuckets = (row, fields) => fields.every(field => row[field] !== null && row[field] !== undefined + && row[field] !== '' && Number.isSafeInteger(Number(row[field])) && Number(row[field]) >= 0) + +export async function inventorySnapshot(context, customerId, warehouseId, inboundCodes, name, sourceBalanceIds = []) { + const codes = new Set(inboundCodes) + const sourceIds = [...new Set(sourceBalanceIds.map(id))].sort() + if (!sourceIds.length || sourceIds.some(value => !/^[1-9][0-9]*$/.test(value))) { + blocker(context, name, '需要本轮正式PDA Task.positions冻结的全部真实balance IDs;分组代表ID不能冒充完整来源') + return null + } + const inventoryEvidence = context.createdResources?.phase5OverseasFulfillment + if (inventoryEvidence) { + inventoryEvidence.inventoryReadEvidence ||= {} + inventoryEvidence.inventoryReadEvidence[name] = { customerId, warehouseId, inboundCodes: [...codes], + sourceBalanceIds: sourceIds, complete: false, globalGroups: null, sourceBalances: [] } + } + const readEvidence = inventoryEvidence?.inventoryReadEvidence?.[name] + const groups = await allPages(context, `${LASTLEG_BASE}/inventory/balances`, + { customerId, overseasWarehouseId: warehouseId }, `${name}-客户仓全部分组余额`) + if (!groups) return null + const groupValid = groups.every(row => id(row.customerId) === customerId + && id(row.overseasWarehouseId) === warehouseId && validBuckets(row, BALANCE_BUCKETS)) + && new Set(groups.map(balanceDimension)).size === groups.length + context.assert(MODULE, `${name}完整分组客户仓数量及维度唯一`, null, groupValid, `groups=${groups.length}`, OPTIONS) + if (!groupValid) return null + if (readEvidence) readEvidence.globalGroups = inventoryQuantitySnapshot(groups) + context.context ||= {} + context.context.phase5InventorySources ||= {} + const frozen = context.context.phase5InventorySources + const snapshot = [] + for (const balanceId of sourceIds) { + const sourceKey = `${customerId}:${warehouseId}:${balanceId}` + const detailResponse = await context.request('GET', query(`${LASTLEG_BASE}/inventory/balances/${encodeURIComponent(balanceId)}`, { customerId })) + const missingZero = detailResponse.http === 404 || (detailResponse.http === 200 && code(detailResponse) === 'A0404') + if (!success(detailResponse) && !missingZero) { + context.apiSuccess(MODULE, `${name}读取冻结真实余额`, detailResponse, trace(detailResponse), OPTIONS) + return null + } + const current = await platform(context, 'GET', `${name}读取冻结余额实际全部位置`, + query(`${LASTLEG_BASE}/inventory/balances/${encodeURIComponent(balanceId)}/positions`, { customerId })) + const positions = current.data + const valid = success(current.response) && Array.isArray(positions) + && new Set(positions.map(row => id(row.id))).size === positions.length + && positions.every(row => nonBlank(id(row.id)) && id(row.balanceId) === balanceId + && id(row.customerId) === customerId && id(row.overseasWarehouseId) === warehouseId + && nonBlank(row.inboundCode) && validBuckets(row, POSITION_BUCKETS)) + context.assert(MODULE, `${name}位置身份及完整数量有效`, current.response, valid, + `balanceId=${balanceId}, positions=${Array.isArray(positions) ? positions.length : 'missing'}`, OPTIONS) + if (!valid) return null + let balance = detailResponse.body?.data + if (missingZero) { + const prior = frozen[sourceKey] + const currentGroup = prior && groups.find(row => balanceDimension(row) === balanceDimension(prior.balance)) + const zeroProof = prior && positions.every(row => POSITION_BUCKETS.every(field => Number(row[field]) === 0)) + && ['inTransitPackages', 'procurementPackages'].every(field => Number(prior.balance[field]) === 0) + && BALANCE_BUCKETS.every(field => Number(currentGroup?.[field] || 0) + <= Number(prior.group?.[field] || 0) - Number(prior.balance[field])) + context.assert(MODULE, `${name}零余额排除由原ID位置及完整分组共同证实`, detailResponse, Boolean(zeroProof), + `balanceId=${balanceId}, retainedSource=${Boolean(prior)}, zeroPositions=${positions.length}`, OPTIONS) + if (!zeroProof) return null + balance = { ...prior.balance, ...Object.fromEntries(BALANCE_BUCKETS.map(field => [field, 0])) } + } else { + const owned = id(balance?.id) === balanceId && id(balance?.customerId) === customerId + && id(balance?.overseasWarehouseId) === warehouseId && validBuckets(balance, BALANCE_BUCKETS) + context.assert(MODULE, `${name}真实余额客户仓及完整数量有效`, detailResponse, owned, `balanceId=${balanceId}`, OPTIONS) + if (!owned) return null + const group = groups.find(row => balanceDimension(row) === balanceDimension(balance)) + const included = Boolean(group) && BALANCE_BUCKETS.every(field => Number(group[field]) >= Number(balance[field])) + context.assert(MODULE, `${name}真实非零余额包含于独立完整分组`, detailResponse, included, `balanceId=${balanceId}`, OPTIONS) + if (!included) return null + frozen[sourceKey] = { balance: inventoryQuantitySnapshot([balance])[0], group: inventoryQuantitySnapshot([group])[0] } + } + if (readEvidence) readEvidence.sourceBalances.push({ balance: inventoryQuantitySnapshot([balance])[0], positions: inventoryQuantitySnapshot(positions) }) + const reconciled = POSITION_BUCKETS.every(field => positions.reduce((sum, row) => sum + Number(row[field]), 0) === Number(balance[field])) + context.assert(MODULE, `${name}全部位置独立核平原余额`, current.response, reconciled, + `balanceId=${balanceId}, balanceTotal=${balance.totalPackages}`, OPTIONS) + if (!reconciled) return null + const exact = positions.filter(row => codes.has(row.inboundCode)) + snapshot.push({ id: balanceId, customerId, overseasWarehouseId: warehouseId, + inboundCodes: [...new Set(exact.map(row => row.inboundCode))].sort(), + ...Object.fromEntries(POSITION_BUCKETS.map(field => [field, exact.reduce((sum, row) => sum + Number(row[field]), 0)])), + balance: inventoryQuantitySnapshot([balance])[0], positions: inventoryQuantitySnapshot(positions), + matchingPositionIds: exact.map(row => id(row.id)).sort(), globalGroups: inventoryQuantitySnapshot(groups) }) + } + const jointlyIncluded = groups.every(group => BALANCE_BUCKETS.every(field => snapshot + .filter(row => balanceDimension(row.balance) === balanceDimension(group)) + .reduce((sum, row) => sum + Number(row.balance[field]), 0) <= Number(group[field]))) + context.assert(MODULE, `${name}固定原始余额合计不超过独立分组`, null, jointlyIncluded, + `rawBalances=${snapshot.length}, groups=${groups.length}`, OPTIONS) + if (readEvidence) readEvidence.complete = jointlyIncluded + return jointlyIncluded ? snapshot : null +} + +export function dispatchInventorySources(task, dispatchNo, warehouseId) { + if (task?.dispatchNo !== dispatchNo || id(task.warehouseId) !== warehouseId + || !Array.isArray(task.items) || !task.items.length) return null + const positions = task.items.flatMap(item => Array.isArray(item.positions) ? item.positions : []) + if (!positions.length || positions.some(row => !/^[1-9][0-9]*$/.test(id(row.id)) || !/^[1-9][0-9]*$/.test(id(row.balanceId)))) return null + const references = new Map() + for (const row of positions) { + const previous = references.get(id(row.id)) + if (previous && previous !== id(row.balanceId)) return null + references.set(id(row.id), id(row.balanceId)) + } + return { sourceBalanceIds: [...new Set(positions.map(row => id(row.balanceId)))].sort(), + sourcePositionIds: [...references.keys()].sort(), + sourcePositionBalances: [...references].map(([positionId, balanceId]) => ({ positionId, balanceId })) + .sort((left, right) => left.positionId.localeCompare(right.positionId)) } +} + +export function inventorySourcesPresent(snapshot, references) { + return Array.isArray(snapshot) && references.every(reference => snapshot + .find(row => row.id === reference.balanceId)?.positions.some(position => position.id === reference.positionId + && position.balanceId === reference.balanceId)) +} + +async function shipmentLedger(context, customerId, outboundNo, name, businessType = 'SHIP', businessOrderNo = undefined) { + const rows = await allPages(context, `${LASTLEG_BASE}/inventory/ledger`, + { customerId, outboundOrderNo: outboundNo, businessType, businessOrderNo }, name) + if (!rows) return null + const exact = rows.filter(row => id(row.customerId) === customerId + && row.outboundOrderNo === outboundNo && row.businessType === businessType + && (!businessOrderNo || row.businessOrderNo === businessOrderNo)) + context.assert(MODULE, `${name}只属于本轮实际出库来源`, null, exact.length === rows.length, + `rows=${rows.length}, exact=${exact.length}, outboundNo=${outboundNo}`, OPTIONS) + const quantities = rows.map(row => ({ id: id(row.id), customerId: id(row.customerId), + overseasWarehouseId: id(row.overseasWarehouseId), inboundOrderId: id(row.inboundOrderId), + inboundOrderNo: id(row.inboundOrderNo), inboundCode: id(row.inboundCode), palletNo: id(row.palletNo), + batchNo: id(row.batchNo), skuCode: id(row.skuCode), businessType: id(row.businessType), + businessOrderNo: id(row.businessOrderNo), outboundOrderNo: id(row.outboundOrderNo), + deltaPackages: Number(row.deltaPackages), beforePackages: Number(row.beforePackages), afterPackages: Number(row.afterPackages) })) + .sort((a, b) => a.id.localeCompare(b.id)) + const owned = context.createdResources?.phase5OverseasFulfillment + if (owned) { + owned.ledgerReadEvidence ||= {} + owned.ledgerReadEvidence[name] = { customerId, outboundNo, businessType, businessOrderNo: businessOrderNo || null, + complete: exact.length === rows.length, rows: quantities } + } + return exact.length === rows.length ? quantities : null +} + +const quantityTotal = rows => (rows || []).reduce((sum, row) => sum + Number(row.totalPackages), 0) +const groupQuantityTotal = rows => (rows?.[0]?.globalGroups || []).reduce((sum, row) => sum + Number(row.totalPackages), 0) +const balanceQuantityTotal = rows => (rows || []).reduce((sum, row) => sum + Number(row.balance.totalPackages), 0) +const sameSnapshot = (left, right) => left !== null && right !== null && JSON.stringify(left) === JSON.stringify(right) + +export function dispatchStockConservation(context, { customerId, warehouseId, dispatchNo, expectedPackages, + sourcePositionBalances, stockBefore, stockAfter, ledgerBefore, ledgerAfter }) { + const positions = snapshot => (snapshot || []).flatMap(row => row.positions || []) + const referenceKeys = new Set(sourcePositionBalances.map(row => `${row.balanceId}:${row.positionId}`)) + const selected = snapshot => positions(snapshot).filter(row => referenceKeys.has(`${row.balanceId}:${row.id}`)) + const positionBefore = selected(stockBefore) + const positionAfter = selected(stockAfter) + const newLedger = ledgerAfter?.filter(row => !ledgerBefore?.some(before => before.id === row.id)) || [] + const rowChecks = newLedger.map(row => ({ id: row.id, + identity: row.customerId === customerId && row.overseasWarehouseId === warehouseId + && row.businessType === 'SHIP' && row.businessOrderNo === dispatchNo && row.outboundOrderNo === dispatchNo + && positionBefore.some(position => position.palletNo === row.palletNo), + quantities: [row.beforePackages, row.deltaPackages, row.afterPackages].every(Number.isSafeInteger) + && row.deltaPackages < 0 && row.afterPackages - row.beforePackages === row.deltaPackages })) + const identity = row => Object.fromEntries(INVENTORY_IDENTITIES.filter(field => row[field] !== undefined).map(field => [field, row[field]])) + const sourcesStable = positionBefore.every(row => { + const after = positionAfter.find(value => value.id === row.id && value.balanceId === row.balanceId) + return after && JSON.stringify(identity(row)) === JSON.stringify(identity(after)) + }) + const unselectedBefore = positions(stockBefore).filter(row => !referenceKeys.has(`${row.balanceId}:${row.id}`)) + const unselectedAfter = positions(stockAfter).filter(row => !referenceKeys.has(`${row.balanceId}:${row.id}`)) + const totals = { positionBefore: quantityTotal(positionBefore), positionAfter: quantityTotal(positionAfter), + rawBefore: balanceQuantityTotal(stockBefore), rawAfter: balanceQuantityTotal(stockAfter), + groupBefore: groupQuantityTotal(stockBefore), groupAfter: groupQuantityTotal(stockAfter), + ledgerShipped: -newLedger.reduce((sum, row) => sum + row.deltaPackages, 0), expectedPackages } + const checks = { + fixedSourcesPresent: inventorySourcesPresent(stockBefore, sourcePositionBalances) + && inventorySourcesPresent(stockAfter, sourcePositionBalances), + sourceIdentitiesStable: sourcesStable, + unselectedPositionsUnchanged: sameSnapshot(inventoryQuantitySnapshot(unselectedBefore), inventoryQuantitySnapshot(unselectedAfter)), + positionConservation: expectedPackages > 0 && totals.positionBefore - totals.positionAfter === expectedPackages, + rawConservation: stockBefore !== null && stockAfter !== null && totals.rawBefore - totals.rawAfter === expectedPackages, + groupConservation: stockBefore !== null && stockAfter !== null && totals.groupBefore - totals.groupAfter === expectedPackages, + ledgerSourceIdentities: ledgerBefore !== null && ledgerAfter !== null && newLedger.length > 0 + && new Set(newLedger.map(row => row.id)).size === newLedger.length && rowChecks.every(row => row.identity), + ledgerQuantities: newLedger.length > 0 && totals.ledgerShipped === expectedPackages && rowChecks.every(row => row.quantities), + } + const proof = { sourcePositionBalances, totals, checks, ledgerRowChecks: rowChecks, + snapshots: { beforePick: stockBefore, afterPick: stockAfter }, ledgers: { beforePick: ledgerBefore, afterPick: ledgerAfter } } + if (context.createdResources?.phase5OverseasFulfillment) { + Object.assign(context.createdResources.phase5OverseasFulfillment.inventoryProof, proof) + } + for (const [name, passed] of Object.entries(checks)) { + context.assert(MODULE, `本排车库存独立核对-${name}`, null, passed, JSON.stringify(totals), OPTIONS) + } + const passed = Object.values(checks).every(Boolean) + context.assert(MODULE, 'PDA扫码及重放实际库存只扣本出库数量一次', null, passed, JSON.stringify(totals), OPTIONS) + return { passed, proof, newLedger } +} + +// Completion follows the formal root and every real child; a signed shipment alone is not the aggregate. +export async function verifyFulfillmentRootStatus(context, { sourceOrderId, customerId, merchantToken, + pollAttempts = 45, pollIntervalMs = 1_000 }) { + const rawPath = orderId => query(`/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}`, { tenantId: customerId }) + const source = await platform(context, 'GET', '读取签收来源原始订单履约归属', rawPath(sourceOrderId)) + const sourceOrder = source.data?.order + const rootOrderId = sourceOrder?.orderRole === 'MASTER' ? id(sourceOrder.id) : id(sourceOrder?.rootOrderId) + const sourceOwned = success(source.response) && id(sourceOrder?.id) === sourceOrderId + && id(sourceOrder?.tenantId) === customerId && id(sourceOrder?.customerId) === customerId + && /^[1-9][0-9]*$/.test(rootOrderId) + && (sourceOrder.orderRole === 'MASTER' || (sourceOrder.orderRole === 'CHILD' && id(sourceOrder.parentOrderId) === rootOrderId)) + context.assert(MODULE, '签收来源订单属于本企业及唯一正式履约根', source.response, sourceOwned, `sourceOrderId=${sourceOrderId}, rootOrderId=${rootOrderId}`, OPTIONS) + if (!sourceOwned) return null + let root = rootOrderId === sourceOrderId ? source : await platform(context, 'GET', '读取原始履约根订单', rawPath(rootOrderId)) + const treeResult = await platform(context, 'GET', '读取签收后的完整正式履约树', + query(`/api/platform/v1/orders/${encodeURIComponent(rootOrderId)}/fulfillment-tree`, { tenantId: customerId })) + const tree = treeResult.data + const rows = Array.isArray(tree?.children) ? tree.children : [] + const treeOrderIds = new Set([rootOrderId, ...rows.map(row => id(row.orderId))]) + const consumed = new Set(rows.filter(row => row.splitRole === 'LOADED').map(row => id(row.sourceOrderId))) + const loaded = rows.filter(row => row.splitRole === 'LOADED').reduce((sum, row) => sum + Number(row.packageCount), 0) + const remaining = rows.filter(row => row.splitRole === 'REMAINDER' && !consumed.has(id(row.orderId))) + .reduce((sum, row) => sum + Number(row.packageCount), 0) + const treeComplete = success(root.response) && success(treeResult.response) + && id(root.data?.order?.id) === rootOrderId && root.data.order.orderRole === 'MASTER' + && id(root.data.order.tenantId) === customerId && id(root.data.order.customerId) === customerId + && Array.isArray(root.data.statusHistory) + && id(tree?.parentOrderId) === rootOrderId && tree.parentOrderNo === root.data.order.orderNo + && Array.isArray(tree.children) + && ['originalPackageCount', 'cumulativeLoadedPackageCount', 'currentRemainderPackageCount'].every(field => + tree[field] !== null && tree[field] !== undefined && Number.isSafeInteger(Number(tree[field])) && Number(tree[field]) >= 0) + && loaded === Number(tree.cumulativeLoadedPackageCount) && remaining === Number(tree.currentRemainderPackageCount) + && loaded + remaining === Number(tree.originalPackageCount) + && rows.every(row => /^[1-9][0-9]*$/.test(id(row.orderId)) && /^[1-9][0-9]*$/.test(id(row.sourceOrderId)) + && treeOrderIds.has(id(row.sourceOrderId)) + && nonBlank(row.orderNo) && ['LOADED', 'REMAINDER'].includes(row.splitRole) + && (row.splitRole === 'LOADED' ? id(row.orderId) === id(row.sourceOrderId) : id(row.orderId) !== id(row.sourceOrderId)) + && Number.isSafeInteger(Number(row.packageCount)) && Number(row.packageCount) > 0) + && (sourceOrderId === rootOrderId || rows.some(row => id(row.orderId) === sourceOrderId)) + context.assert(MODULE, '履约根及全部分片身份和树数量完整守恒', treeResult.response, treeComplete, + `rootOrderId=${rootOrderId}, loaded=${loaded}, remaining=${remaining}, original=${tree?.originalPackageCount ?? 'missing'}`, OPTIONS) + if (!treeComplete) return null + const children = [] + const statuses = ['DRAFT', 'SUBMITTED', 'APPROVED', 'ACCEPTED', 'REJECTED', 'CANCELLED', 'PROCESSING', 'COMPLETED'] + for (const childId of [...new Set(rows.map(row => id(row.orderId)).filter(value => value !== rootOrderId))]) { + const current = await platform(context, 'GET', '逐项读取真实履约子单完成事实', rawPath(childId)) + const child = current.data?.order + const owned = success(current.response) && id(child?.id) === childId && id(child?.tenantId) === customerId + && id(child?.customerId) === customerId && child?.orderRole === 'CHILD' + && id(child?.rootOrderId) === rootOrderId && id(child?.parentOrderId) === rootOrderId + && rows.filter(row => id(row.orderId) === childId).every(row => row.orderNo === child.orderNo) + && statuses.includes(child?.status) + context.assert(MODULE, '真实履约子单存在且客户根身份和状态有效', current.response, owned, `childOrderId=${childId}`, OPTIONS) + if (!owned) return null + children.push({ orderId: childId, orderNo: child.orderNo, status: child.status, rootOrderId }) + } + const pendingChildren = children.filter(child => !['COMPLETED', 'CANCELLED'].includes(child.status)) + const rootDelivered = root.data.statusHistory?.some(row => row.action === 'OVERSEAS_DELIVERED') === true + const expectedCompleted = rootDelivered && pendingChildren.length === 0 + const owned = context.createdResources.phase5OverseasFulfillment + if (owned) owned.orderCompletionProof = { customerId, sourceOrderId, rootOrderId, + tree: { parentOrderId: rootOrderId, parentOrderNo: tree.parentOrderNo, originalPackageCount: Number(tree.originalPackageCount), + cumulativeLoadedPackageCount: loaded, currentRemainderPackageCount: remaining, + children: rows.map(row => ({ orderId: id(row.orderId), orderNo: row.orderNo, sourceOrderId: id(row.sourceOrderId), + splitRole: row.splitRole, packageCount: Number(row.packageCount) })) }, + children, rootDelivered, expectedCompleted, pendingChildOrderIds: pendingChildren.map(child => child.orderId) } + const domestic = await poll(() => context.request('GET', + query(`/api/platform/v1/orders/${encodeURIComponent(rootOrderId)}`, { tenantId: customerId })), + response => success(response) && id(response.body?.data?.summary?.orderId) === rootOrderId + && (expectedCompleted ? response.body.data.summary.mainStatus === 'COMPLETED' + : rootDelivered ? response.body.data.summary.mainStatus === 'DELIVERED' + : nonBlank(response.body.data.summary.mainStatus) && response.body.data.summary.mainStatus !== 'COMPLETED'), pollAttempts, pollIntervalMs) + if (expectedCompleted) root = await platform(context, 'GET', '全部有效分片结束后回查原始履约根', rawPath(rootOrderId)) + const rawPassed = success(root.response) && (expectedCompleted + ? root.data?.order?.status === 'COMPLETED' && root.data.statusHistory?.some(row => row.action === 'FULFILLMENT_COMPLETED') + : statuses.includes(root.data?.order?.status) && root.data.order.status !== 'COMPLETED' + && !root.data.statusHistory?.some(row => row.action === 'FULFILLMENT_COMPLETED')) + const platformPassed = success(domestic.result) && id(domestic.result?.body?.data?.summary?.orderId) === rootOrderId + && (expectedCompleted ? domestic.result.body.data.summary.mainStatus === 'COMPLETED' + : rootDelivered ? domestic.result.body.data.summary.mainStatus === 'DELIVERED' + : nonBlank(domestic.result.body.data.summary.mainStatus) && domestic.result.body.data.summary.mainStatus !== 'COMPLETED') + context.assert(MODULE, expectedCompleted ? '全部有效子单签收后原始履约根COMPLETED' : '有效余量或根票未签收时原始履约根不得提前COMPLETED', + root.response, rawPassed, `rootOrderId=${rootOrderId}, pendingChildren=${pendingChildren.map(row => row.orderNo).join(',')}`, OPTIONS) + context.addStep(MODULE, expectedCompleted ? '同订单全部有效履约分片签收后统一订单进入COMPLETED' : '本票签收但有效余量未完时统一订单保留未完成状态', + domestic.result, platformPassed, `rootOrderId=${rootOrderId}, pollRequests=${domestic.attempts}, mainStatus=${domestic.result?.body?.data?.summary?.mainStatus || 'missing'}`, OPTIONS) + const customer = await merchant(context, merchantToken, 'GET', '客户读取本企业履约根真实完成状态', `/api/merchant/v1/orders/${encodeURIComponent(rootOrderId)}`) + const customerPassed = success(customer.response) && id(customer.data?.summary?.orderId) === rootOrderId + && customer.data?.summary?.mainStatus === domestic.result?.body?.data?.summary?.mainStatus && platformPassed + context.assert(MODULE, expectedCompleted ? '客户本企业全部履约完成后订单最终状态为COMPLETED' : '客户本企业仍有有效未完成履约时不得展示COMPLETED', + customer.response, customerPassed, `rootOrderId=${rootOrderId}, mainStatus=${customer.data?.summary?.mainStatus || 'missing'}`, OPTIONS) + if (owned) Object.assign(owned.orderCompletionProof, { rawRootStatus: root.data?.order?.status, + platformMainStatus: domestic.result?.body?.data?.summary?.mainStatus, customerMainStatus: customer.data?.summary?.mainStatus, + rawPassed: Boolean(rawPassed), platformPassed, customerPassed }) + return { rootOrderId, expectedCompleted, pendingChildren, passed: Boolean(rawPassed && platformPassed && customerPassed) } +} + +// A dedicated multi-fragment producer must supply these codes. Multiple FBA destinations alone are not proof. +export async function verifyPartialOrderCompletion(context, { orderId, customerId, deliveredInboundCode, pendingInboundCodes }) { + if (!id(orderId) || !id(customerId) || !nonBlank(deliveredInboundCode) + || !Array.isArray(pendingInboundCodes) || pendingInboundCodes.length === 0 + || pendingInboundCodes.includes(deliveredInboundCode)) { + blocker(context, '同Order多有效入仓分片', '缺少同一订单一片已签收及另一有效片未签收的本轮资源') + return false + } + const rows = [] + for (const inboundCode of [deliveredInboundCode, ...pendingInboundCodes]) { + const current = await platform(context, 'GET', `核对同Order分片${inboundCode}`, + query('/api/platform/inbounds', { tenantId: customerId, inboundCode, page: 1, size: 200 })) + rows.push(current.data?.records?.find(row => row.inboundCode === inboundCode)) + } + const sameOrder = rows.every(row => row && id(row.orderId) === id(orderId)) + const validPartialCompletion = sameOrder && rows[0]?.processStatus === 'DELIVERED' + && rows.slice(1).every(row => nonBlank(row.processStatus) && !['DELIVERED', 'CANCELLED'].includes(row.processStatus)) + context.assert(MODULE, '多分片完成门禁使用同一Order且各入仓码有效', null, + validPartialCompletion, + `codes=${[deliveredInboundCode, ...pendingInboundCodes].join(',')}`, OPTIONS) + if (!validPartialCompletion) return false + const current = await platform(context, 'GET', '未全签收时回查订单完成门禁', + query(`/api/platform/v1/orders/${encodeURIComponent(id(orderId))}`, { tenantId: customerId })) + const blocked = nonBlank(current.data?.summary?.mainStatus) && current.data.summary.mainStatus !== 'COMPLETED' + context.assert(MODULE, '同一Order仍有有效未签收分片不得提前COMPLETED', current.response, + blocked, `mainStatus=${current.data?.summary?.mainStatus || 'missing'}`, OPTIONS) + return sameOrder && blocked +} + +export async function completeReturnedException(context, { customerId, warehouseId, exceptionNo, outboundNo, resume = null }) { + const owned = context.createdResources?.phase5OverseasFulfillment + if (owned?.marker !== context.marker || owned?.returnExceptionNo !== exceptionNo || owned?.outboundNo !== outboundNo) { + blocker(context, '同派送退回异常实收归属', '必须复用本轮createdResources内的原派送退回异常和原出库号') + return null + } + const exceptionPath = query(`${LASTLEG_BASE}/exception-orders/${encodeURIComponent(exceptionNo)}`, { customerId }) + const before = await platform(context, 'GET', '读取本轮派送退回异常冻结来源', exceptionPath) + const order = before.data?.order + const sources = before.data?.returnSources + if (!success(before.response) || !['PENDING', 'PROCESSING'].includes(order?.status) || order?.exceptionType !== 'RETURN' + || order?.outboundNo !== outboundNo || !Array.isArray(sources) || sources.length !== 1) { + blocker(context, '同派送退回异常实收', '需要本轮PENDING/PROCESSING退回异常及唯一原出库来源,不能借普通退货单替代', before.response) + return null + } + const continuing = order.status === 'PROCESSING' + const previousAttempt = resume || owned.returnReceiptAttempt + if (continuing && (!/^\d+$/.test(id(previousAttempt?.targetPalletId)) + || !nonBlank(previousAttempt?.targetPalletNo) || !/^\d+$/.test(id(previousAttempt?.evidenceFileId)) + || (previousAttempt.exceptionNo && previousAttempt.exceptionNo !== exceptionNo) + || (previousAttempt.outboundNo && previousAttempt.outboundNo !== outboundNo))) { + blocker(context, '同派送退回实收续验资料', 'PROCESSING续验必须保留原目标托盘与已上传证据文件ID,不重复开始或重新建资源', before.response) + return null + } + const source = sources[0] + const sourceBalanceIds = owned.inventoryProof?.sourceBalanceIds || [] + const stockBefore = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回实收前库存', sourceBalanceIds) + const ledgerBefore = await shipmentLedger(context, customerId, outboundNo, '退回实收前流水', 'RETURN_STOCK_IN', exceptionNo) + if (!stockBefore || !ledgerBefore) return null + if (!stockBefore.some(balance => balance.positions.some(position => position.id === id(source.inventoryPositionId)))) { + blocker(context, '退回原位置冻结归属', '原returnSource位置必须存在于正式排车冻结真实余额中') + return null + } + const resourceBase = `/api/platform/v1/overseas-warehouse-resources/${encodeURIComponent(warehouseId)}` + const palletCode = continuing ? previousAttempt.targetPalletNo + : `RET-${context.marker}`.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 128) + let targetPalletId = continuing ? id(previousAttempt.targetPalletId) : '' + if (!continuing) { + const created = await platform(context, 'POST', '创建本轮派送退回实收目标托盘', `${resourceBase}/pallets`, + { palletCode, palletType: '木托盘', capacityCbm: 2, unitPrice: 0, status: 'IDLE', remark: `${context.marker}-delivery-return` }) + targetPalletId = id(created.data) + if (!success(created.response) || !/^\d+$/.test(targetPalletId)) return null + } + const pallet = await platform(context, 'GET', '回查退回目标托盘实际库位', `${resourceBase}/pallets/${targetPalletId}`) + const locations = await allPages(context, `${resourceBase}/locations`, {}, '读取退回目标托盘库位') + const location = locations?.find(row => id(row.id) === id(pallet.data?.locationId)) + context.assert(MODULE, '退回实收目标托盘和库位属于同一海外仓', pallet.response, + id(pallet.data?.warehouseId) === warehouseId && pallet.data?.palletCode === palletCode + && Boolean(location?.locationCode), `targetPallet=${palletCode}, location=${location?.locationCode || 'missing'}`, OPTIONS) + if (!location?.locationCode) return null + let processingVersion = Number(order.version) + let evidenceFileId = continuing ? id(previousAttempt.evidenceFileId) : '' + if (!continuing) { + const startBody = { expectedVersion: Number(order.version), idempotencyKey: key(context, 'RETURN-START'), handlingAction: '退回海外仓实收' } + const started = await platform(context, 'POST', '开始处理同一派送退回异常', + query(`${LASTLEG_BASE}/exception-orders/${encodeURIComponent(exceptionNo)}/start-processing`, { customerId }), startBody) + if (!success(started.response) || started.data?.status !== 'PROCESSING') return null + processingVersion = Number(started.data.version) + const evidence = await context.requestMultipart('POST', + query(`${LASTLEG_BASE}/exception-orders/${encodeURIComponent(exceptionNo)}/evidence`, { customerId }), + { name: `${context.marker}-return-receipt.txt`, type: 'text/plain', + content: new TextEncoder().encode(`${context.marker} synthetic return receipt evidence ${exceptionNo}`) }) + const uploaded = context.apiSuccess(MODULE, '上传同派送退回异常实收证据', evidence, trace(evidence), OPTIONS) + if (!success(evidence) || !/^\d+$/.test(id(uploaded?.fileId))) return null + evidenceFileId = id(uploaded.fileId) + } + const completeIdempotencyKey = continuing && nonBlank(previousAttempt.completeIdempotencyKey) + ? previousAttempt.completeIdempotencyKey : key(context, 'RETURN-COMPLETE') + if (!Number.isInteger(processingVersion) || processingVersion < 0) { + blocker(context, '派送退回处理版本', '当前PROCESSING异常缺少有效服务端版本,不能以默认版本完成', before.response) + return null + } + owned.returnReceiptAttempt = { exceptionNo, outboundNo, targetPalletId, targetPalletNo: palletCode, + evidenceFileId, completeIdempotencyKey } + const completeBody = { expectedVersion: processingVersion, idempotencyKey: completeIdempotencyKey, + handlingResult: `${context.marker}-实际退回一箱并恢复来源库存`, evidenceFileIds: [evidenceFileId], + receipts: [{ outboundPalletItemId: id(source.outboundPalletItemId), inventoryPositionId: id(source.inventoryPositionId), + receivedPackages: 1, targetPalletId, targetPalletNo: palletCode, + targetLocationId: id(location.id), targetLocationCode: location.locationCode }] } + const completePath = query(`${LASTLEG_BASE}/exception-orders/${encodeURIComponent(exceptionNo)}/complete`, { customerId }) + const completed = await platform(context, 'POST', '同派送退回异常完成实收及恢复库存', completePath, completeBody) + context.assert(MODULE, '同派送退回异常完成实收恰好恢复一箱', completed.response, + completed.data?.status === 'COMPLETED' && Number(completed.data?.restoredPackages) === 1 + && /^\d+$/.test(id(completed.data?.inventoryAdjustmentId)), trace(completed.response), OPTIONS) + if (!success(completed.response)) { + const stockAfterFailure = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回实收失败后库存', sourceBalanceIds) + const ledgerAfterFailure = await shipmentLedger(context, customerId, outboundNo, '退回实收失败后流水', 'RETURN_STOCK_IN', exceptionNo) + context.assert(MODULE, '退回实收失败后库存及流水保持不变并保留原续验资料', completed.response, + sameSnapshot(stockBefore, stockAfterFailure) && sameSnapshot(ledgerBefore, ledgerAfterFailure), trace(completed.response), OPTIONS) + return null + } + const stockAfter = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回实收后库存', sourceBalanceIds) + const ledgerAfter = await shipmentLedger(context, customerId, outboundNo, '退回实收后流水', 'RETURN_STOCK_IN', exceptionNo) + const targetPositions = [] + for (const balance of stockAfter || []) { + const positions = await platform(context, 'GET', '回查退回目标托盘实际库存位置', + query(`${LASTLEG_BASE}/inventory/balances/${encodeURIComponent(balance.id)}/positions`, { customerId, palletNo: palletCode })) + if (Array.isArray(positions.data)) targetPositions.push(...positions.data.filter(row => row.palletNo === palletCode)) + } + context.assert(MODULE, '退回一箱实际落到新目标托盘和原票库存位置', null, + targetPositions.length === 1 && targetPositions[0].inboundCode === order.inboundCode + && id(targetPositions[0].palletId) === targetPalletId && id(targetPositions[0].warehouseLocationId) === id(location.id) + && id(targetPositions[0].customerId) === customerId && id(targetPositions[0].overseasWarehouseId) === warehouseId + && Number(targetPositions[0].availablePackages) === 1 && Number(targetPositions[0].totalPackages) === 1, + `targetPositionCount=${targetPositions.length}, targetPallet=${palletCode}`, OPTIONS) + const newRows = ledgerAfter?.filter(row => !ledgerBefore.some(beforeRow => beforeRow.id === row.id)) + context.assert(MODULE, '退回实收库存与原出库关联流水恢复数量守恒', null, + stockAfter !== null && quantityTotal(stockAfter) - quantityTotal(stockBefore) === 1 + && balanceQuantityTotal(stockAfter) - balanceQuantityTotal(stockBefore) === 1 + && groupQuantityTotal(stockAfter) - groupQuantityTotal(stockBefore) === 1 + && newRows?.length === 1 && newRows[0].deltaPackages === 1 + && newRows[0].afterPackages - newRows[0].beforePackages === 1, + `before=${quantityTotal(stockBefore)}, after=${quantityTotal(stockAfter)}, newLedgerRows=${newRows?.length ?? 'missing'}`, OPTIONS) + const replay = await platform(context, 'POST', '同派送退回实收完成原键重放', completePath, completeBody) + const stockReplay = await inventorySnapshot(context, customerId, warehouseId, [order.inboundCode], '退回重放后库存', sourceBalanceIds) + const ledgerReplay = await shipmentLedger(context, customerId, outboundNo, '退回重放后流水', 'RETURN_STOCK_IN', exceptionNo) + context.assert(MODULE, '退回实收重放不二次恢复库存或增加流水', replay.response, + replay.data?.replayed === true && id(replay.data?.inventoryAdjustmentId) === id(completed.data?.inventoryAdjustmentId) + && sameSnapshot(stockAfter, stockReplay) && sameSnapshot(ledgerAfter, ledgerReplay), trace(replay.response), OPTIONS) + const final = await platform(context, 'GET', '回查同派送退回异常完成及来源证据', exceptionPath) + const delivery = await platform(context, 'GET', '回查退回实收后的原派送单', + query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(order.deliveryNo)}`, { customerId })) + context.assert(MODULE, '退回异常完成保留原出库库存来源与实收证据且派送RETURNED', final.response, + final.data?.order?.status === 'COMPLETED' && final.data.order.outboundNo === outboundNo + && final.data.order.evidenceFileIds?.some(fileId => id(fileId) === evidenceFileId) + && final.data.returnSources?.some(row => id(row.outboundPalletItemId) === id(source.outboundPalletItemId) + && id(row.inventoryPositionId) === id(source.inventoryPositionId)) + && delivery.data?.order?.status === 'RETURNED' && delivery.data.order.outboundNo === outboundNo, + trace(final.response), OPTIONS) + return { exceptionNo, targetPalletId, targetPalletNo: palletCode, evidenceFileId, + inventoryAdjustmentId: id(completed.data.inventoryAdjustmentId), restoredPackages: Number(completed.data.restoredPackages), + targetPositionIds: targetPositions.map(row => id(row.id)) } +} + export async function saveReceiptAndComplete(context, delivery, customerId, index) { const deliveryNo = id(delivery.deliveryNo) const documentsPath = query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/documents`, { customerId }) @@ -75,7 +587,7 @@ export async function saveReceiptAndComplete(context, delivery, customerId, inde Number.isInteger(version) && version >= 0, `version=${before.data?.version ?? 'missing'}; ${trace(before.response)}`, OPTIONS) if (!success(before.response) || !Number.isInteger(version) || version < 0) return null - const uploadResponse = await context.requestOverseasMultipart('POST', + const uploadResponse = await context.requestMultipart('POST', query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/documents/RECEIPT/files`, { customerId }), { name: `${context.marker}-${deliveryNo}-receipt.txt`, type: 'text/plain', content: new TextEncoder().encode(`${context.marker} receipt acceptance for ${deliveryNo}`), @@ -86,6 +598,11 @@ export async function saveReceiptAndComplete(context, delivery, customerId, inde context.assert(MODULE, `第${index + 1}张派送单签收文件返回业务绑定ID`, uploadResponse, /^\d+$/.test(fileId), `fileIdPresent=${/^\d+$/.test(fileId)}; ${trace(uploadResponse)}`, OPTIONS) if (!success(uploadResponse) || !fileId) return null + if (context.createdResources.phase5OverseasFulfillment) { + const receipts = context.createdResources.phase5OverseasFulfillment.receiptFiles ||= [] + receipts.push({ deliveryNo, fileId, + content: `${context.marker} receipt acceptance for ${deliveryNo}` }) + } const existing = (before.data?.categories || []).map((category) => ({ category: category.category, @@ -115,6 +632,112 @@ export async function saveReceiptAndComplete(context, delivery, customerId, inde return success(completed.response) && completed.data?.order?.status === 'DELIVERED' ? completed.data : null } +async function receiptBinary(context, token, path) { + try { + const response = await fetch(`${context.options.gateway}${path}`, { + headers: { Authorization: `Bearer ${token}`, Accept: '*/*' }, signal: AbortSignal.timeout(120_000), + }) + const bytes = new Uint8Array(await response.arrayBuffer()) + const contentType = response.headers.get('content-type') || '' + let body = null + if (/json/i.test(contentType)) { + try { body = JSON.parse(new TextDecoder().decode(bytes)) } catch { /* keep non-JSON failure evidence */ } + } + return { http: response.status, body, bytes, contentType, + requestId: response.headers.get('x-request-id'), traceId: response.headers.get('x-trace-id') } + } catch (error) { + return { http: 0, body: null, bytes: new Uint8Array(), contentType: '', raw: String(error) } + } +} + +const hiddenOrForbidden = response => [403, 404].includes(response?.http) + || (response?.http === 200 && ['403', '404', 'A0403', 'A0404'].includes(code(response))) + +export async function createSignedDocumentIsolationCustomer(context, { create = createMerchant } = {}) { + // Own options, credentials and resource storage prevent the secondary fixture from changing the main customer. + const secondary = Object.create(context) + secondary.marker = `${context.marker}-SIGNED-B` + secondary.options = { ...context.options, customerName: '' } + secondary.context = { ...context.context } + secondary.createdResources = {} + await create(secondary, { publishEnvironment: false }) + const fixture = secondary.context?.merchantQualificationFixture + const safe = secondary.createdResources?.merchantQualificationFixture + const metadata = { tenantId: id(safe?.tenantId), tenantName: safe?.tenantName, + username: safe?.username, marker: safe?.marker } + const ready = fixture?.marker === secondary.marker && metadata.marker === secondary.marker + && /^\d+$/.test(metadata.tenantId) && metadata.tenantId === id(fixture?.tenantId) + && nonBlank(fixture?.username) && nonBlank(fixture?.password) + context.assert(MODULE, '签收资料跨企业验收具有独立SIGNED-B本轮客户', null, + ready, `fixtureMarker=${metadata.marker || 'missing'}, customerIdPresent=${Boolean(metadata.tenantId)}`, OPTIONS) + if (!ready) { + blocker(context, 'SIGNED-B客户会话', '独立隔离客户前置创建不完整,不使用主客户替代') + return null + } + context.createdResources.phase5OverseasFulfillment.signedDocumentIsolationCustomer = metadata + const login = await context.request('POST', '/api/auth/login', + { loginId: fixture.username, password: fixture.password }, '') + const auth = context.apiSuccess(MODULE, '独立SIGNED-B客户登录用于签收资料隔离', login, trace(login), OPTIONS) + if (!success(login) || !nonBlank(auth?.accessToken)) { + blocker(context, 'SIGNED-B客户会话', '独立客户登录未返回有效会话', login) + return null + } + context.context.phase5TailVerification = { ...context.context.phase5TailVerification, + foreignMerchantToken: auth.accessToken } + return auth.accessToken +} + +export async function verifyCustomerSignedDocuments(context, merchantToken, receiptFiles, foreignToken = null) { + if (!nonBlank(merchantToken) || !Array.isArray(receiptFiles) || receiptFiles.length === 0) { + blocker(context, '客户完成后签收资料', '缺少本轮真实已绑定签收文件与客户会话') + return false + } + let verified = true + if (foreignToken) { + const own = await merchant(context, merchantToken, 'GET', '签收文件隔离核对本客户会话身份', '/api/merchant/v1/iam/tenant/profile') + const foreign = await merchant(context, foreignToken, 'GET', '签收文件隔离核对另一客户会话身份', '/api/merchant/v1/iam/tenant/profile') + const distinct = /^\d+$/.test(id(own.data?.id)) && /^\d+$/.test(id(foreign.data?.id)) && id(own.data.id) !== id(foreign.data.id) + context.assert(MODULE, '签收文件隔离必须使用两个不同企业的真实客户会话', foreign.response, + distinct, `distinctCustomerIdentity=${distinct}`, OPTIONS) + if (!distinct) return false + } + for (const receipt of receiptFiles) { + const base = `${MERCHANT_DELIVERY_BASE}/${encodeURIComponent(receipt.deliveryNo)}/documents/receipt` + const detail = await merchant(context, merchantToken, 'GET', `客户读取已完成派送${receipt.deliveryNo}签收资料`, base) + const bound = detail.data?.deliveryNo === receipt.deliveryNo + && detail.data?.files?.some(file => id(file.fileId) === id(receipt.fileId)) + context.assert(MODULE, '客户签收只读资料返回本单已绑定文件', detail.response, bound, trace(detail.response), OPTIONS) + const downloadPath = `${base}/files/${encodeURIComponent(id(receipt.fileId))}/download` + const download = await receiptBinary(context, merchantToken, downloadPath) + const exactBytes = download.http === 200 && /application\/octet-stream/i.test(download.contentType) + && new TextDecoder().decode(download.bytes) === receipt.content + context.addStep(MODULE, '客户下载自身签收文件字节等于本轮上传内容', download, exactBytes, trace(download), OPTIONS) + verified &&= Boolean(bound && exactBytes) + const otherReceipt = receiptFiles.find(other => other.deliveryNo !== receipt.deliveryNo) + if (otherReceipt) { + const wrongFile = await receiptBinary(context, merchantToken, + `${base}/files/${encodeURIComponent(id(otherReceipt.fileId))}/download`) + context.addStep(MODULE, '客户不得将另一派送单签收文件挂到本单读取', wrongFile, + hiddenOrForbidden(wrongFile), trace(wrongFile), OPTIONS) + verified &&= hiddenOrForbidden(wrongFile) + } + if (foreignToken) { + const deniedDetail = await context.request('GET', base, undefined, foreignToken, {}) + context.addStep(MODULE, '另一客户不得读取本企业派送签收资料', deniedDetail, + hiddenOrForbidden(deniedDetail), trace(deniedDetail), OPTIONS) + const deniedFile = await receiptBinary(context, foreignToken, downloadPath) + context.addStep(MODULE, '另一客户不得下载本企业派送签收文件', deniedFile, + hiddenOrForbidden(deniedFile), trace(deniedFile), OPTIONS) + verified &&= hiddenOrForbidden(deniedDetail) && hiddenOrForbidden(deniedFile) + } + } + if (!foreignToken) { + blocker(context, '客户签收文件跨企业隔离', '本轮缺少独立另一客户会话,自己的下载成功不能替代跨客户拒绝') + return false + } + return verified +} + export function relabelOutboundMarksMatch(detail, relabel) { const marks = (detail?.pallets || []).flatMap(item => String(item.cartonMarks || '') .split(',').map(mark => mark.trim()).filter(Boolean)) @@ -158,16 +781,16 @@ async function loginMerchant(context, customerId) { password: credentials.password, }, '') const auth = context.apiSuccess(MODULE, '本轮客户工作台账号登录', response, trace(response), OPTIONS) - context.assert(MODULE, '客户登录返回海外访问Token', response, nonBlank(auth?.overseasAccessToken), - `tokenPresent=${Boolean(auth?.overseasAccessToken)}; ${trace(response)}`, OPTIONS) - if (!nonBlank(auth?.accessToken) || !nonBlank(auth?.overseasAccessToken)) return null + context.assert(MODULE, '客户登录返回海外访问Token', response, nonBlank(auth?.accessToken), + `tokenPresent=${Boolean(auth?.accessToken)}; ${trace(response)}`, OPTIONS) + if (!nonBlank(auth?.accessToken) || !nonBlank(auth?.accessToken)) return null const profileResponse = await context.request('GET', '/api/merchant/v1/iam/tenant/profile', undefined, auth.accessToken) const profile = context.apiSuccess(MODULE, '回查客户工作台企业身份', profileResponse, trace(profileResponse), OPTIONS) context.assert(MODULE, '客户工作台企业与五期履约客户严格一致', profileResponse, id(profile?.id) === customerId, `merchantCustomer=${id(profile?.id) || 'missing'}, fulfillmentCustomer=${customerId}; ${trace(profileResponse)}`, OPTIONS) - return id(profile?.id) === customerId ? auth.overseasAccessToken : null + return id(profile?.id) === customerId ? auth.accessToken : null } function fixture(context) { @@ -180,6 +803,20 @@ function fixture(context) { return { dispatch, receiving } } +export async function verifyDepartureReadiness(context, token, dispatchNo) { + const ready = await pda(context, token, 'GET', '发车前回查服务端canDepart门禁', + `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) + const dispatch = await platform(context, 'GET', '发车前独立回查实际排车状态', + `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) + const passed = success(ready.response) && success(dispatch.response) + && ready.data?.dispatchNo === dispatchNo && ready.data?.status === 'COMPLETED' + && ready.data?.canDepart === true && dispatch.data?.dispatchNo === dispatchNo + && dispatch.data?.status === 'READY_TO_DEPART' + context.assert(MODULE, '发车前PDA任务完成且服务端门禁和实际排车待发均通过', ready.response, + passed, `taskStatus=${ready.data?.status || 'missing'}, canDepart=${ready.data?.canDepart}, dispatchStatus=${dispatch.data?.status || 'missing'}; ${trace(ready.response)}`, OPTIONS) + return passed ? ready : null +} + export async function pickAll(context, token, dispatchNo) { let detail = await pda(context, token, 'GET', 'PDA读取本轮排车拣货任务', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) @@ -256,6 +893,14 @@ export async function run(context) { const beforeEdit = await platform(context, 'GET', '编辑车辆前读取服务端目的仓线路', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) if (!success(beforeEdit.response) || beforeEdit.data?.dispatchNo !== dispatchNo) return + const routeProof = context.createdResources.phase5OverseasFulfillment.routeProof = { + frozenPlanPalletIds:source.dispatch.planPalletIds, + frozenDestinationWarehouseCodes:source.dispatch.destinationWarehouseCodes, + beforeEdit:beforeEdit.data, + } + context.assert(MODULE,'排车线路目的地集合与实际托盘及本轮冻结候选完全一致',beforeEdit.response, + dispatchDestinationConservation(beforeEdit.data,source.dispatch),trace(beforeEdit.response),OPTIONS) + if (!dispatchDestinationConservation(beforeEdit.data,source.dispatch)) return const updateBody = { vehiclePlate: `DEV-${context.marker.slice(-8)}`.slice(0, 64), driverName: `五期验收司机-${context.marker.slice(-6)}`, @@ -270,7 +915,7 @@ export async function run(context) { trace(updated.response), OPTIONS) if (!success(updated.response)) return - const bol = await context.requestOverseasBinary('GET', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/prints/bol`) + const bol = await context.requestBinary('GET', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/prints/bol`) context.addStep(MODULE, '排车BOL下载为真实PDF而非JSON错误', bol, bol.http === 200 && /application\/pdf/i.test(bol.contentType || '') && new TextDecoder().decode(bol.bytes?.slice(0, 5)) === '%PDF-', trace(bol), OPTIONS) @@ -292,6 +937,10 @@ export async function run(context) { const deliveries = transferDeliveries(transferred.data) const deliveryNos = deliveryNumbers(transferred.data) const destinationKeys = deliveries.map((value) => id(value.destinationWarehouseCode || value.destinationWarehouseId)) + routeProof.deliveries = deliveries + context.assert(MODULE,'转派送目的地集合与实际排车线路及本轮冻结候选守恒',transferred.response, + dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries),trace(transferred.response),OPTIONS) + if (!dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries)) return const deliveryNo = deliveryNos[0] || '' context.assert(MODULE, '转派送按目的FBA仓生成WAIT_DELIVERY单据数组', transferred.response, nonBlank(outboundNo) && deliveries.length > 0 && new Set(deliveryNos).size === deliveries.length @@ -310,8 +959,7 @@ export async function run(context) { && JSON.stringify(deliveryNumbers(adminReplay.data)) === JSON.stringify(deliveryNos), trace(adminReplay.response), OPTIONS) const lockedItem = updated.data?.items?.[0] if (lockedItem?.id) { - const deniedRemove = await context.requestOverseas('DELETE', - `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(lockedItem.id))}`) + const deniedRemove = await context.request('DELETE', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(lockedItem.id))}`) context.addStep(MODULE, '已生成出库单禁止移除排车托盘', deniedRemove, conflict(deniedRemove), trace(deniedRemove), OPTIONS) const stable = await platform(context, 'GET', '移除拒绝后回查排车托盘零漂移', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) context.assert(MODULE, '移除失败不改变箱数和托盘身份', stable.response, @@ -323,8 +971,7 @@ export async function run(context) { context.assert(MODULE, '转派送重放复用同一业务单据', transferReplay.response, transferReplay.data?.replayed === true && id(transferReplay.data?.outboundNo) === outboundNo && JSON.stringify(deliveryNumbers(transferReplay.data)) === JSON.stringify(deliveryNos), trace(transferReplay.response), OPTIONS) - const transferConflict = await context.requestOverseas('POST', query(MERCHANT_DELIVERY_BASE, { dispatchNo }), - { ...transferBody, idempotencyKey: key(context, 'TRANSFER-DIFFERENT') }, {}, merchantToken) + const transferConflict = await context.request('POST', query(MERCHANT_DELIVERY_BASE, { dispatchNo }), { ...transferBody, idempotencyKey: key(context, 'TRANSFER-DIFFERENT') }, merchantToken, {}) context.addStep(MODULE, '同一排车使用另一幂等键仍返回既有单据', transferConflict, success(transferConflict) && id(transferConflict.body?.data?.outboundNo) === outboundNo && transferConflict.body?.data?.replayed === true @@ -351,17 +998,51 @@ export async function run(context) { const access = await resolvePickerAccess(context) const picker = access ? await createPicker(context, 5, warehouseId, access) : null + const dispatchInboundCodes = [...new Set((updated.data?.items || []).map(item => item.inboundCode).filter(nonBlank))] + let tripProof + if (dispatchInboundCodes.length === 0) { + blocker(context, '排车库存数量守恒', '实际排车详情未提供来源入仓码') + await disablePicker(context, picker, 5) + return + } try { if (!nonBlank(picker?.token)) { blocker(context, '创建本轮五期PDA操作员', '正式IAM账号已创建但海外安全投影或登录未就绪') return } - if (!await pickAll(context, picker.token, dispatchNo)) return - const departBody = { idempotencyKey: key(context, 'DISPATCH-DEPART'), remark: `${context.marker}-depart` } + const sourceTask = await pda(context, picker.token, 'GET', '扫码前冻结实际排车全部库存来源', + `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) + const sourceIds = dispatchInventorySources(sourceTask.data, dispatchNo, warehouseId) + context.assert(MODULE, '真实排车任务冻结全部库存位置及余额ID', sourceTask.response, + success(sourceTask.response) && Boolean(sourceIds), `dispatchNo=${dispatchNo}`, OPTIONS) + if (!sourceIds) return + const { sourceBalanceIds, sourcePositionIds, sourcePositionBalances } = sourceIds + context.createdResources.phase5OverseasFulfillment.inventoryProof = { sourceBalanceIds, sourcePositionIds, sourcePositionBalances } + const stockBeforePick = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '扫码前本排车来源库存', sourceBalanceIds) + const ledgerBeforePick = await shipmentLedger(context, customerId, dispatchNo, '扫码前本排车SHIP流水', 'SHIP', dispatchNo) + Object.assign(context.createdResources.phase5OverseasFulfillment.inventoryProof, { snapshots: { beforePick: stockBeforePick }, ledgers: { beforePick: ledgerBeforePick } }) + const sourcesBeforeValid = inventorySourcesPresent(stockBeforePick, sourcePositionBalances) + context.assert(MODULE, '扫码前库存完整覆盖任务冻结的每个原位置关联', null, sourcesBeforeValid, `positions=${sourcePositionIds.length}`, OPTIONS) + if (!sourcesBeforeValid || !ledgerBeforePick) return + tripProof = await prepareFleetTrip(context, MODULE, dispatchNo, warehouseId) + const pickedSuccessfully = await pickAll(context, picker.token, dispatchNo) + const stockAfterPick = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '扫码及重放后库存', sourceBalanceIds) + const ledgerAfterPick = await shipmentLedger(context, customerId, dispatchNo, '扫码及重放后SHIP流水', 'SHIP', dispatchNo) + const expectedPackages = Number(transferred.data.packageCount) + const conservation = dispatchStockConservation(context, { customerId, warehouseId, dispatchNo, expectedPackages, + sourcePositionBalances, stockBefore: stockBeforePick, stockAfter: stockAfterPick, + ledgerBefore: ledgerBeforePick, ledgerAfter: ledgerAfterPick }) + const shippedRows = conservation.newLedger + if (!pickedSuccessfully) return + const tripVersion = await confirmFleetLoading(context, MODULE, tripProof) + const ready = await verifyDepartureReadiness(context, picker.token, dispatchNo) + if (!ready) return + const departBody = { recordVersion: tripVersion, idempotencyKey: key(context, 'DISPATCH-DEPART').slice(0, 128), remark: `${context.marker}-depart` } const departed = await pda(context, picker.token, 'POST', 'PDA确认车辆发车并完成真实出库', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/depart`, departBody) - context.assert(MODULE, '发车推进排车DEPARTED并扣减实物库存一次', departed.response, - departed.data?.status === 'DEPARTED' && Number(departed.data?.shippedPackages) > 0, + context.assert(MODULE, '发车推进排车DEPARTED并返回实发数量', departed.response, + departed.data?.status === 'IN_TRANSIT' && departed.data?.tripNo === tripProof.tripNo + && departed.data?.dispatches?.some(item => item.dispatchNo === dispatchNo) && Number(departed.data?.shippedPackages) > 0, trace(departed.response), OPTIONS) if (!success(departed.response)) return const departReplay = await pda(context, picker.token, 'POST', 'PDA发车同键重放', @@ -370,6 +1051,20 @@ export async function run(context) { departReplay.data?.replayed === true && Number(departReplay.data?.shippedPackages) === Number(departed.data?.shippedPackages), trace(departReplay.response), OPTIONS) + const stockAfterDepart = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '发车及重放后库存', sourceBalanceIds) + const ledgerAfterDepart = await shipmentLedger(context, customerId, dispatchNo, '发车及重放后SHIP流水', 'SHIP', dispatchNo) + const inventoryProof = context.createdResources.phase5OverseasFulfillment.inventoryProof + inventoryProof.snapshots.afterDepart = stockAfterDepart + inventoryProof.ledgers.afterDepart = ledgerAfterDepart + context.assert(MODULE, '发车及重放只推进履约且库存和SHIP流水零额外扣减', departReplay.response, + inventorySourcesPresent(stockAfterDepart, sourcePositionBalances) + && sameSnapshot(stockAfterPick, stockAfterDepart) && sameSnapshot(ledgerAfterPick, ledgerAfterDepart), trace(departReplay.response), OPTIONS) + Object.assign(context.createdResources.phase5OverseasFulfillment.inventoryProof, { + sourceBalanceIds, sourcePositionIds, sourcePositionBalances, inboundCodes: dispatchInboundCodes, beforePackages: conservation.proof.totals.positionBefore, + afterPackages: quantityTotal((stockAfterDepart || []).flatMap(row => row.positions.filter(position => sourcePositionBalances.some(reference => reference.positionId === position.id && reference.balanceId === position.balanceId)))), expectedPackages, + shipmentLedgerIds: (shippedRows || []).map(row => row.id), + canDepart: ready.data?.canDepart === true, + }) } finally { await disablePicker(context, picker, 5) if (picker) picker.token = null @@ -409,9 +1104,7 @@ export async function run(context) { && completedDeliveries.every((value) => value?.order?.status === 'DELIVERED'), `deliveries=${deliveryNos.join(',')}, completed=${completedDeliveries.length}`, OPTIONS) - const removedComplete = await context.requestOverseas('POST', - query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/complete`, { customerId }), - { status: 'DELIVERED', expectedVersion: 0, remark: `${context.marker}-removed-complete-probe` }) + const removedComplete = await context.request('POST', query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/complete`, { customerId }), { status: 'DELIVERED', expectedVersion: 0, remark: `${context.marker}-removed-complete-probe` }) context.addStep(MODULE, '人工确认派送完成接口已移除', removedComplete, removedComplete?.http === 404 || removedComplete?.http === 405 || (removedComplete?.http === 200 && ['A0404', 'A0405'].includes(code(removedComplete))), @@ -451,12 +1144,12 @@ export async function run(context) { blocker(context, '取得签收对应头程订单', '头程入仓详情缺少来源订单ID', domesticInbound.result) return } - const domesticOrder = await poll(async () => context.request('GET', - query(`/api/platform/v1/orders/${encodeURIComponent(sourceOrderId)}`, { tenantId: customerId })), - (response) => success(response) && response.body?.data?.summary?.mainStatus === 'COMPLETED') - context.addStep(MODULE, '同订单全部入仓签收后统一订单进入COMPLETED', domesticOrder.result, - success(domesticOrder.result) && domesticOrder.result?.body?.data?.summary?.mainStatus === 'COMPLETED', - `orderId=${sourceOrderId}, pollRequests=${domesticOrder.attempts}, mainStatus=${domesticOrder.result?.body?.data?.summary?.mainStatus || 'missing'}; ${trace(domesticOrder.result)}`, OPTIONS) + context.createdResources.phase5OverseasFulfillment.sourceOrderId = sourceOrderId + await verifyFulfillmentRootStatus(context, { sourceOrderId, customerId, merchantToken }) + const foreignMerchantToken = context.context?.phase5TailVerification?.foreignMerchantToken + || await createSignedDocumentIsolationCustomer(context) + await verifyCustomerSignedDocuments(context, merchantToken, + context.createdResources.phase5OverseasFulfillment.receiptFiles, foreignMerchantToken) const savedVisit = await platform(context, 'PUT', '保存无附件客户回访结果', query(`${LASTLEG_BASE}/customer-visits/${encodeURIComponent(visitId)}`, { customerId }), { @@ -535,5 +1228,10 @@ export async function run(context) { exceptionRows.some((value) => id(value?.exceptionNo) === manualExceptionNo) && exceptionRows.some((value) => id(value?.exceptionNo) === returnExceptionNo), `records=${exceptionRows.length}; ${trace(exceptionPage.response)}`, OPTIONS) - context.createdResources.phase5OverseasFulfillment.finalStatus = 'RETURN_PENDING_WITH_COMPLETED_VISIT' + const returnReceipt = await completeReturnedException(context, { customerId, warehouseId, + exceptionNo: returnExceptionNo, outboundNo }) + context.createdResources.phase5OverseasFulfillment.returnReceipt = returnReceipt + context.createdResources.phase5OverseasFulfillment.tripClosure = await finishFleetTrip(context, MODULE, tripProof) + context.createdResources.phase5OverseasFulfillment.finalStatus = returnReceipt + ? 'RETURNED_WITH_COMPLETED_VISIT' : 'RETURN_PENDING_WITH_COMPLETED_VISIT' } diff --git a/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs new file mode 100644 index 000000000..faf894e84 --- /dev/null +++ b/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs @@ -0,0 +1,447 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFileSync } from 'node:fs' +import { fulfillmentHistorySnapshot, validTwoFragmentTree, validSignatureGate, + completionCounts, exactCompletionOutboxProof, resolveF40MerchantCredentials, + f40TransferBody, f40LocalDateTime, validFragmentCabinetIdentity, validUnloadedRemainder, + validRemainderCancellationGate, exactCancelledRemainderOutboxProof, cancelUnloadedRemainder, + verifyRootProjections, verifyRemainderTaxIdentity, validFreshReceiptSave, preserveReceivingEvidence, run } from './phase5-root-fulfillment.mjs' +import { loadOneFragment } from './phase3-order-splitting.mjs' +import { verifyPalletLabels } from './phase3-loading-finalize.mjs' +import { awaitProducerConsumption, convertReceivingContainers, runReceiving, + resolveSharedWarehouse } from './phase4-overseas-pda.mjs' + +const source = readFileSync(new URL('./phase5-root-fulfillment.mjs', import.meta.url),'utf8') +const history = (...actions) => actions.map((action,index) => ({id:String(index+1),action})) +const tree = () => ({parentOrderId:'100',originalPackageCount:2,cumulativeLoadedPackageCount:2, + currentRemainderPackageCount:0,children:[ + {orderId:'100',sourceOrderId:'100',splitRole:'LOADED',packageCount:1}, + {orderId:'101',sourceOrderId:'100',splitRole:'REMAINDER',packageCount:1}, + {orderId:'101',sourceOrderId:'101',splitRole:'LOADED',packageCount:1}, + ]}) +const signed = () => ({root:{status:'COMPLETED',histories:history('OVERSEAS_DELIVERED','FULFILLMENT_COMPLETED')}, + child:{status:'COMPLETED',histories:history('OVERSEAS_DELIVERED')}, + rootInbound:{processStatus:'DELIVERED',orderStatusSnapshot:'COMPLETED'}}) +const outbox = () => ({readOnly:true,customerId:'90',rootOrderId:'100',orders:['100','101'].map(orderId => ({ + orderId,customerId:'90',rootOrderId:'100',eventType:'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED', + count:1,publishedCount:1,pendingCount:0,failedCount:0, +}))}) +const fixture = () => ({steps:[],token:'offline',marker:'offline-only',createdResources:{}, + addStep(_module,_label,_response,passed){this.steps.push({passed})}, + assert(_module,_label,_response,passed){this.steps.push({passed})}, + addCoverageGap(){},apiSuccess(_module,_label,response){return response?.body?.data}, +}) + +function remainderTaxFixture(taxMode) { + const context=fixture(),calls=[] + const evidence={taxMode,customerId:'90',rootOrderId:'100',childOrderId:'101',rootOrderNo:'ORDER-ROOT', + rawChild:{id:'101',tenantId:'90',taxMode},rootInbound:{orderId:'100',taxMode}, + childInbound:{orderId:'101',tenantId:'90',taxMode}} + const rows=[{orderId:'100',tenantId:'90',taxMode,relation:{role:'PARENT',rootOrderId:'100'}}, + {orderId:'101',tenantId:'90',taxMode,relation:{role:'CHILD',rootOrderId:'100',parentOrderId:'100'}}] + context.request=async(method,path,body,token)=>{ + calls.push({method,path,body,token}) + const filter=new URL(path,'https://offline.invalid').searchParams.get('taxMode') + const records=filter&&filter!==taxMode?[]:structuredClone(rows) + return {http:200,body:{code:'200',data:{records,total:records.length}}} + } + return {context,evidence,calls,rows} +} +test('raw remainder and actual inbound summary taxMode agree for BUY and TAX and customer filters retain exact parent/child',async()=>{ + for(const mode of ['BUY_DECLARATION','TAX_REFUND']){ + const f=remainderTaxFixture(mode) + await verifyRemainderTaxIdentity(f.context,f.evidence,'own-customer') + assert.equal(f.calls.length,3) + assert.ok(f.calls.every(c=>c.method==='GET'&&c.token==='own-customer'&&!c.path.includes('/customs'))) + assert.deepEqual(f.evidence.matchingTax.records.map(r=>r.orderId),['100','101']) + assert.equal(f.evidence.otherTax.total,0) + assert.match(f.calls[1].path,new RegExp('taxMode='+mode)) + } +}) +test('missing or wrong raw remainder taxMode is rejected before customer/customs calls despite valid inbound projection',async()=>{ + for(const mode of ['BUY_DECLARATION','TAX_REFUND'])for(const actual of [null,undefined,mode==='BUY_DECLARATION'?'TAX_REFUND':'BUY_DECLARATION']){ + const f=remainderTaxFixture(mode);f.evidence.rawChild.taxMode=actual + await assert.rejects(verifyRemainderTaxIdentity(f.context,f.evidence,'own-customer'),/原始余量子订单继承/) + assert.equal(f.calls.length,0) + } +}) +test('wrong inbound tax, missing filtered child, foreign customer or wrong parent identity cannot pass',async()=>{ + for(const mutate of [f=>f.evidence.childInbound.taxMode=null,f=>f.rows.pop(),f=>f.rows[1].tenantId='91', + f=>f.rows[1].relation.parentOrderId='999',f=>f.rows[1].taxMode='BUY_DECLARATION']) { + const f=remainderTaxFixture('TAX_REFUND');mutate(f) + await assert.rejects(verifyRemainderTaxIdentity(f.context,f.evidence,'own-customer')) + } + const f=remainderTaxFixture('TAX_REFUND'),original=f.context.request + f.context.request=async(...args)=>{const r=await original(...args);if(args[1].includes('taxMode=BUY_DECLARATION'))r.body.data={records:[f.rows[1]],total:1};return r} + await assert.rejects(verifyRemainderTaxIdentity(f.context,f.evidence,'own-customer'),/otherTax/) +}) + +test('F40 unified projections require exact root and customer session with final COMPLETED or unfinished DELIVERED',async()=>{ + for (const completed of [true,false]) { + const context=fixture(),calls=[],snapshot={root:{status:completed?'COMPLETED':'TRANSPORTING',histories:history('OVERSEAS_DELIVERED')}} + context.request=async(method,path,body,token)=>{ + calls.push({method,path,token}) + return {http:200,body:{code:'200',data:{summary:{orderId:'100',mainStatus:completed?'COMPLETED':'DELIVERED'}}}} + } + const result=await verifyRootProjections(context,'90','100','customer-session',snapshot) + assert.deepEqual(result.platform,result.customer) + assert.equal(calls[0].path,'/api/platform/v1/orders/100?tenantId=90') + assert.equal(calls[1].path,'/api/merchant/v1/orders/100') + assert.equal(calls[1].token,'customer-session') + assert.equal(context.steps.every(step=>step.passed),true) + } +}) +test('F40 customer mismatched root or prematurely completed projection fails closed',async()=>{ + for (const summary of [{orderId:'101',mainStatus:'COMPLETED'},{orderId:'100',mainStatus:'DELIVERED'}]) { + const context=fixture() + context.request=async(_method,path)=>({http:200,body:{code:'200',data:{summary: + path.startsWith('/api/platform')?{orderId:'100',mainStatus:'COMPLETED'}:summary}}}) + await assert.rejects(verifyRootProjections(context,'90','100','customer-session',{root:{status:'COMPLETED',histories:[]}}), + /平台客户统一状态/) + } +}) +test('F40 README documents all three independent modes and fail-closed receiving/zero child event evidence',()=>{ + const readme=readFileSync(new URL('./README.md',import.meta.url),'utf8') + for(const value of ['CHILD_FIRST','ROOT_SIGNED_REMAINDER_CANCELLED','orderStopAtApproved=true', + 'PROCESSING','WAIT_SPLIT','A0409','phase4ReceivingEvidenceReader','零事件子行'])assert.ok(readme.includes(value),value) + assert.match(source,/proof\.beforeReceiptReplayOutbox = await waitState/) + assert.match(source,/outboxCounts\(proof.beforeReceiptReplayOutbox\) === outboxCounts\(proof.outboxProof\)/) + assert.match(source,/validFreshReceiptSave\(beforeFresh,afterFresh\)/) + assert.match(source,/JSON.stringify\(afterFresh\) === JSON.stringify\(receiptProof.afterSameKey\)/) +}) + +test('fresh receipt save allows exactly one document version/revision/audit while business completion stays unchanged',()=>{ + const documents={version:'2',uploadedCategoryCount:1,categories:[{category:'RECEIPT',files:[{fileId:'1'}]}],replayed:false} + const before={documents,delivery:{order:{version:'2',status:'DELIVERED',signedAt:'2026-09-30T16:00:00'}, + operations:[{id:'1',operationNode:'RECEIPT_AUTO_COMPLETE'}],skus:[{packageCount:1}],fees:[]},revisions:[{version:'2'}]} + const after=structuredClone(before);after.documents.version='3';after.delivery.order.version='3' + after.delivery.operations.push({id:'2',operationNode:'SAVE_DOCUMENTS',result:'保存派送资料:1/3类', + operatedAt:'2026-09-30T16:01:00',operatorName:'platform'}) + after.revisions.push({version:'3',savedAt:'2026-09-30T16:01:00',operatorId:'platform',snapshot:after.documents}) + assert.equal(validFreshReceiptSave(before,after),true) + for(const mutate of [ + value=>value.documents.version='4',value=>value.delivery.order.version='2', + value=>value.delivery.order.signedAt='2026-09-30T16:01:00',value=>value.delivery.order.status='RETURNED', + value=>value.documents.categories[0].files[0].fileId='2',value=>value.delivery.skus[0].packageCount=2, + value=>value.delivery.fees.push({amount:1}),value=>value.delivery.operations[0].id='999', + value=>value.delivery.operations[1].operationNode='RECEIPT_AUTO_COMPLETE', + value=>value.delivery.operations.push({...value.delivery.operations[1],id:'3'}), + value=>value.revisions.pop(),value=>value.revisions[0].version='1', + ]){const bad=structuredClone(after);mutate(bad);assert.equal(validFreshReceiptSave(before,bad),false)} +}) +test('F40 retains partial and failed receiving raw snapshots without exporting identity credentials',()=>{ + const proof={},scope={createdResources:{phase4OverseasPda:{receivingCompletionEvidence:{}}},token:'secret'} + preserveReceivingEvidence(proof,scope) + scope.createdResources.phase4OverseasPda.receivingCompletionEvidence['20']={status:'UNPROVEN',stages:{ + BEFORE_COMPLETE:{captured:true,evidence:{balances:[]}},AFTER_COMPLETE:{captured:false,evidence:{balances:[{id:'1'}]}}}} + assert.deepEqual(proof.receivingCompletionEvidence,scope.createdResources.phase4OverseasPda.receivingCompletionEvidence) + assert.equal(JSON.stringify(proof).includes('secret'),false) + assert.match(source,/finally \{ preserveReceivingEvidence\(proof,scope\);/) + assert.match(source,/proof.receiptReplayProofs \|\|= \[\]/) +}) + +test('F40 transfer uses the current formal Transfer DTO including required positive fee and same LocalDateTime tool',() => { + const dto=readFileSync(new URL('../../lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java',import.meta.url),'utf8') + const vehicle={carrierName:'offline-fleet',vehiclePlate:'offline-plate',driverName:'offline-driver',driverPhone:'19900000000'} + const now=new Date('2026-09-30T01:02:03.456Z'),body=f40TransferBody('offline',1,vehicle,now) + for(const field of ['idempotencyKey','plannedDeliveryAt','deliveryMethod','pickupMethod','loadingType','fees']) { + assert.match(dto,new RegExp(`\\b${field}\\b`));assert.ok(body[field]) + } + assert.equal(body.plannedDeliveryAt,'2026-10-02T01:02:03') + assert.equal(f40LocalDateTime(now),'2026-09-30T01:02:03') + assert.equal(body.deliveryMethod,'TRUCK_PALLET');assert.equal(body.pickupMethod,'WAREHOUSE_PICKUP') + assert.equal(body.loadingType,'PALLET');assert.equal(body.vehicleSize,'15托') + for(const field of Object.keys(vehicle))assert.equal(body[field],vehicle[field]) + assert.equal(body.fees.length,1) + assert.deepEqual(Object.fromEntries(['feeType','feeName','direction','currency','amount'].map(field=>[field,body.fees[0][field]])), + {feeType:'DELIVERY_FEE',feeName:'派送费',direction:'RECEIVABLE',currency:'USD',amount:10}) + assert.match(source,/f40TransferBody\(context.marker,index,vehicle\)/) + assert.match(source,/estimatedArrivalAt: f40LocalDateTime\(/) +}) +test('F40 credentials prefer a complete current fixture pair and never combine fixture with configured customer',() => { + const options={username:'offline-admin',password:'offline-admin-secret',merchantUsername:'configured-customer',merchantPassword:'configured-secret'} + const current={username:'fixture-customer',password:'fixture-secret',tenantId:'90'} + assert.deepEqual(resolveF40MerchantCredentials({options,context:{merchantQualificationFixture:current}}), + {username:'fixture-customer',password:'fixture-secret',customerId:'90'}) + assert.deepEqual(resolveF40MerchantCredentials({options,context:{}}), + {username:'configured-customer',password:'configured-secret',customerId:null}) + for(const incomplete of [{username:'fixture-only',tenantId:'90'},{password:'fixture-only',tenantId:'90'}, + {username:'fixture-customer',password:'fixture-secret'}]) + assert.equal(resolveF40MerchantCredentials({options,context:{merchantQualificationFixture:incomplete}}),null) + assert.equal(resolveF40MerchantCredentials({options:{merchantUsername:'incomplete'},context:{}}),null) + assert.ok(source.indexOf("'F40 正式订单创建客户与本轮登录客户一致'") < source.indexOf("await stage(scope, 'inbound-measurement')")) +}) +test('F40 reads formal nested domestic cabinet workbench and compares booking/plan/task identity instead of inventing GET containers/id',() => { + const controller=readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java',import.meta.url),'utf8') + const dto=readFileSync(new URL('../../firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/ContainerWorkbenchDtos.java',import.meta.url),'utf8') + assert.match(controller,/@GetMapping\("\/domestic\/\{id\}"\)/) + assert.doesNotMatch(controller,/@GetMapping\("\/\{id\}"\)/) + assert.match(dto,/record WorkbenchItem\(ContainerBookingEntity container, String workbenchStage/) + assert.match(source,/`\/api\/platform\/v1\/containers\/domestic\/\$\{result.containerId\}`/) + assert.doesNotMatch(source,/`\/api\/platform\/v1\/containers\/\$\{result.containerId\}`/) + const fragment={containerId:'20',planId:'30',taskId:'40',oceanBookingId:'50'},task={id:'40',containerBookingId:'20',loadingPlanId:'30'} + const workbench={container:{id:'20',cabinetNo:'D0001',containerNo:'OFFLINE',oceanBookingId:'50'}} + assert.equal(validFragmentCabinetIdentity(workbench,fragment,task),true) + assert.equal(validFragmentCabinetIdentity(workbench,{...fragment,containerId:'21'},task),false) + assert.equal(validFragmentCabinetIdentity(workbench,fragment,{...task,containerBookingId:'21'}),false) + assert.equal(validFragmentCabinetIdentity(workbench,fragment,{...task,loadingPlanId:'31'}),false) + assert.equal(validFragmentCabinetIdentity(workbench,fragment,{...task,id:'41'}),false) + assert.equal(validFragmentCabinetIdentity(workbench,{...fragment,oceanBookingId:'51'},task),false) + assert.equal(validFragmentCabinetIdentity(workbench.container,fragment,task),false) +}) + +test('formal tree requires one root physical source plus its exact real remainder child and conserves two boxes',() => { + assert.equal(validTwoFragmentTree(tree(),'100','101'),true) + for (const change of [ + value => value.parentOrderId='999', value => value.currentRemainderPackageCount=1, + value => value.children[2].sourceOrderId='999', value => value.children[1].sourceOrderId='999', + value => value.children[2].packageCount=2, + value => value.children.push({splitRole:'UNKNOWN',orderId:'999'}), + ]) {const value=tree();change(value);assert.equal(validTwoFragmentTree(value,'100','101'),false)} +}) +test('F40 separates immutable physical task cartons from current actual FBA receiving prefix scans',() => { + assert.match(source,/cartonMarks: stableLoadedCartonMarks\(task.items\)/) + assert.match(source,/receivingCartonMarks:\[`\$\{current.summary.inboundCode\}#PDA-CONTRACT-1`\]/) + assert.doesNotMatch(source,/cartonMarks:\s*\[`\$\{current.summary.inboundCode\}#PDA-CONTRACT/) +}) +test('raw Order and original status history remain in evidence including operator, reason and timestamp',() => { + const detail={order:{id:'100',orderRole:'MASTER',version:3,status:'TRANSPORTING'},statusHistory:[ + {id:'11',orderId:'100',action:'OVERSEAS_DELIVERED',operatorName:'OVERSEAS_DELIVERY_EVENT', + reason:'signed actual delivery',occurredAt:'2026-09-30T01:00:00',fromStatus:'TRANSPORTING',toStatus:'TRANSPORTING'}, + ]} + const snapshot=fulfillmentHistorySnapshot(detail) + assert.deepEqual(snapshot.rawOrder,detail.order) + assert.deepEqual(snapshot.histories,detail.statusHistory) + assert.deepEqual(completionCounts(snapshot),{own:1,final:0}) +}) +test('root shipment signed first preserves unfinished root while effective child remains pending',() => { + const snapshot=signed();snapshot.root.status='TRANSPORTING' + snapshot.root.histories=history('OVERSEAS_DELIVERED');snapshot.child={status:'TRANSPORTING',histories:[]} + snapshot.rootInbound.orderStatusSnapshot='TRANSPORTING' + assert.equal(validSignatureGate(snapshot,'MASTER'),true) + snapshot.root.status='COMPLETED' + assert.equal(validSignatureGate(snapshot,'MASTER'),false) +}) +test('child signed before root remains an independent completed child and cannot finish unsigned root',() => { + const snapshot=signed();snapshot.root={status:'TRANSPORTING',histories:[]} + snapshot.rootInbound={processStatus:'IN_TRANSIT',orderStatusSnapshot:'TRANSPORTING'} + assert.equal(validSignatureGate(snapshot,'CHILD'),true) + assert.equal(validSignatureGate(snapshot,'MASTER'),false) + snapshot.root.histories=history('OVERSEAS_DELIVERED') + assert.equal(validSignatureGate(snapshot,'CHILD'),false) +}) +test('all signed requires root own fact, exactly one aggregate final history and real Firstleg delivered completion',() => { + assert.equal(validSignatureGate(signed(),'MASTER',true),true) + for(const change of [ + value => value.root.histories=history('FULFILLMENT_COMPLETED'), + value => value.root.histories.push({action:'FULFILLMENT_COMPLETED'}), + value => value.child.histories.push({action:'OVERSEAS_DELIVERED'}), + value => value.child.status='CANCELLED', + value => value.rootInbound.orderStatusSnapshot='TRANSPORTING', + value => value.rootInbound.processStatus='RECEIVED', + ]) {const value=signed();change(value);assert.equal(validSignatureGate(value,'MASTER',true),false)} +}) +test('Outbox requires exact customer/root/source aggregate membership, unique completion and PUBLISHED',() => { + assert.equal(exactCompletionOutboxProof(outbox(),'90','100',['101','100']),true) + for(const change of [ + value => value.readOnly=false, value => value.customerId='91', value => value.rootOrderId='999', + value => value.orders[0].rootOrderId='999', value => value.orders[0].customerId='91', + value => value.orders[1].orderId='100', value => value.orders[0].count=2, + value => value.orders[1].publishedCount=0, value => value.orders[1].pendingCount=1, + value => value.orders[0].failedCount=1, value => value.orders[0].eventType='OVERSEAS_DELIVERED', + value => value.orders.pop(), + ]) {const value=outbox();change(value);assert.equal(exactCompletionOutboxProof(value,'90','100',['100','101']),false)} +}) +test('exported fragment producer preserves fail closed supplier precondition and never writes without actual source master',async() => { + const context=fixture(),calls=[] + context.request=async(method,path) => {calls.push([method,path]);return {http:200,body:{code:200,data:{records:[]}}}} + assert.equal(await loadOneFragment(context,{sourceOrderNo:'offline-only'}),null) + assert.equal(calls.length,1) + assert.equal(calls[0][0],'GET') + assert.match(calls[0][1],/category=BOOKING&status=ACTIVE/) +}) +test('exported pallet label helper preserves FBA eligibility without introducing writes for other destinations',async() => { + const context=fixture();context.request=async() => {throw new Error('unexpected HTTP')} + assert.equal(await verifyPalletLabels(context,{destinationType:'OVERSEAS'},'1'),null) + assert.equal(await verifyPalletLabels(context,{destinationType:'FBA',fulfillmentType:'ONE_CLICK_DROPSHIP'},'1'),null) +}) +test('exported consumer polling and conversion fail closed when real producer consumption is absent',async() => { + const context=fixture(),calls=[] + context.request=async(method,path) => {calls.push([method,path]);return {http:200,body:{code:200,data:{records:[]}}}} + assert.equal(await awaitProducerConsumption(context,[]),true) + assert.equal(await convertReceivingContainers(context,[{containerNo:'OFFLINE'}]),false) + assert.equal(calls.every(([method]) => method==='GET'),true) + assert.equal(context.steps.at(-1).passed,false) +}) +test('exported receiving rejects unmatched current producer instead of borrowing any history container',async() => { + const context=fixture();context.request=async(_method,path) => ({http:200,body:{code:200,data: + path.endsWith('/candidates')?[{containerNo:'ANOTHER',sourceContainerBookingId:'999',totalPackages:1}]:null}}) + await runReceiving(context,'offline-pda',null,null,{containerNo:'EXPECTED',containerBookingId:'100'},false,{attempts:1}) + assert.equal(context.steps.some(step => step.passed === false),true) +}) +test('exported receiving warehouse resolver continues to choose only configured active official warehouse',async() => { + const context=fixture();context.request=async() => ({http:200,body:{code:200,data:{records:[ + {id:'193000000000200003',warehouseType:'OVERSEAS',status:'ACTIVE',warehouseName:'offline'}, + ]}}}) + assert.deepEqual(await resolveSharedWarehouse(context),{id:'193000000000200003',name:'offline'}) +}) +test('complete orchestrator calls actual source chain and blocks missing Outbox evidence; no fake callback or SQL',() => { + assert.equal(typeof run,'function') + for(const name of ['orders','inbound-measurement','phase3-customs','phase3-outbound-dispatch', + 'phase3-carrier-schedule-queries'])assert.ok(source.includes(`'${name}'`)) + for(const name of ['loadOneFragment','verifyPalletLabels','awaitProducerConsumption', + 'convertReceivingContainers','prepareTerminalDrayage','runReceiving','pickAll','withDeliveryEventRecovery']) + assert.match(source,new RegExp(`await ${name}\\(`)) + assert.match(source,/\(\) => saveReceiptAndComplete\(context, deliveries\[0\], customerId, index\)/) + assert.match(source,/rootOrderId,\s*sourceOrderIds:\[rootOrderId,childOrderId\]/) + assert.match(source,/HTTP_VERIFIED_OUTBOX_PENDING/) + assert.match(source,/finally \{ preserveReceivingEvidence\(proof,scope\); proof.currentStage = 'PDA_IDENTITY_CLEANUP'; await disablePicker/) + assert.doesNotMatch(source,/['"`]\/internal\/|UPDATE\s+ord_|INSERT\s+INTO|\/complete-delivery/) +}) + +const oneLoadedTree = () => ({...tree(),cumulativeLoadedPackageCount:1,currentRemainderPackageCount:1, + children:tree().children.slice(0,2).map(row=>({...row,loadingPlanId:'30',containerBookingId:'20'}))}) +const approvedChild = () => fulfillmentHistorySnapshot({order:{id:'101',tenantId:'90',orderRole:'CHILD', + rootOrderId:'100',parentOrderId:'100',status:'APPROVED',version:1,orderNo:'R-1',inboundCode:'I-1',completedAt:null},statusHistory:[]}) +const unshippedInbound = () => ({id:'301',tenantId:'90',orderId:'101',orderNo:'R-1',inboundCode:'I-1', + processStatus:'WAIT_SPLIT',orderStatusSnapshot:'APPROVED',totalPackageCount:1,containerNo:null,cabinetNo:null}) +const cancelled = () => ({root:{status:'COMPLETED',histories:history('OVERSEAS_DELIVERED','FULFILLMENT_COMPLETED')}, + child:{status:'CANCELLED',rawOrder:{completedAt:null},histories:history('CANCELLED')}, + rootInbound:{processStatus:'DELIVERED',orderStatusSnapshot:'COMPLETED'}}) +const cancellationOutbox = () => {const value=outbox();value.orders[1].count=0;value.orders[1].publishedCount=0;return value} + +test('unloaded remainder requires real raw APPROVED child, exact customer/root/source and one remaining physical box',() => { + const child=approvedChild(),inbound=unshippedInbound(),tree=oneLoadedTree() + assert.equal(validUnloadedRemainder(tree,child,inbound,'90','100','101'),true) + for(const mutate of [ + value => value.child.status='PROCESSING',value => value.child.status='ACCEPTED', + value => value.child.rootOrderId='999',value => value.child.parentOrderId='999', + value => value.child.rawOrder.tenantId='91',value => value.child.orderId='102', + value => value.child.histories=history('OVERSEAS_DELIVERED'), + value => value.inbound.orderId='102',value => value.inbound.tenantId='91', + value => value.inbound.inboundCode='ANOTHER',value => value.inbound.orderNo='ANOTHER', + value => value.inbound.processStatus='LOADED',value => value.inbound.totalPackageCount=2, + value => value.inbound.orderStatusSnapshot='PROCESSING',value => value.inbound.containerNo='ACTUAL-CABINET', + value => value.inbound.cabinetNo='ACTUAL-CABINET',value => value.tree.children.push(tree.children[0]), + value => value.tree.children[0].sourceOrderId='101',value => value.tree.children[1].loadingPlanId='31', + value => value.tree.children[1].containerBookingId='21',value => value.tree.currentRemainderPackageCount=0, + ]) { + const value={tree:oneLoadedTree(),child:approvedChild(),inbound:unshippedInbound()};mutate(value) + assert.equal(validUnloadedRemainder(value.tree,value.child,value.inbound,'90','100','101'),false) + } +}) +test('cancelled remainder cannot substitute for child signature in either full-signature mode',() => { + assert.equal(validRemainderCancellationGate(cancelled()),true) + assert.equal(validSignatureGate(cancelled(),'MASTER',true),false) + assert.equal(validSignatureGate(cancelled(),'CHILD',true),false) + for(const mutate of [ + value=>value.root.histories=history('FULFILLMENT_COMPLETED'), + value=>value.root.histories.push({action:'FULFILLMENT_COMPLETED'}), + value=>value.child.status='COMPLETED',value=>value.child.histories.push({action:'OVERSEAS_DELIVERED'}), + value=>value.child.histories.push({action:'CANCELLED'}),value=>value.child.rawOrder.completedAt='2026-09-30T01:00:00', + value=>value.rootInbound.orderStatusSnapshot='APPROVED',value=>value.rootInbound.processStatus='RECEIVED', + ]){const value=cancelled();mutate(value);assert.equal(validRemainderCancellationGate(value),false)} +}) +test('cancellation Outbox requires exactly one published root completion and explicit zero child completion counts',() => { + assert.equal(exactCancelledRemainderOutboxProof(cancellationOutbox(),'90','100','101'),true) + assert.equal(exactCompletionOutboxProof(cancellationOutbox(),'90','100',['100','101']),false) + for(const mutate of [ + value=>value.orders.pop(),value=>value.orders[1].count=1,value=>value.orders[1].publishedCount=1, + value=>value.orders[1].pendingCount=1,value=>value.orders[1].failedCount=1, + value=>value.orders[1].count=null,value=>delete value.orders[1].failedCount, + value=>value.orders[1].rootOrderId='999',value=>value.orders[1].customerId='91', + value=>value.orders[1].orderId='100',value=>value.orders[1].eventType='ORDER_CANCELLED', + value=>value.orders[0].count=2,value=>value.orders[0].publishedCount=0,value=>value.readOnly=false, + ]){const value=cancellationOutbox();mutate(value);assert.equal(exactCancelledRemainderOutboxProof(value,'90','100','101'),false)} +}) +function cancellationFixture(change=null) { + const context=fixture(),calls=[],proof={snapshots:[]} + const rootDetail={order:{id:'100',tenantId:'90',orderRole:'MASTER',rootOrderId:'100',status:'APPROVED', + version:1,orderNo:'R',inboundCode:'I',completedAt:null},statusHistory:history('OVERSEAS_DELIVERED')} + const childDetail={order:approvedChild().rawOrder,statusHistory:[]} + const rootInbound={summary:{processStatus:'DELIVERED',orderStatusSnapshot:'APPROVED'}} + const childInbound={summary:unshippedInbound()},tree=oneLoadedTree() + let count=0 + context.context={f40BaseMarker:'offline-only',f40ReadOnlyOutboxEvidence:async args=>{ + assert.deepEqual(args,{customerId:'90',rootOrderId:'100',sourceOrderIds:['100','101'], + eventType:'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED'}) + return cancellationOutbox() + }} + if(change)change({rootDetail,childDetail,childInbound,tree}) + let frozenCancel + context.request=async(method,path,body,token,headers)=>{ + calls.push({method,path,body,headers}) + if(method==='POST'){ + assert.equal(path,'/api/platform/v1/orders/first-leg/101/cancel?tenantId=90') + assert.ok(body.reason.includes('offline-only')) + assert.ok(headers?.['Idempotency-Key']) + if(count++)return {http:200,body:{code:headers['Idempotency-Key']===frozenCancel.key && JSON.stringify(body)===frozenCancel.payload ? '200' : 'A0409'}} + assert.equal(body.expectedVersion,childDetail.order.version) + frozenCancel={key:headers['Idempotency-Key'],payload:JSON.stringify(body)} + childDetail.order.status='CANCELLED';childDetail.order.version++ + childDetail.statusHistory=history('CANCELLED') + rootDetail.order.status='COMPLETED';rootDetail.order.version++ + rootDetail.order.completedAt='2026-09-30T01:00:00' + rootDetail.statusHistory=history('OVERSEAS_DELIVERED','FULFILLMENT_COMPLETED') + rootInbound.summary.orderStatusSnapshot='COMPLETED' + return {http:200,body:{code:'200',data:null}} + } + let data + if(path.includes('/fulfillment-tree?'))data=tree + else if(path.includes('/orders/first-leg/100?'))data=rootDetail + else if(path.includes('/orders/first-leg/101?'))data=childDetail + else if(path.includes('/inbounds/300?'))data=rootInbound + else if(path.includes('/inbounds/301?'))data=childInbound + else throw new Error(`unexpected offline request ${path}`) + return {http:200,body:{code:'200',data:structuredClone(data)}} + } + return {context,proof,calls} +} +test('formal cancellation helper rereads physical eligibility, uses public cancel and accepts replay rejection only with unchanged facts and Outbox',async()=>{ + const {context,proof,calls}=cancellationFixture() + const result=await cancelUnloadedRemainder(context,proof,'90','100','101','300','301') + assert.equal(validRemainderCancellationGate(result),true) + const writes=calls.filter(row=>row.method==='POST') + assert.equal(writes.length,4) + assert.deepEqual(writes[1].body,writes[0].body) + assert.deepEqual(writes[1].headers,writes[0].headers) + assert.equal(writes[2].headers['Idempotency-Key'],writes[0].headers['Idempotency-Key']) + assert.notEqual(writes[3].headers['Idempotency-Key'],writes[0].headers['Idempotency-Key']) + assert.ok(writes[3].body.expectedVersion>writes[0].body.expectedVersion) + assert.equal(proof.snapshots.length,3) + assert.deepEqual(proof.cancelReplayOutbox.before,proof.cancelReplayOutbox.after) + assert.equal(context.steps.every(step=>step.passed),true) +}) +test('formal cancellation helper stops before any mutation for PROCESSING, unsigned root or already loaded child',async()=>{ + for(const change of [value=>value.childDetail.order.status='PROCESSING', + value=>value.rootDetail.order.status='PROCESSING',value=>value.rootDetail.statusHistory=[], + value=>value.childInbound.summary.processStatus='LOADED',value=>value.tree.children.push(tree().children[2])]) { + const {context,proof,calls}=cancellationFixture(change) + await assert.rejects(cancelUnloadedRemainder(context,proof,'90','100','101','300','301'),/仅取消真实签收根下未装 APPROVED/) + assert.equal(calls.some(row=>row.method!=='GET'),false) + } +}) +test('cancel scenario uses new approved-only order and exactly root shipment; both existing full-signature modes remain independent',()=>{ + assert.match(source,/const cancelRemainder = signatureOrder === 'ROOT_SIGNED_REMAINDER_CANCELLED'/) + assert.match(source,/orderStopAtApproved:cancelRemainder/) + assert.match(source,/approvedRoot.status === 'APPROVED'/) + assert.match(source,/if \(cancelRemainder\) \{[\s\S]*?proof.unloadedRemainder[\s\S]*?\} else \{[\s\S]*?second = await fragment/) + assert.match(source,/const producers = cancelRemainder \? \[first\] : \[first,second\]/) + assert.match(source,/signatureOrder === 'CHILD_FIRST' \? \[second,first\] : producers/) + assert.match(source,/if \(cancelRemainder\) await cancelUnloadedRemainder/) + const orderSource=readFileSync(new URL('./orders.mjs',import.meta.url),'utf8') + assert.match(orderSource,/context.context\?\.orderStopAtApproved === true/) +}) +test('early F40 failures become FAILED with only a safe stage and preserve the original exception',async() => { + const context=fixture();context.context={};context.options={} + await assert.rejects(run(context),/F40 本轮客户完整凭据有效/) + assert.equal(context.createdResources.phase5RootFulfillment.finalStatus,'FAILED') + assert.equal(context.createdResources.phase5RootFulfillment.failedStage,'CUSTOMER_IDENTITY') + assert.equal('error' in context.createdResources.phase5RootFulfillment,false) + const secretError=new Error('offline-secret-body-not-recorded') + const withLogin=fixture();withLogin.context={};withLogin.options={merchantUsername:'offline',merchantPassword:'offline'} + withLogin.request=async()=>{throw secretError} + await assert.rejects(run(withLogin),error=>error === secretError) + assert.equal(withLogin.createdResources.phase5RootFulfillment.finalStatus,'FAILED') + assert.equal(JSON.stringify(withLogin.createdResources.phase5RootFulfillment).includes(secretError.message),false) +}) diff --git a/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs new file mode 100644 index 000000000..3cbf1b110 --- /dev/null +++ b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs @@ -0,0 +1,751 @@ +import { loadOneFragment } from './phase3-order-splitting.mjs' +import { freezeOrderCommand } from './orders.mjs' +import { verifyPalletLabels } from './phase3-loading-finalize.mjs' +import { stableLoadedCartonMarks } from './phase3-loading.mjs' +import { awaitProducerConsumption, convertReceivingContainers, runReceiving, resolveSharedWarehouse, + resolvePickerAccess, createPicker, disablePicker } from './phase4-overseas-pda.mjs' +import { pickAll, verifyDepartureReadiness, saveReceiptAndComplete } from './phase5-overseas-fulfillment.mjs' +import { isDeepStrictEqual } from 'node:util' +import { assertTestDeliveryFaultProof } from '../test-delivery-fault-proof.mjs' +import { prepareTerminalDrayage } from './phase5-terminal-drayage.mjs' +import { prepareTaxCustoms, verifyTaxDocumentsAfterCompletion } from './phase5-root-tax-customs.mjs' +import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './fleet-trip-fulfillment.mjs' + +const MODULE = 'phase5-root-fulfillment' +const OPTIONS = { scenario: 'f40-real-root-and-remainder-signatures', deploymentBlocker: true } +const DISPATCH = '/api/platform/v1/last-leg/dispatches' +const PDA = '/api/pda/v1/last-leg/dispatches' +const id = value => value == null ? '' : String(value) +const success = response => response?.http === 200 && String(response?.body?.code) === '200' +const query = (path, values) => `${path}?${new URLSearchParams(values)}` + +export function f40LocalDateTime(value) { return value.toISOString().slice(0,19) } +export function preserveReceivingEvidence(proof, scope) { + proof.receivingCompletionEvidence = scope.createdResources?.phase4OverseasPda?.receivingCompletionEvidence || {} + proof.receivingCandidateEvidence = scope.createdResources?.phase4OverseasPda?.candidateWaitEvidence || [] +} +export function validFreshReceiptSave(before, after) { + const previousVersion = Number(before?.documents?.version) + const nextVersion = Number(after?.documents?.version) + if (!Number.isSafeInteger(previousVersion) || nextVersion !== previousVersion + 1 + || Number(before?.delivery?.order?.version) !== previousVersion + || Number(after?.delivery?.order?.version) !== nextVersion + || before?.delivery?.order?.status !== 'DELIVERED' || !before.delivery.order.signedAt) return false + const same = (left,right) => JSON.stringify(left) === JSON.stringify(right) + if (!same(before.documents,{...after.documents,version:before.documents.version})) return false + const oldOperations = before.delivery.operations, newOperations = after.delivery.operations + if (!Array.isArray(oldOperations) || !Array.isArray(newOperations) + || newOperations.length !== oldOperations.length + 1) return false + const oldIds = new Set(oldOperations.map(row => id(row.id))) + const added = newOperations.filter(row => !oldIds.has(id(row.id))) + if (added.length !== 1 || !id(added[0].id) || added[0].operationNode !== 'SAVE_DOCUMENTS' + || added[0].result !== `保存派送资料:${after.documents.uploadedCategoryCount}/3类` + || !added[0].operatedAt || !added[0].operatorName + || !same(oldOperations,newOperations.filter(row => oldIds.has(id(row.id))))) return false + if (!same(before.delivery,{...after.delivery,order:{...after.delivery.order,version:before.delivery.order.version}, + operations:oldOperations})) return false + if (!Array.isArray(before.revisions) || !Array.isArray(after.revisions) + || after.revisions.length !== before.revisions.length + 1) return false + const addedRevisions = after.revisions.filter(row => Number(row.version) === nextVersion) + return addedRevisions.length === 1 && Boolean(addedRevisions[0].savedAt && addedRevisions[0].operatorId) + && same(before.revisions,after.revisions.filter(row => Number(row.version) !== nextVersion)) + && same(addedRevisions[0].snapshot,after.documents) +} +export function resolveF40MerchantCredentials(context) { + const fixture = context.context?.merchantQualificationFixture + if (!fixture) { + const source = context.options + if (!source?.merchantUsername || !source?.merchantPassword) return null + return { username:source.merchantUsername, password:source.merchantPassword, customerId:null } + } + if (!fixture.username || !fixture.password || !id(fixture.tenantId)) return null + return { username:fixture.username, password:fixture.password, customerId:id(fixture.tenantId) } +} +export function f40TransferBody(marker, index, vehicle, now = new Date()) { + return { + idempotencyKey:`${marker}-F40-TRANSFER-${index}`, + plannedDeliveryAt:f40LocalDateTime(new Date(now.getTime()+172800000)), + deliveryMethod:'TRUCK_PALLET', waybillNo:`${marker}-WAYBILL-${index}`.slice(0,64), + carrierName:vehicle.carrierName, pickupMethod:'WAREHOUSE_PICKUP', loadingType:'PALLET', + vehicleType:'TRUCK', vehicleSize:'15托', driverName:vehicle.driverName, + driverPhone:vehicle.driverPhone, vehiclePlate:vehicle.vehiclePlate, + appointmentAccount:'SELF', sellerName:null, remark:`${marker}-phase5-transfer`, + fees:[{feeType:'DELIVERY_FEE',feeName:'派送费',direction:'RECEIVABLE',currency:'USD',amount:10, + remark:`${marker}-acceptance`}], + } +} +export function validFragmentCabinetIdentity(workbench, fragment, task) { + const container = workbench?.container + return id(container?.id) === id(fragment?.containerId) + && Boolean(container?.cabinetNo && container?.containerNo) + && id(container?.oceanBookingId) === id(fragment?.oceanBookingId) + && id(task?.id) === id(fragment?.taskId) + && id(task?.containerBookingId) === id(fragment?.containerId) + && id(task?.loadingPlanId) === id(fragment?.planId) +} + +export function fulfillmentHistorySnapshot(detail) { + return { + orderId: id(detail?.order?.id), rootOrderId: id(detail?.order?.rootOrderId), + parentOrderId: id(detail?.order?.parentOrderId), role: detail?.order?.orderRole, + status: detail?.order?.status, version: Number(detail?.order?.version), + inboundCode: detail?.order?.inboundCode, orderNo: detail?.order?.orderNo, + rawOrder: detail?.order, + histories: (detail?.statusHistory || []).map(row => ({ ...row, id: id(row.id) })), + } +} +export function validTwoFragmentTree(tree, rootOrderId, childOrderId) { + const rows = Array.isArray(tree?.children) ? tree.children : [] + const loaded = rows.filter(row => row.splitRole === 'LOADED') + const remainder = rows.filter(row => row.splitRole === 'REMAINDER') + return id(tree?.parentOrderId) === id(rootOrderId) && Number(tree.originalPackageCount) === 2 + && Number(tree.cumulativeLoadedPackageCount) === 2 && Number(tree.currentRemainderPackageCount) === 0 + && rows.length === 3 && loaded.length === 2 && remainder.length === 1 + && new Set(loaded.map(row => id(row.sourceOrderId))).size === 2 + && loaded.every(row => id(row.orderId) === id(row.sourceOrderId) && Number(row.packageCount) === 1) + && loaded.some(row => id(row.sourceOrderId) === id(rootOrderId)) + && loaded.some(row => id(row.sourceOrderId) === id(childOrderId)) + && id(remainder[0]?.orderId) === id(childOrderId) && id(remainder[0]?.sourceOrderId) === id(rootOrderId) + && Number(remainder[0]?.packageCount) === 1 +} +export function completionCounts(snapshot) { + return { + own: (snapshot?.histories || []).filter(row => row.action === 'OVERSEAS_DELIVERED').length, + final: (snapshot?.histories || []).filter(row => row.action === 'FULFILLMENT_COMPLETED').length, + } +} +export function validSignatureGate(snapshot, firstSource, allSigned = false) { + const root = completionCounts(snapshot?.root), child = completionCounts(snapshot?.child) + if (allSigned) return snapshot?.root?.status === 'COMPLETED' && snapshot?.child?.status === 'COMPLETED' + && root.own === 1 && root.final === 1 && child.own === 1 && child.final === 0 + && snapshot?.rootInbound?.processStatus === 'DELIVERED' + && snapshot?.rootInbound?.orderStatusSnapshot === 'COMPLETED' + if (firstSource === 'CHILD') return snapshot?.root?.status !== 'COMPLETED' + && snapshot?.child?.status === 'COMPLETED' && root.own === 0 && root.final === 0 + && child.own === 1 && snapshot?.rootInbound?.orderStatusSnapshot !== 'COMPLETED' + return firstSource === 'MASTER' && snapshot?.root?.status !== 'COMPLETED' + && snapshot?.child?.status !== 'COMPLETED' && root.own === 1 && root.final === 0 && child.own === 0 + && snapshot?.rootInbound?.processStatus === 'DELIVERED' + && snapshot?.rootInbound?.orderStatusSnapshot !== 'COMPLETED' +} +export function exactCompletionOutboxProof(evidence, customerId, rootOrderId, sourceOrderIds) { + const expected = [...new Set(sourceOrderIds.map(id))].sort() + const rows = Array.isArray(evidence?.orders) ? evidence.orders : [] + return evidence?.readOnly === true && id(evidence.customerId) === id(customerId) + && id(evidence.rootOrderId) === id(rootOrderId) + && rows.length === expected.length && JSON.stringify(rows.map(row => id(row.orderId)).sort()) === JSON.stringify(expected) + && rows.every(row => id(row.rootOrderId) === id(rootOrderId) && id(row.customerId) === id(customerId) + && row.eventType === 'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED' + && Number(row.count) === 1 && Number(row.publishedCount) === 1 && Number(row.pendingCount) === 0 + && Number(row.failedCount) === 0) +} +export function validUnloadedRemainder(tree, child, inbound, customerId, rootOrderId, childOrderId) { + const rows = Array.isArray(tree?.children) ? tree.children : [] + const loaded = rows.filter(row => row.splitRole === 'LOADED') + const remainder = rows.filter(row => row.splitRole === 'REMAINDER') + return Boolean(id(rootOrderId) && id(childOrderId) && id(rootOrderId) !== id(childOrderId)) + && id(tree?.parentOrderId) === id(rootOrderId) && Number(tree.originalPackageCount) === 2 + && Number(tree.cumulativeLoadedPackageCount) === 1 && Number(tree.currentRemainderPackageCount) === 1 + && rows.length === 2 && loaded.length === 1 && remainder.length === 1 + && id(loaded[0].orderId) === id(rootOrderId) && id(loaded[0].sourceOrderId) === id(rootOrderId) + && Number(loaded[0].packageCount) === 1 + && id(remainder[0].orderId) === id(childOrderId) && id(remainder[0].sourceOrderId) === id(rootOrderId) + && Number(remainder[0].packageCount) === 1 + && id(remainder[0].loadingPlanId) === id(loaded[0].loadingPlanId) + && id(remainder[0].containerBookingId) === id(loaded[0].containerBookingId) + && Boolean(id(loaded[0].loadingPlanId) && id(loaded[0].containerBookingId)) + && child?.role === 'CHILD' && child.status === 'APPROVED' + && child.orderId === id(childOrderId) && child.rootOrderId === id(rootOrderId) + && child.parentOrderId === id(rootOrderId) && id(child.rawOrder?.tenantId) === id(customerId) + && completionCounts(child).own === 0 && completionCounts(child).final === 0 + && Boolean(id(inbound?.id) && inbound?.inboundCode) + && id(inbound.orderId) === id(childOrderId) && id(inbound.tenantId) === id(customerId) + && inbound.inboundCode === child.inboundCode && inbound.orderNo === child.orderNo + && inbound.processStatus === 'WAIT_SPLIT' && inbound.orderStatusSnapshot === 'APPROVED' + && Number(inbound.totalPackageCount) === 1 && !inbound.containerNo && !inbound.cabinetNo +} +export function validRemainderCancellationGate(snapshot) { + const root = completionCounts(snapshot?.root), child = completionCounts(snapshot?.child) + return snapshot?.root?.status === 'COMPLETED' && snapshot?.child?.status === 'CANCELLED' + && root.own === 1 && root.final === 1 && child.own === 0 && child.final === 0 + && snapshot.child.histories.filter(row => row.action === 'CANCELLED').length === 1 + && snapshot.child.rawOrder?.completedAt == null + && snapshot?.rootInbound?.processStatus === 'DELIVERED' + && snapshot?.rootInbound?.orderStatusSnapshot === 'COMPLETED' +} +export function exactCancelledRemainderOutboxProof(evidence, customerId, rootOrderId, childOrderId) { + const rows = Array.isArray(evidence?.orders) ? evidence.orders : [] + const root = rows.filter(row => id(row.orderId) === id(rootOrderId)) + const child = rows.filter(row => id(row.orderId) === id(childOrderId)) + return id(rootOrderId) !== id(childOrderId) && rows.length === 2 && root.length === 1 && child.length === 1 + && exactCompletionOutboxProof({...evidence,orders:root},customerId,rootOrderId,[rootOrderId]) + && id(child[0].customerId) === id(customerId) && id(child[0].rootOrderId) === id(rootOrderId) + && child[0].eventType === 'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED' + && ['count','publishedCount','pendingCount','failedCount'].every(field => child[0][field] != null && Number(child[0][field]) === 0) +} +function outboxCounts(evidence) { + return JSON.stringify((evidence?.orders || []).map(row => ({ + orderId:id(row.orderId),customerId:id(row.customerId),rootOrderId:id(row.rootOrderId),eventType:row.eventType, + count:Number(row.count),publishedCount:Number(row.publishedCount),pendingCount:Number(row.pendingCount),failedCount:Number(row.failedCount), + })).sort((left,right) => left.orderId.localeCompare(right.orderId))) +} +function check(context, label, condition, evidence, response = null) { + context.assert(MODULE, label, response, condition, evidence, OPTIONS) + if (!condition) throw new Error(`${MODULE}: ${label}: ${evidence}`) +} +async function api(context, method, label, path, body, token = context.token, headers = {}) { + const response = await context.request(method, path, body, token, headers) + const data = context.apiSuccess(MODULE, label, response, '', OPTIONS) + if (!success(response)) throw new Error(`${MODULE}: ${label}: ${response?.body?.code || response?.http}`) + return data +} +async function stage(context, name) { + const mod = await import(`./${name}.mjs`) + const before = context.steps?.length || 0 + await mod.run(context) + const failures = (context.steps || []).slice(before).filter(step => step.passed === false) + check(context, `F40 ${name} 正式前置没有失败`, failures.length === 0, `failed=${failures.length}`) +} +async function state(context, customerId, rootOrderId, childOrderId, rootInboundId) { + const root = fulfillmentHistorySnapshot(await api(context, 'GET', '读取根订单原始状态与完整历史', + query(`/api/platform/v1/orders/first-leg/${rootOrderId}`, { tenantId: customerId }))) + const child = childOrderId ? fulfillmentHistorySnapshot(await api(context, 'GET', '读取真实余量子订单原始状态与历史', + query(`/api/platform/v1/orders/first-leg/${childOrderId}`, { tenantId: customerId }))) : null + const inbound = rootInboundId ? await api(context, 'GET', '读取根来源 Firstleg 入仓摘要', + query(`/api/platform/inbounds/${rootInboundId}`, { tenantId: customerId })) : null + return { root, child, rootInbound: inbound?.summary || null } +} +async function waitState(read, predicate, attempts = 90) { + let last + for (let index = 0; index < attempts; index++) { + last = await read() + if (predicate(last)) return last + if (index + 1 < attempts) await new Promise(resolve => setTimeout(resolve, 1000)) + } + return last +} +export async function verifyRootProjections(context, customerId, rootOrderId, merchantToken, snapshot) { + const completed = snapshot.root.status === 'COMPLETED' + const ownSigned = completionCounts(snapshot.root).own === 1 + const valid = value => id(value?.summary?.orderId) === id(rootOrderId) + && (completed ? value.summary.mainStatus === 'COMPLETED' + : ownSigned ? value.summary.mainStatus === 'DELIVERED' + : Boolean(value.summary.mainStatus) && value.summary.mainStatus !== 'COMPLETED') + const platform = await waitState(() => api(context,'GET','F40 平台统一根订单状态', + query(`/api/platform/v1/orders/${rootOrderId}`,{tenantId:customerId})),valid) + const customer = await api(context,'GET','F40 本企业客户统一根订单状态', + `/api/merchant/v1/orders/${rootOrderId}`,undefined,merchantToken) + check(context,'F40 平台客户统一状态与原始根完成门禁一致',valid(platform) && valid(customer) + && customer.summary.mainStatus === platform.summary.mainStatus,'exact-root-platform-customer-status') + return {platform:platform.summary,customer:customer.summary} +} +export async function verifyRemainderTaxIdentity(context, evidence, merchantToken) { + const {taxMode,customerId,rootOrderId,childOrderId,rootOrderNo,rawChild,rootInbound,childInbound}=evidence + check(context,'F40 余量税制核对范围完整',['BUY_DECLARATION','TAX_REFUND'].includes(taxMode) + && Boolean(rootOrderNo && customerId && rootOrderId && childOrderId),'exact-current-remainder-scope') + check(context,'F40 原始余量子订单继承本轮税制',rawChild?.taxMode===taxMode + && id(rawChild.id)===childOrderId && id(rawChild.tenantId)===customerId, + `expected=${taxMode}, actual=${rawChild?.taxMode || 'missing'}`) + check(context,'F40 根与余量入仓摘要税制和来源身份一致',rootInbound?.taxMode===taxMode + && childInbound?.taxMode===taxMode && id(rootInbound.orderId)===rootOrderId + && id(childInbound.orderId)===childOrderId && id(childInbound.tenantId)===customerId, + `rootTax=${rootInbound?.taxMode || 'missing'}, childTax=${childInbound?.taxMode || 'missing'}`) + const exactRows = page => Array.isArray(page?.records) && page.records.length===2 && Number(page.total)===2 + && new Set(page.records.map(row=>id(row.orderId))).size===2 + && page.records.every(row=>[rootOrderId,childOrderId].includes(id(row.orderId)) + && id(row.tenantId)===customerId && row.taxMode===taxMode + && id(row.relation?.rootOrderId)===rootOrderId + && (id(row.orderId)===rootOrderId?row.relation?.role==='PARENT' + :row.relation?.role==='CHILD'&&id(row.relation?.parentOrderId)===rootOrderId)) + for(const [name,filter] of [['unfiltered',null],['matchingTax',taxMode], + ['otherTax',taxMode==='TAX_REFUND'?'BUY_DECLARATION':'TAX_REFUND']]) { + const values={orderNo:rootOrderNo,page:'1',size:'200'} + if(filter)values.taxMode=filter + evidence[name]=await api(context,'GET',`F40 客户统一订单税制筛选对照${name}`, + query('/api/merchant/v1/orders',values),undefined,merchantToken) + check(context,`F40 客户筛选${name}精确覆盖本轮父子身份`,name==='otherTax' + ? Array.isArray(evidence[name]?.records)&&evidence[name].records.length===0&&Number(evidence[name].total)===0 + : exactRows(evidence[name]),`taxMode=${filter || 'unfiltered'}`) + } + return evidence +} +export async function cancelUnloadedRemainder(context, proof, customerId, rootOrderId, childOrderId, + rootInboundId, childInboundId) { + proof.currentStage = 'REMAINDER_CANCEL_PRECONDITION' + const before = await state(context,customerId,rootOrderId,childOrderId,rootInboundId) + const treePath = query(`/api/platform/v1/orders/${rootOrderId}/fulfillment-tree`,{tenantId:customerId}) + const tree = await api(context,'GET','取消前重新核对唯一未消费余量履约树',treePath) + const childInbound = await api(context,'GET','取消前回查未装余量入仓来源', + query(`/api/platform/inbounds/${childInboundId}`,{tenantId:customerId})) + check(context,'F40 仅取消真实签收根下未装 APPROVED 一箱余量', before.root.status === 'APPROVED' + && validSignatureGate(before,'MASTER') + && validUnloadedRemainder(tree,before.child,childInbound?.summary,customerId,rootOrderId,childOrderId), + 'raw-approved-root/child; one-loaded-root/one-unloaded-remainder') + proof.snapshots.push({phase:'BEFORE_REMAINDER_CANCELLATION',...before,tree,childInbound:childInbound.summary}) + const cancelPath = query(`/api/platform/v1/orders/first-leg/${childOrderId}/cancel`,{tenantId:customerId}) + const cancelCommand = freezeOrderCommand({order:before.child.rawOrder}, `${context.context.f40BaseMarker}-cancel-remainder`.slice(0,128), + {reason:`${context.context.f40BaseMarker} 未装柜余量取消验收`}) + proof.cancelCommand = cancelCommand + const cancelBody = cancelCommand.body + proof.currentStage = 'REMAINDER_CANCELLATION' + await api(context,'POST','正式取消未装柜 APPROVED 余量订单',cancelPath,cancelBody,context.token,cancelCommand.headers) + const after = await waitState(() => state(context,customerId,rootOrderId,childOrderId,rootInboundId), + validRemainderCancellationGate) + check(context,'F40 最后有效未装余量取消后根仅完成一次且子未签收',validRemainderCancellationGate(after), + 'root-own=1/root-final=1/child-cancel=1/child-signature=0') + const afterTree = await api(context,'GET','取消后保留真实分片数量台账',treePath) + check(context,'F40 取消没有伪造第二柜实装或改写分片数量', + JSON.stringify(tree.children) === JSON.stringify(afterTree.children) + && Number(afterTree.cumulativeLoadedPackageCount) === 1 && Number(afterTree.currentRemainderPackageCount) === 1, + 'ledger-retains-one-physical-box-and-one-cancelled-remainder') + proof.snapshots.push({phase:'ROOT_SIGNED_REMAINDER_CANCELLED',...after,tree:afterTree}) + proof.currentStage = 'REMAINDER_CANCEL_REPLAY' + const reader = context.context?.f40ReadOnlyOutboxEvidence + const request = {customerId,rootOrderId,sourceOrderIds:[rootOrderId,childOrderId], + eventType:'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED'} + const beforeOutbox = typeof reader === 'function' ? await waitState(() => reader(request), + value => exactCancelledRemainderOutboxProof(value,customerId,rootOrderId,childOrderId),30) : null + if (beforeOutbox) check(context,'F40 取消后唯一根完成Outbox且取消子完成Outbox为零', + exactCancelledRemainderOutboxProof(beforeOutbox,customerId,rootOrderId,childOrderId),'exact-root-one/cancelled-child-zero') + const replay = await context.request('POST',cancelPath,cancelBody,context.token,cancelCommand.headers) + check(context,'F40 原键原版本取消重放成功',success(replay),'cancel-original-key-replay',replay) + const conflict = await context.request('POST',cancelPath,{...cancelBody,reason:`${cancelBody.reason}-conflict`},context.token,cancelCommand.headers) + check(context,'F40 原键不同取消载荷返回 A0409',conflict?.http === 200 && String(conflict?.body?.code) === 'A0409','cancel-payload-conflict',conflict) + const duplicate = freezeOrderCommand({order:after.child.rawOrder}, `${context.context.f40BaseMarker}-cancel-new-key`.slice(0,128), {reason:cancelBody.reason}) + const rejected = await context.request('POST',cancelPath,duplicate.body,context.token,duplicate.headers) + check(context,'F40 新键重复取消返回 A0409',rejected?.http === 200 && String(rejected?.body?.code) === 'A0409','cancel-new-key-conflict',rejected) + const stable = await state(context,customerId,rootOrderId,childOrderId,rootInboundId) + const replayTree = await api(context,'GET','取消重放后回查原履约树',treePath) + check(context,'F40 取消重放订单版本历史根摘要及履约台账零变化', + JSON.stringify(after) === JSON.stringify(stable) && JSON.stringify(afterTree) === JSON.stringify(replayTree), + 'exact-root/child/history/summary/tree') + if (beforeOutbox) { + const afterOutbox = await reader(request) + check(context,'F40 取消重放完成Outbox计数发布状态零变化', + exactCancelledRemainderOutboxProof(afterOutbox,customerId,rootOrderId,childOrderId) + && outboxCounts(beforeOutbox) === outboxCounts(afterOutbox),'exact-root-child-completion-counts') + proof.cancelReplayOutbox = {before:beforeOutbox,after:afterOutbox} + } + proof.snapshots.push({phase:'AFTER_CANCELLATION_REPLAY',...stable,tree:replayTree}) + return stable +} +async function domesticLogin(context) { + if (context.pdaToken) return + const username = context.options.pdaUsername, password = context.options.pdaPassword + check(context, 'F40 显式国内 PDA 账号已配置', Boolean(username && password), 'credentialsPresent=true/false') + const login = await api(context, 'POST', '登录正式国内 PDA 会话', '/api/auth/login', { loginId: username, password }, '') + context.pdaToken = login.accessToken + check(context, 'F40 国内 PDA 会话有效', Boolean(context.pdaToken), 'tokenPresent=true/false') +} +async function fragment(context, source, rootOrderId, sequence) { + const summary = source.summary + const result = await loadOneFragment(context, { + tenantId: id(summary.tenantId), rootOrderId, sourceOrderId: id(summary.orderId), + sourceOrderNo: summary.orderNo, customerName: summary.customerName, + channelCode: summary.channelCode, channelName: summary.channelName, + warehouseId: id(summary.warehouseId), warehouseName: summary.warehouseName, + sourcePackageCount: sequence === 0 ? 2 : 1, sequence, + }) + check(context, 'F40 一箱来源通过真实 PDA 完成装柜', Boolean(result?.taskId && result?.sourceInboundId), `sequence=${sequence}`) + const current = await api(context, 'GET', '回查该片真实入仓来源', + query(`/api/platform/inbounds/${result.sourceInboundId}`, { tenantId: id(summary.tenantId) })) + const workbench = await api(context, 'GET', '回查该片实际柜次身份', `/api/platform/v1/containers/domestic/${result.containerId}`) + const container = workbench?.container + const task = await api(context, 'GET', '回查该片真实完成 PDA 任务', + query(`/api/platform/v1/first-leg/pda-loading/tasks/${result.taskId}`, { warehouseId: id(summary.warehouseId) })) + check(context,'F40 公开国内柜次详情和实际 PDA 的订舱柜次计划任务身份一致', + validFragmentCabinetIdentity(workbench,result,task),`containerBookingId=${result.containerId},planId=${result.planId},taskId=${result.taskId}`) + check(context, 'F40 来源单实际 LOADED 且 PDA 仅实装一箱', current?.summary?.processStatus === 'LOADED' + && task?.taskStatus === 'COMPLETED' && task.items.reduce((sum, item) => sum + Number(item.pickedPackageCount), 0) === 1, + `source=${current?.summary?.inboundCode}`) + const loading = { + marker: context.marker, tenantId: id(summary.tenantId), orderId: id(summary.orderId), + inboundId: result.sourceInboundId, inboundCode: current.summary.inboundCode, + oceanBookingId: result.oceanBookingId, containerBookingId: result.containerId, + loadingPlanId: result.planId, canonicalPdaTaskId: result.taskId, pickingTaskId: result.taskId, + warehouseId: id(summary.warehouseId), cabinetNo: container.cabinetNo, containerNo: container.containerNo, + destinationType: 'FBA', fulfillmentType: 'STANDARD', destinationWarehouseId: id(current.summary.destinationWarehouseId), + destinationWarehouseCode: current.summary.destinationCode, cartonMarks: stableLoadedCartonMarks(task.items), + // Standard FBA resolves the actual receiving inbound prefix; transferred physical box codes remain unchanged. + receivingCartonMarks:[`${current.summary.inboundCode}#PDA-CONTRACT-1`], + finalStatus: 'LOADED', inboundStatus: 'LOADED', finalizeSucceeded: true, + } + context.createdResources.phase3Loading = loading + context.createdResources.orders = { ...(context.createdResources.orders || {}), ...loading, + taxMode: context.context.f40TaxMode } + const palletLabelManifest = await verifyPalletLabels(context, loading, result.planId) + check(context, 'F40 来源柜散货托盘码来自真实准备接口', Boolean(palletLabelManifest?.totalCount), `sequence=${sequence}`) + if(context.context.f40TaxMode==='TAX_REFUND') await prepareTaxCustoms(context,loading) + else await stage(context,'phase3-customs') + for (const name of ['service-center-flow', 'phase3-port-schedules', + 'phase3-outbound-dispatch', 'phase3-container-documents', 'phase3-carrier-schedule-queries']) await stage(context, name) + const nodes = await import('./phase4-carrier-nodes.mjs') + await nodes.runUntilPickupReady(context) + const pickup = context.createdResources.phase4CarrierPickupReady + check(context, 'F40 来源柜实际节点达到 PICKUP_READY', pickup?.ready === true, `queryId=${pickup?.queryId || 'missing'}`) + return { ...loading, sourceOrderId: loading.orderId, sourceInboundId: loading.inboundId, + sourceStatus: 'LOADED', resourceKey: `${context.marker}:FBA:${loading.containerNo}`, + shippingReady: true, carrierScheduleQueryId: pickup.queryId, carrierLatestActualAt: pickup.latestActualAt, + palletLabelManifest, rootOrderId } +} +async function sendFragment(context, producer, picker, merchantToken, index) { + const customerId = producer.tenantId + const candidates = await api(context, 'GET', '读取该来源入仓码实收排车候选', `${DISPATCH}/candidates?sourceStage=RECEIVED`) + const matches = (Array.isArray(candidates) ? candidates : []).filter(row => row.sourceStage === 'RECEIVED' + && row.inboundCode === producer.inboundCode && id(row.overseasWarehouseId) === id(picker.warehouseId)) + check(context, 'F40 只选择该根履约片的真实实收托盘', matches.length > 0 + && matches.every(row => Number(row.packageCount) > 0), `inboundCode=${producer.inboundCode}, pallets=${matches.length}`) + const dispatch = await api(context, 'POST', '该履约片创建独立真实排车', `${DISPATCH}/pre-arrange`, { + planPalletIds: matches.map(row => id(row.planPalletId)), + }) + const dispatchNo = dispatch.dispatchNo + const vehicle = { + vehiclePlate: `F40-${index}-${context.marker.slice(-6)}`.slice(0,64), driverName: 'F40验收司机', + driverPhone: '19900000000', carrierName: 'F40测试车队', + estimatedArrivalAt: f40LocalDateTime(new Date(Date.now()+86400000)), + } + await api(context, 'PUT', '补齐该片车辆司机', `${DISPATCH}/${dispatchNo}`, vehicle) + const transfer = await api(context, 'POST', '客户正式创建该片派送单', + query('/api/merchant/v1/last-leg/delivery-orders', { dispatchNo }), + f40TransferBody(context.marker,index,vehicle), merchantToken) + const deliveries = Array.isArray(transfer.deliveries) ? transfer.deliveries : [] + check(context, 'F40 同目的仓一片对应唯一派送单', deliveries.length === 1 && Boolean(deliveries[0]?.deliveryNo), `count=${deliveries.length}`) + const deliveryPath = query(`/api/platform/v1/last-leg/delivery-orders/${deliveries[0].deliveryNo}`, { customerId }) + const detail = await api(context, 'GET', '核对派送实际 SKU 仅来自本片入仓码', deliveryPath) + check(context, 'F40 派送实际物品来源和一箱数量严格匹配', detail.order?.customerId === customerId + && detail.skus?.length > 0 && detail.skus.every(row => row.inboundCode === producer.inboundCode) + && detail.skus.reduce((sum,row) => sum + Number(row.packageCount),0) === 1, `source=${producer.inboundCode}`) + context.marker = `${context.context.f40BaseMarker}-SEND-${index}` + const tripProof = await prepareFleetTrip(context, MODULE, dispatchNo, picker.warehouseId) + check(context, 'F40 正式 PDA 拣货完成', await pickAll(context, picker.token, dispatchNo), `dispatch=${dispatchNo}`) + const tripVersion = await confirmFleetLoading(context, MODULE, tripProof) + check(context, 'F40 发车前置就绪', await verifyDepartureReadiness(context, picker.token, dispatchNo), `dispatch=${dispatchNo}`) + const departed = await api(context, 'POST', 'F40 正式 PDA 发车', `${PDA}/${dispatchNo}/depart`, + { recordVersion: tripVersion, idempotencyKey: `${context.marker}-DEPART`.slice(0,128), remark: context.marker }, picker.token) + check(context, 'F40 整车发车包含本片排车', departed.status === 'IN_TRANSIT' && departed.tripNo === tripProof.tripNo + && departed.dispatches?.some(item => item.dispatchNo === dispatchNo), `dispatch=${dispatchNo}`) + const outbound = await api(context, 'GET', '回查实际来源出库完成', + query(`/api/platform/v1/last-leg/outbound-orders/${transfer.outboundNo}`, { page:'1',size:'20' })) + check(context,'F40 实际出库单 OUTBOUNDED 且来源入仓码匹配', outbound.outbound?.status === 'OUTBOUNDED' + && outbound.pallets?.length > 0 && outbound.pallets.every(row => row.inboundCode === producer.inboundCode), + `outbound=${transfer.outboundNo}`) + const beforeDelivery = await api(context, 'GET', '读取本片派送推进前版本', deliveryPath) + const delivering = await api(context,'PUT','实际派送推进配送中', + query(`/api/platform/v1/last-leg/delivery-orders/${deliveries[0].deliveryNo}/status`,{customerId}), + {status:'DELIVERING',expectedVersion:Number(beforeDelivery.order.version),remark:context.marker}) + check(context,'F40 派送处于配送中后保存签收',delivering.deliveryStatus === 'DELIVERING',`delivery=${deliveries[0].deliveryNo}`) + const completed = await withDeliveryEventRecovery(context, { + marker: context.context.f40BaseMarker, customerId: id(customerId), deliveryId: id(beforeDelivery.order.id), + sourceInboundId: id(producer.sourceInboundId), sourceOrderId: id(producer.sourceOrderId), rootOrderId: id(producer.rootOrderId), + }, id(picker.warehouseId), index, () => saveReceiptAndComplete(context, deliveries[0], customerId, index)) + check(context, 'F40 实际签收资料驱动派送完成', completed?.order?.status === 'DELIVERED', `delivery=${deliveries[0].deliveryNo}`) + check(context,'F40 签收完成记录真实签收时间', Boolean(completed.order.signedAt), `delivery=${deliveries[0].deliveryNo}`) + const tripClosure = await finishFleetTrip(context, MODULE, tripProof) + return { producer, dispatchNo, outboundNo: transfer.outboundNo, deliveryNo: deliveries[0].deliveryNo, + receiptVersion: completed.order.version, signedAt: completed.order.signedAt, receiptSnapshot:completed, tripNo: tripProof.tripNo, tripClosure } +} + +/** Only the first actual FBA signature is faulted; caller performs the normal receipt command. */ +export async function withDeliveryEventRecovery(context, faultScope, warehouseId, index, sign) { + const executor = context.context.f40DeliveryEventFaultExecutor + if (!executor || index !== 0) return sign() + const proof = context.context.f40RootFulfillmentProof + if (!proof) throw Error('F40 parent fulfillment proof required') + if (proof.deliveryEventRecovery) throw Error('F40 delivery recovery already attempted') + const reader = context.context.f40ReadOnlyInventoryEvidence + if (typeof reader !== 'function') throw Error('F40 complete inventory evidence reader required') + const record = proof.deliveryEventRecovery = { scope: faultScope, status: 'PREPARING', restored: false } + const inventoryScope = { marker: faultScope.marker, customerId: faultScope.customerId, warehouseId } + try { + record.inventoryBefore = await reader(inventoryScope) + record.preparationAttempted = true + await executor.prepare(faultScope) + record.status = 'SIGNING' + const result = await sign() + record.status = 'RECOVERING' + record.proof = await executor.readProof(faultScope) + assertTestDeliveryFaultProof(record.proof, faultScope) + if (record.proof.restored !== true) throw Error('F40 delivery producer restoration missing') + record.inventoryAfter = await reader(inventoryScope) + if (record.inventoryBefore?.scope?.coverage !== 'CUSTOMER_WAREHOUSE_COMPLETE' + || record.inventoryBefore.scope.customerId !== faultScope.customerId + || record.inventoryBefore.scope.warehouseId !== warehouseId + || !['balances','positions','ledger'].every(key => Array.isArray(record.inventoryBefore[key])) + || !isDeepStrictEqual(record.inventoryBefore, record.inventoryAfter)) throw Error('F40 full inventory drift during delivery recovery') + record.inventoryUnchanged = true + record.status = 'VERIFIED' + return result + } catch (error) { + record.status = 'FAILED' + throw error + } finally { + if (record.preparationAttempted) try { + record.restoration = await executor.cleanup() + record.restored = record.restoration?.restored === true && record.restoration?.healthy === true && record.restoration?.proxyStopped === true + if (!record.restored) throw Error('F40 delivery restoration incomplete') + } catch (error) { + record.status = 'RESTORATION_FAILED' + record.restored = false + throw error + } + } +} + +/** Explicit TEST orchestrator, called by the parent acceptance context; never synthesizes an event. */ +export async function run(context) { + try { return await runRootFulfillment(context) } + catch (error) { + const proof = context.createdResources?.phase5RootFulfillment + if (proof) { + proof.finalStatus = 'FAILED' + proof.failedStage ||= proof.currentStage || 'INITIALIZING' + } + throw error + } +} +async function runRootFulfillment(context) { + const initialStepCount = context.steps?.length || 0 + const proof = context.createdResources.phase5RootFulfillment = { + marker: context.marker, rootOrderId: null, childOrderId: null, customerId: null, + fragments: [], signatures: [], snapshots: [], finalStatus: 'PREPARING', outboxProof: null, + currentStage:'INITIALIZING', failedStage:null, + cleanup: 'Fresh completed root/child/cabinet/PDA/receipt audit resources remain as acceptance evidence', + } + const signatureOrder = context.context?.f40SignatureOrder || 'ROOT_FIRST' + check(context,'F40 签收次序为显式正式来源顺序', + ['ROOT_FIRST','CHILD_FIRST','ROOT_SIGNED_REMAINDER_CANCELLED'].includes(signatureOrder), signatureOrder) + const cancelRemainder = signatureOrder === 'ROOT_SIGNED_REMAINDER_CANCELLED' + proof.signatureOrder = signatureOrder + const taxMode=context.context?.f40TaxMode||'BUY_DECLARATION' + check(context,'F40 税制必须为显式支持值',['BUY_DECLARATION','TAX_REFUND'].includes(taxMode),taxMode) + proof.taxMode=taxMode;proof.taxCustoms=[] + const scope = Object.create(context) + scope.marker = `${context.marker}-F40` + scope.context = { ...context.context, orderDestinationType: 'FBA', orderFulfillmentType: 'STANDARD', + f40RootFulfillmentProof: proof, + orderStopAtApproved:cancelRemainder, + orderProfile: { taxMode, fulfillmentType: 'STANDARD' },f40TaxMode:taxMode,f40TaxEvidence:proof.taxCustoms, + waitSplitVariant: { skuCount: 1, packageCountPerSku: 2, packageCounts: [2] }, + phase4OverseasPdaOperations: { completedFixtures: [] }, + allowAnyExistingPallet: true, f40BaseMarker: `${context.marker}-F40` } + scope.createdResources = { phase3OrderSplitting: { retainedAuditRecordIds: [] } } + proof.currentStage = 'CUSTOMER_IDENTITY' + const credentials = resolveF40MerchantCredentials(scope) + check(scope,'F40 本轮客户完整凭据有效', Boolean(credentials), 'complete-credential-pair') + const login = await api(scope,'POST','F40 本轮客户正式登录','/api/auth/login', + {loginId:credentials.username,password:credentials.password},'') + const profile = await api(scope,'GET','F40 本轮客户会话归属核验', + '/api/merchant/v1/iam/tenant/profile',undefined,login.accessToken) + const expectedCustomerId = id(profile.id) + if(taxMode==='TAX_REFUND')scope.context.f40TaxMerchantToken=login.accessToken + check(scope,'F40 客户会话和本轮客户 fixture 一致', Boolean(expectedCustomerId) + && (!credentials.customerId || credentials.customerId === expectedCustomerId),'exact-fixture-customer') + await domesticLogin(scope) + proof.currentStage = 'ORDER_CREATION' + await stage(scope, 'orders') + const orderFixture = { ...scope.createdResources.orders } + check(scope,'F40 正式订单创建客户与本轮登录客户一致', id(orderFixture.tenantId) === expectedCustomerId, + 'actual-order-customer-matches-profile') + if (cancelRemainder) { + const approvedRoot = fulfillmentHistorySnapshot(await api(scope,'GET','取消专项回查新根真实审核状态', + query(`/api/platform/v1/orders/first-leg/${orderFixture.orderId}`,{tenantId:expectedCustomerId}))) + check(scope,'F40 取消专项新根必须为原始 APPROVED 而非快照推断',approvedRoot.role === 'MASTER' + && approvedRoot.status === 'APPROVED' && approvedRoot.orderId === id(orderFixture.orderId) + && id(approvedRoot.rawOrder?.tenantId) === expectedCustomerId,'raw-approved-fresh-root') + proof.snapshots.push({phase:'APPROVED_NEW_ROOT',root:approvedRoot}) + } + proof.currentStage = 'DOMESTIC_MEASUREMENT' + await stage(scope, 'inbound-measurement') + const rootOrderId = id(orderFixture.orderId), customerId = id(orderFixture.tenantId) + proof.rootOrderId = rootOrderId; proof.customerId = customerId + const rootInbound = await api(scope, 'GET', '读取两箱根真实 WAIT_SPLIT 来源', + query(`/api/platform/inbounds/${orderFixture.inboundId}`, { tenantId: customerId })) + check(scope, 'F40 根实物数量确为两箱且可分柜', rootInbound.summary?.processStatus === 'WAIT_SPLIT' + && Number(scope.createdResources.inboundMeasurement.actualPackageCount) === 2, `root=${rootOrderId}`) + proof.currentStage = 'ROOT_LOAD_AND_SHIP' + const first = await fragment(scope, rootInbound, rootOrderId, 0) + proof.fragments.push(first) + proof.currentStage = 'REMAINDER_IDENTITY' + const tree = await api(scope, 'GET', '首柜 PDA 完成后读取正式余量树', + query(`/api/platform/v1/orders/${rootOrderId}/fulfillment-tree`, { tenantId: customerId })) + const remainder = tree.children?.filter(row => row.splitRole === 'REMAINDER') || [] + check(scope, 'F40 首柜仅生成一个真实一箱余量子单', remainder.length === 1 + && Number(remainder[0].packageCount) === 1 && Number(tree.currentRemainderPackageCount) === 1, `children=${remainder.length}`) + const childOrderId = id(remainder[0].orderId) + proof.childOrderId = childOrderId + const childDetail = await api(scope, 'GET', '读取实际余量订单身份', + query(`/api/platform/v1/orders/first-leg/${childOrderId}`, { tenantId: customerId })) + check(scope, 'F40 子订单根父身份完整一致', childDetail.order?.orderRole === 'CHILD' + && id(childDetail.order.rootOrderId) === rootOrderId && id(childDetail.order.parentOrderId) === rootOrderId, + `root=${rootOrderId}, child=${childOrderId}`) + const childCandidates = await api(scope, 'GET', '从公开入仓列表定位真实子入仓单', + query('/api/platform/inbounds', { tenantId: customerId, inboundCode: childDetail.order.inboundCode, page:'1',size:'20' })) + const childRows = (childCandidates.records || []).filter(row => id(row.orderId) === childOrderId + && row.inboundCode === childDetail.order.inboundCode) + check(scope, 'F40 子单有唯一独立真实入仓来源', childRows.length === 1, `sources=${childRows.length}`) + const childInbound = await api(scope, 'GET', '读取子单可装柜来源', + query(`/api/platform/inbounds/${childRows[0].id}`, { tenantId: customerId })) + proof.remainderTaxIdentity = {taxMode,customerId,rootOrderId,childOrderId,rootOrderNo:orderFixture.orderNo, + rawChild:childDetail.order,rootInbound:rootInbound.summary,childInbound:childInbound.summary} + await verifyRemainderTaxIdentity(scope,proof.remainderTaxIdentity,login.accessToken) + let second = null + if (cancelRemainder) { + check(scope,'F40 取消专项真实余量未装且原始 APPROVED', + validUnloadedRemainder(tree,fulfillmentHistorySnapshot(childDetail),childInbound?.summary, + customerId,rootOrderId,childOrderId),'one-approved-unloaded-remainder') + proof.unloadedRemainder = {childOrderId,childInboundId:id(childRows[0].id),tree,summary:childInbound.summary} + } else { + scope.marker = `${scope.context.f40BaseMarker}-CHILD` + proof.currentStage = 'CHILD_LOAD_AND_SHIP' + second = await fragment(scope, childInbound, rootOrderId, 1) + proof.fragments.push(second) + } + proof.currentStage = 'FULFILLMENT_TREE' + const finalTree = await api(scope, 'GET', '两柜装完后核对数量守恒履约树', + query(`/api/platform/v1/orders/${rootOrderId}/fulfillment-tree`, { tenantId: customerId })) + check(scope, 'F40 真实同根实装及余量数量守恒', cancelRemainder + ? validUnloadedRemainder(finalTree,fulfillmentHistorySnapshot(childDetail),childInbound?.summary, + customerId,rootOrderId,childOrderId) + : validTwoFragmentTree(finalTree, rootOrderId, childOrderId) + && first.containerBookingId !== second.containerBookingId, `root=${rootOrderId}, child=${childOrderId}`) + const before = await state(scope, customerId, rootOrderId, childOrderId, first.inboundId) + check(scope, 'F40 尚未签收两片不得出现完成事实', before.root.role === 'MASTER' + && id(before.root.rootOrderId) === rootOrderId && before.root.status !== 'COMPLETED' + && before.child.status !== 'COMPLETED' && completionCounts(before.root).own === 0, 'before-signatures') + proof.snapshots.push({ phase:'BEFORE_SIGNATURES', ...before }) + proof.currentStage = 'OVERSEAS_PDA_IDENTITY' + const warehouse = await resolveSharedWarehouse(scope), access = await resolvePickerAccess(scope) + check(scope, 'F40 实际海外接柜仓和PDA权限有效', Boolean(warehouse?.id && access), 'resourcePresent') + scope.marker = `${scope.context.f40BaseMarker}-PDA` + const picker = await createPicker(scope, 7, warehouse.id, access) + try { + check(scope, 'F40 海外PDA操作员会话有效', Boolean(picker?.token), 'tokenPresent') + picker.warehouseId = warehouse.id + check(scope, 'F40 客户会话属于该根订单客户', id(profile.id) === customerId, 'customerOwned') + const producers = cancelRemainder ? [first] : [first,second] + scope.createdResources.phase4InboundProducerFixtures = { marker: scope.marker, containers:producers } + scope.createdResources.phase4OverseasPda = { marker:scope.marker, generatedPalletsByInbound:{}, + palletIds:[],locationIds:[],completedContainerFacts:[], completedContainers:[], + overseasWarehouseId:warehouse.id, overseasWarehouseName:warehouse.name, customerId, finalStatus:null, + receivingCompletionEvidence:{} } + preserveReceivingEvidence(proof,scope) + proof.currentStage = 'OVERSEAS_PLAN_CONVERSION' + check(scope, 'F40 实装来源真实装柜事件已消费', await awaitProducerConsumption(scope,producers), `producers=${producers.length}`) + check(scope, 'F40 实装来源真实计划及托盘转换成功', await convertReceivingContainers(scope,producers), `producers=${producers.length}`) + const signatureSources = signatureOrder === 'CHILD_FIRST' ? [second,first] : producers + for (const [index, producer] of signatureSources.entries()) { + proof.currentStage = `OVERSEAS_RECEIVING_${index+1}` + scope.marker = `${scope.context.f40BaseMarker}-RECEIVE-${index}` + scope.createdResources.phase4InboundProducerFixtures.marker = scope.marker + scope.createdResources.phase4OverseasPda.marker = scope.marker + scope.createdResources.phase4OverseasPda.finalStatus = null + scope.pdaContainerSuffix = producer.containerNo + check(scope, 'F40 码头正式提柜前置完成', await prepareTerminalDrayage(scope, producer), `container=${producer.containerNo}`) + await runReceiving(scope,picker.token,null,null,producer,false) + check(scope, 'F40 来源柜 PDA 卸柜实收完成', scope.createdResources.phase4OverseasPda.finalStatus === 'COMPLETED' + && scope.createdResources.phase4OverseasPda.completedContainerFacts.some(row => + id(row.containerBookingId) === id(producer.containerBookingId) && row.containerNo === producer.containerNo), + `container=${producer.containerNo}`) + proof.currentStage = `DISPATCH_AND_SIGNATURE_${index+1}` + const signed = await sendFragment(scope,producer,picker,login.accessToken,index) + proof.signatures.push(signed) + proof.currentStage = `COMPLETION_GATE_${index+1}` + const observed = await waitState(() => state(scope,customerId,rootOrderId,childOrderId,first.inboundId), + value => validSignatureGate(value, signatureOrder === 'CHILD_FIRST' ? 'CHILD' : 'MASTER', index === 1)) + proof.snapshots.push({ phase:index === 0 ? 'ONE_SIGNED_ROOT_PENDING' : 'ALL_SIGNED', ...observed }) + check(scope,index === 0 ? 'F40 第一片签收根仍未完成' : 'F40 最后一片签收才完成根及来源摘要且各历史只一次', + validSignatureGate(observed, signatureOrder === 'CHILD_FIRST' ? 'CHILD' : 'MASTER', index === 1), signatureOrder) + proof.snapshots.at(-1).projections = await verifyRootProjections(scope,customerId,rootOrderId,login.accessToken,observed) + } + if (cancelRemainder) await cancelUnloadedRemainder(scope,proof,customerId,rootOrderId,childOrderId, + first.inboundId,id(childRows[0].id)) + proof.currentStage = 'RECEIPT_REPLAY' + const stable = await state(scope,customerId,rootOrderId,childOrderId,first.inboundId) + proof.finalProjections = await verifyRootProjections(scope,customerId,rootOrderId,login.accessToken,stable) + const finalSignedAt = proof.signatures.map(row => row.signedAt).sort().at(-1) + check(scope,'F40 根完成时间为实际有效来源签收时间最大值', stable.root.rawOrder?.completedAt === finalSignedAt, + `completedAt=${stable.root.rawOrder?.completedAt}, maxSignedAt=${finalSignedAt}`) + const reader = context.context?.f40ReadOnlyOutboxEvidence + const outboxRequest = {customerId,rootOrderId,sourceOrderIds:[rootOrderId,childOrderId], + eventType:'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED'} + const validOutbox = evidence => cancelRemainder + ? exactCancelledRemainderOutboxProof(evidence,customerId,rootOrderId,childOrderId) + : exactCompletionOutboxProof(evidence,customerId,rootOrderId,[rootOrderId,childOrderId]) + if (typeof reader === 'function') { + proof.beforeReceiptReplayOutbox = await waitState(() => reader(outboxRequest),validOutbox) + check(scope,'F40 重复签收前完成事件各实际来源唯一发布',validOutbox(proof.beforeReceiptReplayOutbox), + 'exact-source-before-receipt-replay') + } + // Existing receipt helper replays the exact original save command before each signature observation. + // Re-saving completed receipts with a fresh business key is also a no-transition operation. + for (const [index,signed] of proof.signatures.entries()) { + scope.marker = `${scope.context.f40BaseMarker}-REPLAY-${index}` + const current = await api(scope,'GET','读取已完成签收文件绑定版本', + query(`/api/platform/v1/last-leg/delivery-orders/${signed.deliveryNo}/documents`,{customerId})) + const documents = (current.categories || []).filter(row => row.files?.length).map(row => ({category:row.category, + fileIds:row.files.map(file => id(file.fileId))})) + const documentPath = query(`/api/platform/v1/last-leg/delivery-orders/${signed.deliveryNo}/documents`,{customerId}) + const deliveryPath = query(`/api/platform/v1/last-leg/delivery-orders/${signed.deliveryNo}`,{customerId}) + const historyPath = query(`/api/platform/v1/last-leg/delivery-orders/${signed.deliveryNo}/documents/history`,{customerId}) + const readReceipt = async target => { + target.documents = await api(scope,'GET','F40 回查签收资料完整绑定',documentPath) + target.delivery = await api(scope,'GET','F40 回查签收派送完整事实',deliveryPath) + target.revisions = await api(scope,'GET','F40 回查签收资料完整版本历史',historyPath) + return target + } + const beforeFresh = {documents:current,delivery:await api(scope,'GET','F40 新键前冻结派送业务与操作事实',deliveryPath), + revisions:await api(scope,'GET','F40 新键前冻结资料版本历史',historyPath)} + check(scope,'F40 新键前原签收事实保持完整稳定', + JSON.stringify(signed.receiptSnapshot) === JSON.stringify(beforeFresh.delivery),`delivery=${signed.deliveryNo}`) + const saveBody = {expectedVersion:Number(current.version),idempotencyKey:`${scope.marker}-SAVE`,bolNo:current.bolNo || null,documents} + const receiptProof = {deliveryNo:signed.deliveryNo,beforeFresh,saveBody,afterFresh:{},afterSameKey:{}} + ;(proof.receiptReplayProofs ||= []).push(receiptProof) + const saved = await api(scope,'PUT','重复已完成派送文件保存不得重发签收事实', + query(`/api/platform/v1/last-leg/delivery-orders/${signed.deliveryNo}/documents`,{customerId}), + saveBody) + receiptProof.saved = saved + const afterFresh = await readReceipt(receiptProof.afterFresh) + check(scope,'F40 新键资料保存仅递增一次版本及资料审计不得重复完成', + saved.replayed === false && validFreshReceiptSave(beforeFresh,afterFresh),`delivery=${signed.deliveryNo}`) + const sameKeyReplay = await api(scope,'PUT','F40 新资料命令原键原请求重放',documentPath,saveBody) + receiptProof.sameKeyReplay = sameKeyReplay + await readReceipt(receiptProof.afterSameKey) + check(scope,'F40 新资料命令原键重放资料版本历史派送业务及操作零漂移', + sameKeyReplay.replayed === true && JSON.stringify({...sameKeyReplay,replayed:false}) === JSON.stringify(saved) + && JSON.stringify(afterFresh) === JSON.stringify(receiptProof.afterSameKey),`delivery=${signed.deliveryNo}`) + } + const afterReplay = await state(scope,customerId,rootOrderId,childOrderId,first.inboundId) + check(scope,'F40 已完成签收重放根子订单状态版本和历史零新增', + JSON.stringify(stable) === JSON.stringify(afterReplay),'exact-root-child-inbound-snapshot') + proof.snapshots.push({phase:'AFTER_RECEIPT_REPLAY',...afterReplay}) + proof.currentStage = 'READ_ONLY_OUTBOX_EVIDENCE' + if (typeof reader === 'function') { + proof.outboxProof = await reader(outboxRequest) + check(scope,'F40 每个实际来源完成Outbox唯一并已发布', + validOutbox(proof.outboxProof), + 'exact-root/source/customer/readOnly-proof') + check(scope,'F40 新键重复签收完成Outbox数量与发布状态零漂移', + outboxCounts(proof.beforeReceiptReplayOutbox) === outboxCounts(proof.outboxProof),'before-after-receipt-replay-outbox') + if(taxMode==='TAX_REFUND')await verifyTaxDocumentsAfterCompletion(scope,proof.taxCustoms,login.accessToken) + proof.finalStatus='VERIFIED' + } else { + proof.finalStatus='HTTP_VERIFIED_OUTBOX_PENDING' + context.addCoverageGap(MODULE,OPTIONS.scenario,'F40 完成Outbox唯一发布只读证据', + '原始Order状态与历史已验证;仍需对本轮客户/根/子 exact source 完成Outbox做只读计数', + 'missing-outbox-evidence',{deploymentBlocker:true}) + context.addStep(MODULE,'F40 真实完成Outbox唯一发布证据',null,false,'readonly evidence reader missing',OPTIONS) + } + } catch (error) { + proof.finalStatus = 'FAILED' + proof.failedStage = proof.currentStage + throw error + } finally { preserveReceivingEvidence(proof,scope); proof.currentStage = 'PDA_IDENTITY_CLEANUP'; await disablePicker(scope,picker,7) } + if (proof.finalStatus === 'VERIFIED') { + const failedSteps = (context.steps || []).slice(initialStepCount).filter(row => row.passed === false) + if (failedSteps.length) proof.finalStatus = 'FAILED' + check(context,'F40 正式链、重放及临时PDA停用所有检查均通过', failedSteps.length === 0, + `failed=${failedSteps.length}`) + } + proof.currentStage = 'FINISHED' + return proof +} diff --git a/scripts/admin-test-env-modules/phase5-root-tax-customs-contract.test.mjs b/scripts/admin-test-env-modules/phase5-root-tax-customs-contract.test.mjs new file mode 100644 index 000000000..de01d2a05 --- /dev/null +++ b/scripts/admin-test-env-modules/phase5-root-tax-customs-contract.test.mjs @@ -0,0 +1,69 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import {createHash} from 'node:crypto' +import {readFileSync} from 'node:fs' +import {prepareTaxCustoms,verifyTaxDocumentsAfterCompletion,taxDocumentMatches} from './phase5-root-tax-customs.mjs' +const loading={tenantId:'90',orderId:'100',inboundId:'200',loadingPlanId:'300',containerBookingId:'400',inboundCode:'IN-TAX',containerNo:'TAX-BOX'} +function fixture(){ + const calls=[],files={},evidence=[],view={version:0,status:'READY_TO_START',loadingPlanId:'300',containerBookingId:'400', + inbounds:[{inboundId:'200',taxMode:'TAX_REFUND'}],documents:[]} + const wrap=data=>({http:200,body:{code:'200',data:structuredClone(data)}}) + const context={marker:'OFFLINE-TAX',token:'platform',createdResources:{},context:{f40TaxMerchantToken:'customer',f40TaxEvidence:evidence}, + assert:(_m,name,_r,passed)=>{assert.equal(Boolean(passed),true,name)},apiSuccess:(_m,_n,r)=>r.body?.data, + request:async(method,path,body,token)=>{ + calls.push({method,path,body,token}) + if(path==='/api/merchant/v1/orders/100')return wrap({summary:{orderId:'100',mainStatus:'COMPLETED',taxMode:'TAX_REFUND'}}) + if(method==='GET')return wrap(view) + if(path.endsWith('/start')){view.status='DECLARING';view.documents[0].frozen=true} + if(path.endsWith('/receipt/confirm'))view.documents[0].receiptStatus='CONFIRMED' + if(path.endsWith('/declaration-batches'))return wrap({id:'500',version:0,status:'READY'}) + if(path.endsWith('/500/submit'))return wrap({version:1,status:'SUBMITTED'}) + if(path.endsWith('/500/results'))return wrap({resultType:'RELEASED',resultStatus:'RESOLVED'}) + view.version++;return wrap(view) + }, + requestMultipart:async(_method,path,file,token)=>{ + calls.push({method:'UPLOAD',path,token}) + const kind=path.includes('/receipt/')?'receipt':path.includes('/final/')?'final':'source';files[kind]=file.content + const metadata={fileHash:createHash('sha256').update(file.content).digest('hex'),version:1} + if(kind==='source')view.documents.push({documentId:'600',tenantId:'90',inboundIds:['200'],source:metadata,frozen:false}) + else view.documents[0][kind==='final'?'finalDocument':kind]=metadata + if(kind==='final')view.status='COMPLETED' + return wrap(view.documents[0]) + }, + async requestBinary(_method,path){assert.equal(this.token,'customer');const kind=path.includes('/receipt/')?'receipt':path.includes('/final/')?'final':'source';return {http:200,bytes:files[kind]}}, + } + return {context,calls,files,evidence,view} +} +test('tax F40 uses actual upload confirm declaration release and final before post-COMPLETED customer downloads',async()=>{ + const f=fixture();const proof=await prepareTaxCustoms(f.context,loading) + assert.equal(proof.status,'CUSTOMS_COMPLETED') + await verifyTaxDocumentsAfterCompletion(f.context,f.evidence,'customer') + assert.equal(proof.status,'ORDER_COMPLETED_CUSTOMS_READ_DOWNLOAD_VERIFIED') + assert.deepEqual(proof.downloads.map(x=>x.kind),['source','receipt','final']) + const paths=f.calls.filter(c=>c.method!=='GET').map(c=>c.path) + let last=-1 + for(const suffix of ['/documents/upload?inboundIds=200','/start','/send-to-broker','/receipt/upload?expectedVersion=0', + '/receipt/confirm','/submit-to-broker','/declaration-batches','/500/submit','/500/results','/final/upload?expectedVersion=0']){ + const index=paths.findIndex((p,i)=>i>last&&p.endsWith(suffix));assert.ok(index>last,suffix);last=index + } + assert.equal(f.calls.find(c=>c.path.endsWith('/receipt/confirm')).token,'customer') + assert.equal(JSON.stringify(proof).includes('customer'),true) // customerId evidence, never session token field + assert.equal(Object.hasOwn(proof,'token'),false) + assert.doesNotMatch(JSON.stringify(proof),/"(?:bytes|password|accessToken|refreshToken|authorization)"\s*:/i) +}) +test('customer tax evidence rejects changed customer, source, plan, file bytes and still-uncompleted order',async()=>{ + const f=fixture(),proof=await prepareTaxCustoms(f.context,loading) + for(const mutate of [v=>v.status='DECLARING',v=>v.loadingPlanId='999',v=>v.documents[0].tenantId='91', + v=>v.documents[0].inboundIds=['201'],v=>v.documents[0].finalDocument.fileHash='changed']){ + const v=structuredClone(f.view);mutate(v);assert.equal(taxDocumentMatches(v,proof),false) + } + f.files.final=new TextEncoder().encode('incorrect bytes') + await assert.rejects(verifyTaxDocumentsAfterCompletion(f.context,[proof],'customer'),/final字节/) +}) +test('buy remains the default; tax path is explicit and final verification occurs after Outbox proof',()=>{ + const source=readFileSync(new URL('./phase5-root-fulfillment.mjs',import.meta.url),'utf8') + assert.match(source,/f40TaxMode\|\|'BUY_DECLARATION'/) + assert.match(source,/f40TaxMode==='TAX_REFUND'\) await prepareTaxCustoms/) + assert.match(source,/else await stage\(context,'phase3-customs'\)/) + assert.ok(source.indexOf('await verifyTaxDocumentsAfterCompletion')>source.indexOf('proof.outboxProof =')) +}) diff --git a/scripts/admin-test-env-modules/phase5-root-tax-customs.mjs b/scripts/admin-test-env-modules/phase5-root-tax-customs.mjs new file mode 100644 index 000000000..59e069b02 --- /dev/null +++ b/scripts/admin-test-env-modules/phase5-root-tax-customs.mjs @@ -0,0 +1,97 @@ +import { createHash } from 'node:crypto' +const MODULE='phase5-root-fulfillment',OPTIONS={scenario:'f40-tax-customs-after-completion',deploymentBlocker:true} +const PLATFORM='/api/platform/v1/customs/loading-plans',MERCHANT='/api/merchant/v1/customs' +const id=value=>String(value??'') +const sha=bytes=>createHash('sha256').update(bytes).digest('hex') +function check(context,name,passed,response=null){ + context.assert(MODULE,name,response,Boolean(passed),'exact-current-tax-source',OPTIONS) + if(!passed)throw Error(name) +} +async function api(context,method,name,path,body,token=context.token,key){ + const response=await context.request(method,path,body,token,key?{'Idempotency-Key':key}:{}) + const data=context.apiSuccess(MODULE,name,response,'',OPTIONS) + check(context,name,response?.http===200&&String(response.body?.code)==='200',response) + return data +} +export function taxDocumentMatches(view,proof){ + const documents=view?.documents||[] + const document=documents.find(row=>id(row.documentId)===proof.documentId) + return view?.status==='COMPLETED'&&id(view.loadingPlanId)===proof.loadingPlanId + &&id(view.containerBookingId)===proof.containerBookingId&&document?.frozen===true + &&id(document.tenantId)===proof.customerId&&document.inboundIds?.map(id).includes(proof.inboundId) + &&document.source?.fileHash===proof.files.source.sha256&&document.receiptStatus==='CONFIRMED' + &&document.receipt?.fileHash===proof.files.receipt.sha256&&document.finalDocument?.fileHash===proof.files.final.sha256 +} +export async function prepareTaxCustoms(context,loading){ + const proof={customerId:id(loading.tenantId),orderId:id(loading.orderId),inboundId:id(loading.inboundId), + inboundCode:loading.inboundCode,containerBookingId:id(loading.containerBookingId),loadingPlanId:id(loading.loadingPlanId), + containerNo:loading.containerNo,files:{},stages:[],status:'PREPARING'} + context.context.f40TaxEvidence.push(proof) + const token=context.context.f40TaxMerchantToken + check(context,'F40 退税报关使用本轮客户会话',Boolean(token)) + const base=`${PLATFORM}/${proof.loadingPlanId}` + const key=action=>`${context.marker}-F40-TAX-${action}`.slice(0,128) + const detail=()=>api(context,'GET','F40 回查真实退税报关聚合',base) + const upload=async(kind,path,token)=>{ + const content=new TextEncoder().encode(`${context.marker} TAX_REFUND ${proof.inboundCode} ${kind}`) + proof.files[kind]={sha256:sha(content),byteCount:content.length} + const response=await context.requestMultipart('POST',path,{name:`${context.marker}-${kind}.txt`,type:'text/plain',content},token,{'Idempotency-Key':key(kind)}) + const data=context.apiSuccess(MODULE,`F40 真实上传退税${kind}文件`,response,'',OPTIONS) + check(context,`F40 退税${kind}上传成功`,response.http===200&&String(response.body?.code)==='200',response) + return data + } + let current=await detail() + check(context,'F40 实装来源确为本客户退税入仓码',current.inbounds?.length===1&¤t.inbounds.every(row=> + row.taxMode==='TAX_REFUND'&&id(row.inboundId)===proof.inboundId)) + const source=await upload('source',`${MERCHANT}/loading-plans/${proof.loadingPlanId}/documents/upload?inboundIds=${proof.inboundId}`,token) + proof.documentId=id(source.documentId) + check(context,'F40 客户原始报关文件精确绑定本票',Boolean(proof.documentId)&&source.source?.fileHash===proof.files.source.sha256 + &&id(source.tenantId)===proof.customerId&&source.inboundIds?.map(id).includes(proof.inboundId)) + for(const action of ['start','send-to-broker']){ + current=await detail() + const next=await api(context,'POST',`F40 退税真实${action}`,`${base}/${action}`, + {expectedVersion:Number(current.version),confirm:true,remark:context.marker},context.token,key(action)) + proof.stages.push({action,status:next.status,version:next.version}) + } + const receipt=await upload('receipt',`${PLATFORM}/documents/${proof.documentId}/receipt/upload?expectedVersion=0`,context.token) + await api(context,'POST','F40 客户确认本票报关回执',`${MERCHANT}/documents/${proof.documentId}/receipt/confirm`, + {expectedVersion:Number(receipt.receipt.version),confirm:true,remark:context.marker},token,key('confirm')) + current=await detail() + await api(context,'POST','F40 平台发送已确认回执执行报关',`${base}/submit-to-broker`, + {expectedVersion:Number(current.version),confirm:true,remark:context.marker},context.token,key('execute')) + current=await detail() + const batch=await api(context,'POST','F40 人工创建真实正式申报批次',`${base}/declaration-batches`, + {expectedVersion:Number(current.version),batchNo:`${context.marker}-DECL`.slice(-64),remark:context.marker},context.token,key('declaration')) + proof.declarationBatchId=id(batch.id) + const submitted=await api(context,'POST','F40 人工提交正式申报',`${PLATFORM}/declaration-batches/${batch.id}/submit`, + {expectedVersion:Number(batch.version),customsEntryNo:`F40TAX-${proof.loadingPlanId}`,remark:context.marker},context.token,key('submit-declaration')) + const now=new Date(),local=new Date(now.getTime()-now.getTimezoneOffset()*60000).toISOString().slice(0,19) + const release=await api(context,'POST','F40 人工记录海关放行',`${PLATFORM}/declaration-batches/${batch.id}/results`, + {expectedVersion:Number(submitted.version),resultType:'RELEASED',occurredAt:local,resolution:'本轮退税验收人工放行',resolved:true},context.token,key('release')) + check(context,'F40 真实申报已提交且放行已处理',submitted.status==='SUBMITTED'&&release.resultType==='RELEASED'&&release.resultStatus==='RESOLVED') + await upload('final',`${PLATFORM}/documents/${proof.documentId}/final/upload?expectedVersion=0`,context.token) + proof.completedCustoms=await detail() + check(context,'F40 退税完成文件齐全才完成报关',taxDocumentMatches(proof.completedCustoms,proof)) + proof.status='CUSTOMS_COMPLETED' + context.createdResources.phase3Customs={...loading,parentOrderId:loading.orderId,finalStatus:'CLEARANCE_COMPLETED'} + return proof +} +export async function verifyTaxDocumentsAfterCompletion(context,proofs,merchantToken){ + check(context,'F40 完成后验收具有真实退税报关证据',proofs.length>0) + for(const proof of proofs){ + const order=await api(context,'GET','F40 客户回查退税原订单最终完成',`/api/merchant/v1/orders/${proof.orderId}`,undefined,merchantToken) + check(context,'F40 退税订单最终COMPLETED且税制未变',id(order.summary?.orderId)===proof.orderId&&order.summary.mainStatus==='COMPLETED'&&order.summary.taxMode==='TAX_REFUND') + proof.afterCompletedView=await api(context,'GET','F40 COMPLETED后客户仍可读取本票报关资料',`${MERCHANT}/orders/${proof.orderId}`,undefined,merchantToken) + check(context,'F40 完成后报关资料客户范围及文件哈希保持一致',taxDocumentMatches(proof.afterCompletedView,proof)) + proof.downloads=[] + const customer=Object.create(context);customer.token=merchantToken + for(const [kind,suffix] of [['source','download'],['receipt','receipt/download'],['final','final/download']]){ + const response=await customer.requestBinary('GET',`${MERCHANT}/documents/${proof.documentId}/${suffix}`) + const fact={kind,http:response.http,byteCount:response.bytes?.length||0,sha256:sha(response.bytes||new Uint8Array())} + proof.downloads.push(fact) + check(context,`F40 COMPLETED后客户真实下载${kind}字节保持一致`,fact.http===200&&fact.byteCount===proof.files[kind].byteCount&&fact.sha256===proof.files[kind].sha256, + {...response,bytes:undefined}) + } + proof.status='ORDER_COMPLETED_CUSTOMS_READ_DOWNLOAD_VERIFIED' + } +} diff --git a/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs b/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs index 20dd73f1e..da1505fc2 100644 --- a/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs +++ b/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs @@ -138,7 +138,7 @@ test('边界fixture遵守审核、单SKU托盘和完工照片新契约', async ( assert.match(source, /'X-Customer-Id': customerId/) assert.match(source, /status !== 'PENDING_REVIEW'/) assert.match(source, /palletCode: order\.palletCode/) - assert.match(source, /requestOverseasMultipart\('POST',[^\n]+form, operator\.token\)/) + assert.match(source, /requestMultipart\('POST',[^\n]+form, operator\.token\)/) assert.match(source, /两个合法SKU同批签收仍整笔拒绝/) assert.match(source, /palletCode: nextPallet\.palletCode/) assert.doesNotMatch(source, /\['receiptNo', 'receiptBatchNo', 'cartonMark'\]/) diff --git a/scripts/admin-test-env-modules/return-boundary-scenarios.mjs b/scripts/admin-test-env-modules/return-boundary-scenarios.mjs index 1654dfa06..514596acd 100644 --- a/scripts/admin-test-env-modules/return-boundary-scenarios.mjs +++ b/scripts/admin-test-env-modules/return-boundary-scenarios.mjs @@ -61,10 +61,10 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p context.assert(MODULE, name, response, Boolean(passed), evidence, OPTIONS) } async function request(token, method, path, body) { - let response = await context.requestOverseas(method, path, body, {}, token) + let response = await context.request(method, path, body, token, {}) for (let attempt = 1; attempt < 3 && response?.http === 0; attempt += 1) { await new Promise((resolve) => setTimeout(resolve, 250 * attempt)) - response = await context.requestOverseas(method, path, body, {}, token) + response = await context.request(method, path, body, token, {}) } return response } @@ -87,8 +87,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p /^RT-[0-9A-Z]{25}$/.test(data.order.returnNo) && /^RETU-[0-9A-Z]{25}$/.test(data.order.lastlegOrderNo)) const customerId = customerByToken.get(token) if (!customerId || data.order.status !== 'PENDING_REVIEW') throw new Error('边界退货客户或审核初态不符') - const reviewed = await context.requestOverseas('POST', `/api/platform/v1/last-leg/return-orders/${data.order.returnNo}/review`, - { decision: 'APPROVE', remark: context.marker }, { 'X-Customer-Id': customerId }) + const reviewed = await context.request('POST', `/api/platform/v1/last-leg/return-orders/${data.order.returnNo}/review`, { decision: 'APPROVE', remark: context.marker }, undefined, { 'X-Customer-Id': customerId }) check('边界退货单审核通过进入待签收', reviewed, ok(reviewed) && reviewed.body.data?.order?.status === 'PENDING_RECEIPT') if (!ok(reviewed) || reviewed.body.data?.order?.status !== 'PENDING_RECEIPT') throw new Error('边界退货审核失败') const operator = String(warehouseId) === String(warehouse.id) ? picker : foreignPicker @@ -97,7 +96,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p if (!pallet.palletCode) throw new Error('退货托盘码缺失') const form = new FormData() form.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), 'synthetic-return-boundary.png') - const photo = await context.requestOverseasMultipart('POST', `${RECEIPTS}/${data.order.returnNo}/photos/files`, form, operator.token) + const photo = await context.requestMultipart('POST', `${RECEIPTS}/${data.order.returnNo}/photos/files`, form, operator.token) check('边界签收准备合成打托照片', photo, ok(photo) && Boolean(photo.body.data?.fileId)) if (!ok(photo) || !photo.body.data?.fileId) throw new Error('边界照片上传失败') return { returnNo: data.order.returnNo, sku, skus: items.map(item => item.skuCode), quantity, token, warehouseId, palletCode: pallet.palletCode } @@ -160,9 +159,9 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p const fixtureB = secondary.context.merchantQualificationFixture resources.secondaryCustomer = secondary.createdResources.merchantQualificationFixture const loginB = await context.request('POST', '/api/auth/login', { loginId: fixtureB.username, password: fixtureB.password }, '') - check('第二隔离客户登录取得海外令牌', loginB, ok(loginB) && Boolean(loginB.body?.data?.overseasAccessToken)) - if (!ok(loginB) || !loginB.body?.data?.overseasAccessToken) return - const tokenB = loginB.body.data.overseasAccessToken + check('第二隔离客户登录取得访问令牌', loginB, ok(loginB) && Boolean(loginB.body?.data?.accessToken)) + if (!ok(loginB) || !loginB.body?.data?.accessToken) return + const tokenB = loginB.body.data.accessToken customerByToken.set(tokenB, String(fixtureB.tenantId)) const warehouseResponse = await context.request('GET', '/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE') const otherWarehouse = rows(warehouseResponse?.body?.data).find(row => String(row.id) !== String(warehouse.id)) @@ -364,7 +363,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p await scenario('身份入口权限', async () => { const order = await newOrder('权限边界', `${prefix}-AUTH`, 1) - for (const [name, token, statuses] of [['客户', merchantToken, [403]], ['无登录', '', [401]], ['平台管理员', context.overseasToken, [403]]]) { + for (const [name, token, statuses] of [['客户', merchantToken, [403]], ['无登录', '', [401]], ['平台管理员', context.token, [403]]]) { await unchanged(`${name}不能调用PDA签收`, [order], () => request(token, 'POST', `${RECEIPTS}/confirm`, receiptBody(order)), statuses) } await receive(order) @@ -383,7 +382,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p await scenario('签收流水与余额核对', async () => { const order = await newOrder('流水核对', `${prefix}-LEDGER`, 3) const customerId = context.context.merchantQualificationFixture.tenantId - const ledger = () => good(context.overseasToken, 'GET', '读取本轮签收流水', url(`${PLATFORM_INVENTORY}/movements`, { + const ledger = () => good(context.token, 'GET', '读取本轮签收流水', url(`${PLATFORM_INVENTORY}/movements`, { customerId, overseasWarehouseId: warehouse.id, skuCode: order.sku, page: 1, size: 200 })) check('未签收前本轮SKU无流水', null, exactReceiptLedger(await ledger(), [])) const body = receiptBody(order, 2) diff --git a/scripts/admin-test-env-modules/return-review-contract.test.mjs b/scripts/admin-test-env-modules/return-review-contract.test.mjs index 8076f8576..595b87a04 100644 --- a/scripts/admin-test-env-modules/return-review-contract.test.mjs +++ b/scripts/admin-test-env-modules/return-review-contract.test.mjs @@ -7,7 +7,7 @@ function fixture({ failedReview = false, wrongOwner = false, stale = false } = { const calls = [] const context = { marker: 'FIXTURE', context: { merchantQualificationFixture: { tenantId: '123' } }, addStep() {}, assert() {}, - async requestOverseas(method, path, body, headers, token) { + async request(method,path,body,token,headers) { calls.push({ method, path, body, headers, token }) if (method === 'POST') { assert.equal(headers['X-Customer-Id'], '123') diff --git a/scripts/admin-test-env-modules/system.mjs b/scripts/admin-test-env-modules/system.mjs index 9b2ad4273..ba7028919 100644 --- a/scripts/admin-test-env-modules/system.mjs +++ b/scripts/admin-test-env-modules/system.mjs @@ -1,5 +1,23 @@ function id(value) { return value == null ? '' : String(value) } +export async function verifyRevokedSessions(context, reason, sessions, accessPath = '/api/platform/v1/orders/status') { + const options = { scenario: 'unified-session-revocation', deploymentBlocker: true } + for (const [index, session] of sessions.entries()) { + const complete = Boolean(session?.accessToken && session?.refreshToken) + context.assert('system', `${reason}第${index + 1}组会话凭据完整`, null, complete, + `session=${index + 1}`, options) + if (!complete) throw new Error('会话撤销检查缺少完整 Access/Refresh 对') + const access = await context.request('GET', accessPath, undefined, session.accessToken) + context.assert('system', `${reason}旧Access失效`, access, + access.http === 401 && String(access.body?.code) === 'A0401', + `session=${index + 1}, HTTP=${access.http}, code=${access.body?.code || 'missing'}`, options) + const refresh = await context.request('POST', '/api/auth/refresh', { refreshToken: session.refreshToken }, '') + context.assert('system', `${reason}旧Refresh不能恢复会话`, refresh, + [200, 401].includes(refresh.http) && String(refresh.body?.code) === 'A0401', + `session=${index + 1}, HTTP=${refresh.http}, code=${refresh.body?.code || 'missing'}`, options) + } +} + export async function run(context) { const module = 'system' const suffix = context.marker.replace(/\D/g, '').slice(-12) @@ -140,20 +158,22 @@ export async function run(context) { const loginResponse = await context.request('POST', '/api/auth/login', { loginId: username, password: initialPassword }, '') const loginPassed = loginResponse.http === 200 && String(loginResponse.body?.code) === '200' && Boolean(loginResponse.body?.data?.accessToken) context.addStep(module, '新建内部用户可使用初始密码登录', loginResponse, loginPassed, `username=${username}`) + const secondLoginResponse = await context.request('POST', '/api/auth/login', { loginId: username, password: initialPassword }, '') + const secondLogin = context.apiSuccess(module, '建立同账号第二个独立会话', secondLoginResponse) const resetResponse = await context.request('PUT', `/api/platform/v1/iam/users/${membershipId}/password`, { newPassword: resetPassword }) context.apiSuccess(module, '重置平台内部用户密码', resetResponse) + await verifyRevokedSessions(context, '密码重置撤销多端会话', [loginResponse.body?.data, secondLogin]) const resetLoginResponse = await context.request('POST', '/api/auth/login', { loginId: username, password: resetPassword }, '') const resetLoginPassed = resetLoginResponse.http === 200 && String(resetLoginResponse.body?.code) === '200' && Boolean(resetLoginResponse.body?.data?.accessToken) context.addStep(module, '内部用户使用重置密码登录', resetLoginResponse, resetLoginPassed, `username=${username}`) if (context.options.modules.includes('phase4-cross-region-events')) { - const restrictedOverseasToken = resetLoginResponse.body?.data?.overseasAccessToken || '' - context.assert(module, '受限角色登录同时签发海外Token', resetLoginResponse, + const restrictedOverseasToken = resetLoginResponse.body?.data?.accessToken || '' + context.assert(module, '受限角色登录同时签发统一Token', resetLoginResponse, Boolean(restrictedOverseasToken), `tokenPresent=${Boolean(restrictedOverseasToken)}`, { scenario: 'canonical-overseas-permission-denial', deploymentBlocker: true }) if (restrictedOverseasToken) { - const denied = await context.requestOverseas('GET', '/api/platform/v1/last-leg/orders?page=1&size=1', - undefined, {}, restrictedOverseasToken) + const denied = await context.request('GET', '/api/platform/v1/last-leg/orders?page=1&size=1', undefined, restrictedOverseasToken, {}) context.assert(module, '缺少canonical海外订单权限必须403', denied, (denied.http === 403 || ['403', 'A0403'].includes(String(denied.body?.code))) && Boolean(denied.requestId) && Boolean(denied.traceId), @@ -162,16 +182,20 @@ export async function run(context) { } } + const clearPermissionResponse = await context.request('PUT', '/api/platform/v1/iam/roles/permissions', { roleId, permissionIds: [] }) + context.apiSuccess(module, '清空测试角色权限', clearPermissionResponse) + await verifyRevokedSessions(context, '角色权限清空撤销已登录会话', [resetLoginResponse.body?.data]) + const activeLoginResponse = await context.request('POST', '/api/auth/login', { loginId: username, password: resetPassword }, '') + const activeLogin = context.apiSuccess(module, '按更新后的权限重新登录', activeLoginResponse) const disableUserResponse = await context.request('PUT', `/api/platform/v1/iam/users/${membershipId}/status`, { status: 'DISABLED' }) const disabledUser = context.apiSuccess(module, '停用平台内部用户', disableUserResponse) context.assert(module, '内部用户停用状态正确', disableUserResponse, disabledUser?.status === 'DISABLED', `status=${disabledUser?.status ?? 'missing'}`) + await verifyRevokedSessions(context, '账号停用撤销会话', [activeLogin]) const disabledLoginResponse = await context.request('POST', '/api/auth/login', { loginId: username, password: resetPassword }, '') context.addStep(module, '停用用户禁止登录', disabledLoginResponse, disabledLoginResponse.http === 200 && String(disabledLoginResponse.body?.code) !== '200', `HTTP=${disabledLoginResponse.http}, code=${disabledLoginResponse.body?.code ?? 'missing'}`) - const clearPermissionResponse = await context.request('PUT', '/api/platform/v1/iam/roles/permissions', { roleId, permissionIds: [] }) - context.apiSuccess(module, '清空测试角色权限', clearPermissionResponse) const deleteRoleResponse = await context.request('DELETE', `/api/platform/v1/iam/roles/${roleId}`) context.expectConflict(module, '已分配成员的角色禁止删除', deleteRoleResponse, 'system-role-protection') const finalDisableRoleResponse = await context.request('PUT', `/api/platform/v1/iam/roles/${roleId}/status`, { status: 'DISABLED' }) diff --git a/scripts/admin-test-env-modules/test-event-bridge-dns-deployment-contract.test.mjs b/scripts/admin-test-env-modules/test-event-bridge-dns-deployment-contract.test.mjs index 56515796a..ecd20ccfd 100644 --- a/scripts/admin-test-env-modules/test-event-bridge-dns-deployment-contract.test.mjs +++ b/scripts/admin-test-env-modules/test-event-bridge-dns-deployment-contract.test.mjs @@ -1,110 +1,55 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' - -import { - EVENT_BRIDGE_RECOVERY_FIXTURE, - INTERNAL_TOKEN_COHORT_SERVICES, - recoveredEventBridgeFacts, - TEST_EVENT_BRIDGE_URLS, - validInternalTokenCohort, - validTestEventBridgeEnvironment, -} from './event-bridge-pending-recovery.mjs' - -const read = (relativeUrl) => readFileSync(new URL(relativeUrl, import.meta.url), 'utf8') -const cnCompose = read('../../deploy/docker-compose.yml') -const overseasCompose = read('../../deploy/docker-compose.overseas.yml') -const deployBuilt = read('../../deploy/scripts/deploy-built-service.sh') -const deployServices = read('../../deploy/scripts/deploy-services.sh') -const crossRegionHttp = read('phase4-cross-region-events.mjs') - -test('test bridge URLs use stable Compose DNS and internal port 9312 only', () => { - assert.deepEqual(TEST_EVENT_BRIDGE_URLS, { - LASTLEG_EVENT_BRIDGE_URL: 'http://event-bridge-overseas:9312', - FIRSTLEG_EVENT_BRIDGE_URL: 'http://event-bridge-cn:9312', - }) - assert.equal(validTestEventBridgeEnvironment(TEST_EVENT_BRIDGE_URLS), true) - assert.equal(validTestEventBridgeEnvironment({ - ...TEST_EVENT_BRIDGE_URLS, LASTLEG_EVENT_BRIDGE_URL: 'http://172.31.198.8:9313', - }), false) +import { TEST_DIRECT_DELIVERY_URLS, INTERNAL_TOKEN_COHORT_SERVICES, validDirectDeliveryEnvironment, + validInternalTokenCohort, validDirectDeliveryEvidence } from './event-bridge-pending-recovery.mjs' +const read=p=>readFileSync(new URL(p,import.meta.url),'utf8') +const compose=read('../../deploy/docker-compose.yml') +test('all three direct targets use Compose DNS and current internal ports',()=>{ + assert.equal(validDirectDeliveryEnvironment(TEST_DIRECT_DELIVERY_URLS),true) + for(const [key,url] of Object.entries(TEST_DIRECT_DELIVERY_URLS)){ + assert.ok(compose.includes(`${key}: ${url}`)) + assert.equal(validDirectDeliveryEnvironment({...TEST_DIRECT_DELIVERY_URLS,[key]:'http://localhost:9312'}),false) + } + assert.doesNotMatch(compose,/EVENT_BRIDGE_URL|event-bridge-service:/) +}) +test('the three producers and consumers share internal token revision without exposing secrets',()=>{ + assert.deepEqual(INTERNAL_TOKEN_COHORT_SERVICES,['firstleg','lastleg-service','finance']) + assert.match(compose,/INTERNAL_SERVICE_TOKEN: \$\{INTERNAL_SERVICE_TOKEN\}/) + assert.equal(validInternalTokenCohort({firstleg:'r1','lastleg-service':'r1',finance:'r1'}),true) + assert.equal(validInternalTokenCohort({firstleg:'r1','lastleg-service':'r1',finance:'r2'}),false) + assert.equal(validInternalTokenCohort({}),false) +}) +const attempt=(extra={})=>({eventId:'event-fixture',payloadHash:'hash-fixture',at:'2026-09-29T00:00:00Z',requestId:'request-fixture',traceId:'trace-fixture',...extra}) +const facts=(attempts,extra={})=>({marker:'run-fixture',eventId:'event-fixture',payloadHash:'hash-fixture',maxAttempts:12,sqlMutationCount:0,status:'SENT',inboxEventCount:1,businessEffectCount:1,attempts,...extra}) +test('DNS, invalid token and timeout recovery require original event identity and actual duplicate ACK',()=>{ + for(const failure of ['DNS_UNAVAILABLE','TOKEN_REJECTED','TIMEOUT']){ + const evidence=facts([attempt({failure}),attempt({outcome:'APPLIED',ackLost:true}),attempt({outcome:'DUPLICATE'})],{authorizedDeadRetry:failure==='TOKEN_REJECTED'}) + assert.equal(validDirectDeliveryEvidence(evidence),true) + assert.equal(validDirectDeliveryEvidence({...evidence,businessEffectCount:2}),false) + assert.equal(validDirectDeliveryEvidence({...evidence,sqlMutationCount:1}),false) + assert.equal(validDirectDeliveryEvidence({...evidence,attempts:[...evidence.attempts,attempt({eventId:'new-id',outcome:'DUPLICATE'})]}),false) + } + assert.equal(validDirectDeliveryEvidence(facts([attempt({outcome:'APPLIED',ackLost:true}),attempt({outcome:'APPLIED',ackLost:true})])),false) +}) +test('retry budget exhaustion stays DEAD and never schedules an automatic retry',()=>{ + const evidence=facts(Array.from({length:12},()=>attempt({failure:'TIMEOUT'})),{status:'DEAD',automaticRetryScheduled:false}) + assert.equal(validDirectDeliveryEvidence(evidence),true) + assert.equal(validDirectDeliveryEvidence({...evidence,automaticRetryScheduled:true}),false) + assert.equal(validDirectDeliveryEvidence({...evidence,attempts:[...evidence.attempts,attempt({failure:'TIMEOUT'})]}),false) +}) +test('GET stability does not claim real event replay coverage',()=>{ + const source=read('./phase4-cross-region-events.mjs') + assert.match(source,/非事件重放证据/) + assert.match(source,/missing-direct-delivery-replay-evidence/) + assert.match(source,/event-outbox-operations\.mjs/) + assert.doesNotMatch(source,/经Bridge|不能访问.*Audience/) }) -test('Compose wires both producers to the stable service aliases used by test env', () => { - assert.match(cnCompose, /LASTLEG_EVENT_BRIDGE_URL:\s*\$\{LASTLEG_EVENT_BRIDGE_URL\}/) - assert.match(overseasCompose, - /FIRSTLEG_EVENT_BRIDGE_URL:\s*\$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/) - assert.match(cnCompose, /aliases:\s*\[event-bridge-cn\]/) - assert.match(cnCompose, - /FINANCE_FEE_EVENT_CONSUMER_URL:\s*\$\{FINANCE_FEE_EVENT_CONSUMER_URL:-http:\/\/finance:9203\/internal\/finance\/v1\/business-fee-source-events\}/) - assert.match(overseasCompose, /aliases:\s*\[event-bridge-overseas\]/) - for (const source of [cnCompose, overseasCompose]) { - assert.doesNotMatch(source, /(?:LASTLEG|FIRSTLEG|CN)_EVENT_BRIDGE_URL:[^\n]*(?:\d{1,3}\.){3}\d{1,3}/) - assert.doesNotMatch(source, /(?:LASTLEG|FIRSTLEG|CN)_EVENT_BRIDGE_URL:[^\n]*:9313/) - } -}) - -test('all four recovery services inherit INTERNAL_SERVICE_TOKEN and deploy from one env file', () => { - assert.deepEqual(INTERNAL_TOKEN_COHORT_SERVICES, [ - 'firstleg', 'event-bridge-cn', 'event-bridge-overseas', 'lastleg-service', - ]) - assert.match(cnCompose, /x-common:\s*&common[\s\S]*INTERNAL_SERVICE_TOKEN:\s*\$\{INTERNAL_SERVICE_TOKEN\}/) - assert.match(overseasCompose, - /x-overseas-common:\s*&overseas-common[\s\S]*INTERNAL_SERVICE_TOKEN:\s*\$\{INTERNAL_SERVICE_TOKEN\}/) - assert.match(cnCompose, /firstleg:[\s\S]*<<:\s*\*common/) - assert.match(cnCompose, /event-bridge-service:[\s\S]*<<:\s*\*common/) - assert.match(overseasCompose, /lastleg-service:[\s\S]*<<:\s*\*overseas-common/) - assert.match(overseasCompose, /event-bridge-service:[\s\S]*<<:\s*\*overseas-common/) - for (const source of [deployBuilt, deployServices]) { - assert.match(source, /--env-file\s+"\$ENV_FILE"/) - } - assert.equal(validInternalTokenCohort({ - firstleg: 'revision-20260827', - 'event-bridge-cn': 'revision-20260827', - 'event-bridge-overseas': 'revision-20260827', - 'lastleg-service': 'revision-20260827', - }), true) - assert.equal(validInternalTokenCohort({ - firstleg: 'revision-20260827', - 'event-bridge-cn': 'revision-20260827', - 'event-bridge-overseas': 'stale-revision', - 'lastleg-service': 'revision-20260827', - }), false) -}) - -test('incident fixture records DNS recovery followed by retry81 token rejection', () => { - assert.deepEqual(EVENT_BRIDGE_RECOVERY_FIXTURE, { - customerCode: 'C10000000', - eventId: 'b569c7f6-3493-4a0f-a944-dd42698f183b', - initialOutboxStatus: 'PENDING', - initialRetryCount: 62, - failedTarget: '172.31.198.8:9313', - currentOutboxStatus: 'PENDING', - currentRetryCount: 81, - currentFailureCode: 'A0401', - currentFailureMessage: '内部服务令牌无效', - currentRequestId: '200edca3-428e-446d-977a-ca98a84dd704', - currentTraceId: 'e8e6d620c1cff40caa765e6968f89024', - dockerDnsProven: true, - }) -}) - -test('post-rebuild convergence uses the original pending event with no replay or SQL repair', () => { - assert.equal(recoveredEventBridgeFacts({ - firstlegOutboxStatus: 'SENT', overseasBridgeEventCount: 1, - lastlegInboxEventCount: 1, lastlegOrderCount: 1, cnBridgeDuplicateCount: 0, - manualReplayCount: 0, sqlMutationCount: 0, - }), true) - assert.equal(recoveredEventBridgeFacts({ - firstlegOutboxStatus: 'SENT', overseasBridgeEventCount: 1, - lastlegInboxEventCount: 1, lastlegOrderCount: 2, cnBridgeDuplicateCount: 0, - manualReplayCount: 0, sqlMutationCount: 0, - }), false) - assert.equal(recoveredEventBridgeFacts({ - firstlegOutboxStatus: 'SENT', overseasBridgeEventCount: 1, - lastlegInboxEventCount: 1, lastlegOrderCount: 1, cnBridgeDuplicateCount: 0, - manualReplayCount: 1, sqlMutationCount: 0, - }), false) - assert.match(crossRegionHttp, /const delivered = await poll/) - assert.match(crossRegionHttp, /deliveredRows\.length > 0/) - assert.match(crossRegionHttp, /sameIds\(delivered\.response\.body\?\.data, deliveredAgain\.body\?\.data\)/) +test('permanent token failure stops immediately and recovery needs authorized DEAD retry', () => { + const stopped=facts([attempt({failure:'TOKEN_REJECTED'})],{status:'DEAD',automaticRetryScheduled:false}) + assert.equal(validDirectDeliveryEvidence(stopped),true) + const recovered=facts([attempt({failure:'TOKEN_REJECTED'}),attempt({outcome:'APPLIED',ackLost:true}),attempt({outcome:'DUPLICATE'})]) + assert.equal(validDirectDeliveryEvidence(recovered),false) + assert.equal(validDirectDeliveryEvidence({...recovered,authorizedDeadRetry:true}),true) }) diff --git a/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs b/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs index a78f190b8..0757cdc17 100644 --- a/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs +++ b/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs @@ -92,7 +92,16 @@ test('same event replay adds nothing and preserves the allocated number', () => const before = { orderCount: 15, orderNo: 'LMO-260827-0042', detailCount: 30, historyCount: 15 } assert.equal(validEventReplay(before, { ...before }), true) assert.equal(validEventReplay(before, { ...before, orderCount: 30 }), false) - assert.match(consumer, /if\s*\(existing\s*!=\s*null\)[\s\S]{0,400}return countOrders/) + const duplicateBranch = consumer.slice(consumer.indexOf('if(existing!=null)'), consumer.indexOf('LastlegEventInbox inbox=')) + assert.match(duplicateBranch, /existing\.getPayloadHash\(\)\.equalsIgnoreCase\(envelope\.payloadHash\(\)\)/) + assert.match(duplicateBranch, /!"PROCESSED"\.equals\(existing\.getStatus\(\)\).*throw new IllegalStateException/) + assert.match(duplicateBranch, /return DeliveryAck\.of\(envelope, DeliveryAck\.Outcome\.DUPLICATE\)/) + assert.doesNotMatch(duplicateBranch, /createOrder|forContainerNo|nextValue/) + const racedBranch = consumer.slice(consumer.indexOf('catch(DuplicateKeyException exception)'), consumer.indexOf('projectLoadingEvidence(envelope')) + assert.match(racedBranch, /raced\.getPayloadHash\(\)\.equalsIgnoreCase\(envelope\.payloadHash\(\)\)/) + assert.match(racedBranch, /!"PROCESSED"\.equals\(raced\.getStatus\(\)\).*throw new IllegalStateException/) + assert.match(racedBranch, /DeliveryAck\.Outcome\.DUPLICATE/) + assert.match(consumerTests, /replayPreservesExistingLmoNumberAndAddsNothing/) assert.match(consumerTests, /verify\(sequences,never\(\)\)\.nextValue/) }) diff --git a/scripts/admin-test-env-modules/unified-pda-session-revocation-contract.test.mjs b/scripts/admin-test-env-modules/unified-pda-session-revocation-contract.test.mjs new file mode 100644 index 000000000..38994969c --- /dev/null +++ b/scripts/admin-test-env-modules/unified-pda-session-revocation-contract.test.mjs @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { verifyPdaWarehouseRevocation, warehouseClaim } from './unified-pda-session-revocation.mjs' +import { pickerSessionHandoff } from './phase4-overseas-pda.mjs' + +const token = warehouse => 'header.' + Buffer.from(JSON.stringify({ overseas_warehouse_id: warehouse })).toString('base64url') + '.signature' +test('signed binding identity retains 64-bit decimal precision for numeric and string claims', () => { + const make = raw => 'header.' + Buffer.from(raw).toString('base64url') + '.signature' + assert.equal(warehouseClaim(make('{"overseas_warehouse_id":193000000000200004}')), '193000000000200004') + assert.equal(warehouseClaim(token('193000000000200004')), '193000000000200004') + assert.equal(warehouseClaim(make('{"overseas_warehouse_id":null}')), '') + assert.equal(warehouseClaim(make('{"overseas_warehouse_id":22,"overseas_warehouse_id":23}')), '') +}) +function fixture(wrongWarehouse = false) { + const checks = [], calls = [] + let cleared = false + let isolatedRole + const picker = { membershipId: '7', username: 'offline-user', password: 'offline-password', + token: 'old-access', refreshToken: 'old-refresh', userUpdate: { displayName: 'offline', phone: '13900000001', + email: 'offline@example.invalid', tenantId: '0', roleIds: ['9'], overseasWarehouseId: '11', domesticWarehouseId: null } } + const response = (data, code = '200', http = 200) => ({ http, body: { code, data } }) + const context = { assert: (_m, name, _r, passed, evidence) => checks.push({ name, passed, evidence }), + apiSuccess: (_m, _n, r) => r.body.code === '200' ? r.body.data : null, + request: async (method, path, body, bearer) => { + calls.push({ method, path, body, bearer }) + if (path.includes('receiving-options')) return response({ records: [{ id: '11' }, { id: '22' }] }) + if (path === '/api/platform/v1/iam/roles' && method === 'POST') { + isolatedRole = { ...body, id: '99', status: 'ACTIVE', permissionIds: [] } + return response(isolatedRole) + } + if (path === '/api/platform/v1/iam/roles') return response([isolatedRole]) + if (path === '/api/platform/v1/iam/roles/99/status') return response({ status: 'DISABLED' }) + if (method === 'PUT') { + assert.equal(body.phone, picker.userUpdate.phone) + assert.equal(body.email, picker.userUpdate.email) + assert.ok(body.roleIds.length > 0, 'IAM user edits require a role') + cleared = body.roleIds.includes('99') + return response({ ...body }) + } + if (path === '/api/auth/login') return response({ accessToken: token(cleared ? null : wrongWarehouse ? '11' : '22'), refreshToken: 'new-refresh' }) + if (path === '/api/auth/refresh') { + assert.equal(bearer, '') + return response(null, 'A0401') + } + if (bearer === 'old-access' || (cleared && bearer === token('22'))) return response(null, 'A0401', 401) + return cleared ? response(null, 'A0403', 403) : response(null, 'A0404') + } } + return { context, picker: pickerSessionHandoff(picker), checks, calls } +} +test('binding changes revoke both tokens and role/binding clearing prevents fresh PDA access', async () => { + const { context, picker, checks, calls } = fixture() + await verifyPdaWarehouseRevocation(context, picker) + for (const name of ['PDA仓绑定变更旧Access失效', 'PDA仓绑定变更旧Refresh不能恢复会话', + '新PDA会话只携带服务器签发的新仓绑定', 'PDA角色与仓绑定清空旧Access失效', + 'PDA角色与仓绑定清空旧Refresh不能恢复会话', '无作业仓绑定的新会话不得进入PDA']) { + assert.equal(checks.find(row => row.name === name)?.passed, true, name) + } + assert.ok(checks.every(row => row.passed)) + assert.ok(checks.every(row => !/offline-password|old-refresh|new-refresh/.test(row.evidence || ''))) + assert.equal(calls.filter(row => row.method === 'PUT' && row.path.includes('/iam/users/')).length, 2) + assert.ok(calls.some(row => row.path === '/api/platform/v1/iam/roles/99/status')) + assert.ok(calls.some(row => row.path === '/api/auth/refresh' && row.body.refreshToken === 'old-refresh')) +}) +test('fresh login retaining the original warehouse blocks instead of accepting token presence', async () => { + const { context, picker, checks } = fixture(true) + await assert.rejects(verifyPdaWarehouseRevocation(context, picker), /绑定漂移/) + assert.ok(checks.some(row => !row.passed)) +}) diff --git a/scripts/admin-test-env-modules/unified-pda-session-revocation.mjs b/scripts/admin-test-env-modules/unified-pda-session-revocation.mjs new file mode 100644 index 000000000..e1a92802a --- /dev/null +++ b/scripts/admin-test-env-modules/unified-pda-session-revocation.mjs @@ -0,0 +1,77 @@ +import { verifyRevokedSessions } from './system.mjs' + +const MODULE = 'phase4-overseas-pda-operations' +const OPTIONS = { scenario: 'unified-pda-session-revocation', deploymentBlocker: true } +const PATH = '/api/pda/v1/last-leg/receiving-containers/current' +const id = value => value == null ? '' : String(value) +const success = value => value?.http === 200 && String(value?.body?.code) === '200' +export function warehouseClaim(token) { + try { + const raw = Buffer.from(token.split('.')[1], 'base64url').toString('utf8') + JSON.parse(raw) // Check JSON shape; obtain the ID from its original decimal lexeme. + const matches = [...raw.matchAll(/"overseas_warehouse_id"\s*:\s*(?:"([1-9][0-9]*)"|([1-9][0-9]*)(?=\s*[,}]))/g)] + return matches.length === 1 ? matches[0][1] || matches[0][2] : '' + } + catch { return '' } +} + +// Called only after the owned actor's inventory operations finish. Credentials +// stay in the private handoff context and are never added to createdResources. +export async function verifyPdaWarehouseRevocation(context, picker) { + if (!picker?.userUpdate || !picker.password || !picker.refreshToken) throw Error('PDA撤销检查缺少本轮完整会话和资料') + const optionsResponse = await context.request('GET', '/api/platform/v1/warehouse-admin/overseas-receiving-options?page=1&size=200') + const page = context.apiSuccess(MODULE, '查询PDA仓绑定变更可用的权威收货仓', optionsResponse, '', OPTIONS) + const next = page?.records?.find(row => id(row.id) !== id(picker.userUpdate.overseasWarehouseId)) + if (!next?.id) throw Error('PDA仓绑定变更缺少第二个正式收货仓') + const updatePath = `/api/platform/v1/iam/users/${picker.membershipId}` + const updated = await context.request('PUT', updatePath, { ...picker.userUpdate, overseasWarehouseId: id(next.id) }) + context.assert(MODULE, '仅变更本轮PDA账号的海外仓绑定', updated, + success(updated) && id(updated.body.data.overseasWarehouseId) === id(next.id) + && id(updated.body.data.phone) === id(picker.userUpdate.phone), `warehouseId=${id(next.id)}`, OPTIONS) + if (!success(updated)) throw Error('PDA仓绑定更新失败') + await verifyRevokedSessions(context, 'PDA仓绑定变更', [{ accessToken: picker.token, refreshToken: picker.refreshToken }], PATH) + const loggedIn = await context.request('POST', '/api/auth/login', { loginId: picker.username, password: picker.password }, '') + const session = context.apiSuccess(MODULE, 'PDA按新仓绑定重新登录', loggedIn, '', OPTIONS) + const valid = session?.accessToken && session?.refreshToken && warehouseClaim(session.accessToken) === id(next.id) + context.assert(MODULE, '新PDA会话只携带服务器签发的新仓绑定', loggedIn, Boolean(valid), `warehouseId=${id(next.id)}`, OPTIONS) + if (!valid) throw Error('PDA新仓会话身份不完整或绑定漂移') + const allowed = await context.request('GET', PATH, undefined, session.accessToken) + context.assert(MODULE, '新仓绑定会话可进入PDA且不借旧仓会话', allowed, + success(allowed) || (allowed.http === 200 && String(allowed.body?.code) === 'A0404'), + `HTTP=${allowed.http}, code=${allowed.body?.code || 'missing'}`, OPTIONS) + const roleCode = `PDA_UNBOUND_${picker.membershipId}` + const roleResponse = await context.request('POST', '/api/platform/v1/iam/roles', + { roleCode, roleName: `无PDA权限验收-${picker.membershipId}`, description: context.marker }) + const role = context.apiSuccess(MODULE, '创建本轮无PDA权限的隔离角色', roleResponse, '', OPTIONS) + if (!role?.id) throw Error('无PDA权限验收角色创建失败') + try { + const rolesResponse = await context.request('GET', '/api/platform/v1/iam/roles') + const roles = context.apiSuccess(MODULE, '核对隔离角色未获任何权限', rolesResponse, '', OPTIONS) + const isolated = roles?.find(row => id(row.id) === id(role.id)) + const emptyPermissions = isolated?.roleCode === roleCode && isolated?.status === 'ACTIVE' + && Array.isArray(isolated.permissionIds) && isolated.permissionIds.length === 0 + context.assert(MODULE, '清空仓绑定使用本轮无权限角色', rolesResponse, emptyPermissions, `roleId=${id(role.id)}`, OPTIONS) + if (!emptyPermissions) throw Error('无PDA权限验收角色身份或权限不符合要求') + const cleared = await context.request('PUT', updatePath, + { ...picker.userUpdate, roleIds: [id(role.id)], overseasWarehouseId: null, domesticWarehouseId: null }) + context.assert(MODULE, '取消本轮PDA角色同时清空作业仓绑定', cleared, + success(cleared) && cleared.body.data.overseasWarehouseId == null && cleared.body.data.domesticWarehouseId == null + && cleared.body.data.roleIds?.length === 1 && id(cleared.body.data.roleIds[0]) === id(role.id), '', OPTIONS) + if (!success(cleared)) throw Error('PDA角色及仓绑定清空失败') + await verifyRevokedSessions(context, 'PDA角色与仓绑定清空', [session], PATH) + const noBindingLogin = await context.request('POST', '/api/auth/login', { loginId: picker.username, password: picker.password }, '') + const token = success(noBindingLogin) ? noBindingLogin.body.data?.accessToken : null + if (token) { + const denied = await context.request('GET', PATH, undefined, token) + context.assert(MODULE, '无作业仓绑定的新会话不得进入PDA', denied, + denied.http === 403 || (denied.http === 200 && String(denied.body?.code) === 'A0403'), '', OPTIONS) + } else { + context.assert(MODULE, '无PDA身份不能重新取得作业会话', noBindingLogin, + [200, 401, 403].includes(noBindingLogin.http) && ['A0401', 'A0403'].includes(String(noBindingLogin.body?.code)), '', OPTIONS) + } + } finally { + const disabled = await context.request('PUT', `/api/platform/v1/iam/roles/${role.id}/status`, { status: 'DISABLED' }) + context.assert(MODULE, 'finally停用本轮无权限验收角色', disabled, + success(disabled) && disabled.body.data.status === 'DISABLED', `roleId=${id(role.id)}`, OPTIONS) + } +} diff --git a/scripts/admin-test-env-modules/vehicle-care.mjs b/scripts/admin-test-env-modules/vehicle-care.mjs new file mode 100644 index 000000000..2e45f666a --- /dev/null +++ b/scripts/admin-test-env-modules/vehicle-care.mjs @@ -0,0 +1,106 @@ +const BASE = '/api/platform/v1/last-leg/fleet/vehicles' +const VEHICLES = '/api/platform/v1/last-leg/master-data/vehicles' +const OPTIONS = { scenario: 'vehicle-care-lifecycle', deploymentBlocker: true } +const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64') + +/** Run only from an authorized fresh TEST lifecycle. Keep every created record and its evidence. */ +export async function verifyVehicleCare(context, module, vehicle, proof) { + const id = String(vehicle.id), base = `${BASE}/${encodeURIComponent(id)}/records` + proof.status = 'CHECKING'; proof.records = [] + function check(label, response, condition) { + context.assert(module, label, response, Boolean(condition), '', OPTIONS) + if (!condition) throw new Error(label) + } + async function command(method, path, body, label) { + const response = await context.request(method, path, body) + const data = context.apiSuccess(module, label, response, '', OPTIONS) + if (response?.http !== 200 || String(response?.body?.code) !== '200' || data == null) throw new Error(label) + return data + } + const occurredAt = new Date(Date.now() - 86400000).toISOString() + const maintenance = { recordType: 'MAINTENANCE', occurredAt, distanceUnit: 'KM', odometer: 80, + lastServiceOdometer: 20, nextServiceOdometer: 100, fuelQuantity: null, fuelUnit: null, verificationStatus: 'PENDING', remark: context.marker } + const createRequest = { idempotencyKey: `${context.marker}:CARE`, details: maintenance } + let record = await command('POST', base, createRequest, '建立本轮车辆保养记录') + proof.records.push({ recordNo: record.recordNo, createRequest }) + check('保养记录属于本车且原始单位不变', null, record.vehicleId === id && record.details?.distanceUnit === 'KM' && Number(record.details.odometer) === 80) + const replay = await command('POST', base, createRequest, '重放同一保养创建请求') + check('保养创建重放没有重复记录', null, replay.recordNo === record.recordNo && Number(replay.recordVersion) === Number(record.recordVersion)) + const originalVersion = Number(record.recordVersion) + const correction = { recordVersion: originalVersion, reason: '受控验收更正', details: { ...maintenance, odometer: 85 } } + record = await command('PUT', `${base}/${record.recordNo}`, correction, '更正本轮保养里程') + check('更正后业务值与版本均生效', null, Number(record.details.odometer) === 85 && Number(record.recordVersion) === originalVersion + 1) + const stale = await context.request('PUT', `${base}/${record.recordNo}`, correction) + check('旧版本不得覆盖运维记录', stale, stale?.http === 409 || (stale?.http === 200 && ['A0409', '409'].includes(String(stale.body?.code)))) + const history = await command('GET', `${base}/${record.recordNo}/history?page=1&size=20`, undefined, '读取保养更正历史') + check('保养历史保留旧里程与更正原因', null, history.records?.length === 2 && history.records[0].reason === correction.reason + && Number(history.records[1].snapshot?.details?.odometer) === 80) + const fuelRequest = { idempotencyKey: `${context.marker}:FUEL`, details: { ...maintenance, recordType: 'FUEL', distanceUnit: 'MI', + lastServiceOdometer: null, nextServiceOdometer: null, fuelQuantity: 28.6, fuelUnit: 'US_GAL' } } + let fuel = await command('POST', base, fuelRequest, '建立本轮加油记录') + proof.records.push({ recordNo: fuel.recordNo, createRequest: fuelRequest }) + const uploadPath = `${base}/${fuel.recordNo}/files?recordVersion=${fuel.recordVersion}&idempotencyKey=${encodeURIComponent(`${context.marker}:PHOTO`)}` + const upload = () => context.requestMultipart('POST', uploadPath, { name: 'controlled-meter.png', type: 'image/png', content: PNG }) + const first = await upload(); fuel = context.apiSuccess(module, '上传本轮受控运维照片', first, '', OPTIONS) + check('运维照片正式关联本记录', first, Boolean(fuel?.files?.length === 1 && fuel.files[0].fileId)) + const repeated = await upload(), repeatedFuel = context.apiSuccess(module, '同键重放运维照片', repeated, '', OPTIONS) + check('照片重放不增加附件或版本', repeated, repeatedFuel?.files?.length === 1 && repeatedFuel.files[0].fileId === fuel.files[0].fileId + && Number(repeatedFuel.recordVersion) === Number(fuel.recordVersion)) + proof.photoId = fuel.files[0].fileId + const photo = await context.requestBinary('GET', `${base}/${fuel.recordNo}/files/${encodeURIComponent(proof.photoId)}`) + check('运维图片下载真实字节一致', photo, photo?.http === 200 && String(photo.contentType).startsWith('image/png') + && photo.bytes instanceof Uint8Array && Buffer.from(photo.bytes).equals(PNG)) + for (const type of ['REPAIR', 'BREAKDOWN', 'RESCUE', 'EXCEPTION']) { + const priced = ['REPAIR', 'RESCUE'].includes(type) + const details = { recordType: type, occurredAt, distanceUnit: null, odometer: null, lastServiceOdometer: null, nextServiceOdometer: null, + fuelQuantity: null, fuelUnit: null, verificationStatus: null, remark: context.marker, + incident: { description: `${context.marker} 受控故障处置记录`, item: priced ? '更换轮胎' : null, place: priced ? '本轮测试作业点' : null, + handler: null, processingStatus: 'PENDING', exceptionType: type === 'EXCEPTION' ? 'VEHICLE_BREAKDOWN' : null, + reportedCost: type === 'REPAIR' ? 125.5 : priced ? 0 : null, currency: priced ? 'USD' : null } } + const initial = { idempotencyKey: `${context.marker}:${type}`, details } + let incident = await command('POST', base, initial, `创建本轮${type}记录`) + proof.records.push({ recordNo: incident.recordNo, createRequest: initial }) + check('事件项目地点和分类完整回显', null, incident.vehicleId === id && (incident.details?.incident?.item ?? null) === details.incident.item + && (incident.details.incident.place ?? null) === details.incident.place && (incident.details.incident.exceptionType ?? null) === details.incident.exceptionType) + incident = await command('PUT', `${base}/${incident.recordNo}`, { recordVersion: Number(incident.recordVersion), reason: '受控任务实际处理完成', + details: { ...details, incident: { ...details.incident, handler: '本轮测试处理人', processingStatus: 'COMPLETED' } } }, `完成本轮${type}处理记录`) + check('处理完成保持独立的费用及付款事实', null, incident.details.incident.processingStatus === 'COMPLETED' + && (!priced || (Number(incident.details.incident.reportedCost) === details.incident.reportedCost && incident.paymentStatus === 'UNLINKED'))) + const refreshed = await command('GET', `${base}/${incident.recordNo}`, undefined, `重新读取本轮${type}记录`) + check('处理记录重新读取保持全部现场字段', null, (refreshed.details.incident.item ?? null) === details.incident.item + && (refreshed.details.incident.place ?? null) === details.incident.place && refreshed.details.incident.handler === '本轮测试处理人') + const changes = await command('GET', `${base}/${incident.recordNo}/history?page=1&size=20`, undefined, `核对本轮${type}处理历史`) + check('处理历史保留原待处理事实及完成结果', null, changes.records?.length === 2 + && changes.records[0].snapshot?.details?.incident?.processingStatus === 'COMPLETED' + && changes.records[1].snapshot?.details?.incident?.processingStatus === 'PENDING') + } + let other + try { + other = await command('POST', VEHICLES, { plateNumber: `CARE-${context.marker}`.slice(0, 64), model: 'Care isolation fixture', + fuelConsumptionPer100Km: 10, mileageKm: 0, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: context.marker }, '建立运维归属隔离测试车辆') + proof.otherVehicleId = String(other.id) + const foreignBase = `${BASE}/${encodeURIComponent(proof.otherVehicleId)}/records` + for (const [method, suffix, body] of [['GET', `/${record.recordNo}`, undefined], ['PUT', `/${record.recordNo}`, { ...correction, recordVersion: record.recordVersion }], + ['GET', `/${fuel.recordNo}/files/${encodeURIComponent(proof.photoId)}`, undefined]]) { + const response = await context.request(method, foreignBase + suffix, body) + check('其他车辆不能读取或修改本车记录和照片', response, response?.http === 404 || (response?.http === 200 && ['404', 'A0404'].includes(String(response.body?.code)))) + } + const foreign = await command('GET', `${foreignBase}?page=1&size=10`, undefined, '核对其他车辆记录列表') + check('其他车辆列表没有串入本车记录', null, Number(foreign.total) === 0 && foreign.records?.length === 0) + for (const type of ['MAINTENANCE', 'FUEL', 'REPAIR', 'BREAKDOWN', 'RESCUE', 'EXCEPTION']) { + const page = await command('GET', `${base}?recordType=${type}&page=1&size=10`, undefined, '按车辆和类型查询运维记录') + check('每类运维记录唯一且保留单位', null, Number(page.total) === 1 && page.records?.length === 1 && page.records[0].details.recordType === type + && page.records[0].vehicleId === id && (type !== 'FUEL' || page.records[0].details.fuelUnit === 'US_GAL')) + } + const after = await command('GET', `${VEHICLES}/${encodeURIComponent(id)}`, undefined, '回查车辆主档未被运维记录改写') + check('运维保存不改车辆里程状态或主档版本', null, Number(after.mileageKm) === Number(vehicle.mileageKm) + && after.status === vehicle.status && Number(after.recordVersion) === Number(vehicle.recordVersion)) + } finally { + if (other?.id) { + const ended = await command('PATCH', `${VEHICLES}/${encodeURIComponent(String(other.id))}/status`, { recordVersion: Number(other.recordVersion), status: 'INACTIVE' }, '停用本轮隔离用车辆并保留记录') + check('隔离用车辆已停用', null, ended.status === 'INACTIVE'); proof.otherVehicleStatus = ended.status + } + } + proof.status = 'VERIFIED' + return proof +} diff --git a/scripts/admin-test-env-modules/warehouse-transfer-review.mjs b/scripts/admin-test-env-modules/warehouse-transfer-review.mjs index f86c2fa1c..bfa891e43 100644 --- a/scripts/admin-test-env-modules/warehouse-transfer-review.mjs +++ b/scripts/admin-test-env-modules/warehouse-transfer-review.mjs @@ -16,7 +16,7 @@ export async function verifyWarehouseTransferReview(context, token, customerId, const scoped = no => `${PLATFORM}/${encodeURIComponent(no)}?customerId=${encodeURIComponent(customerId)}` const reviewPath = no => `${PLATFORM}/${encodeURIComponent(no)}/review?customerId=${encodeURIComponent(customerId)}` async function call(method, path, body, merchantToken) { - return context.requestOverseas(method, path, body, {}, merchantToken) + return context.request(method, path, body, merchantToken, {}) } const initial = await call('GET', scoped(transferNo)) context.addStep(MODULE, '平台转运详情按客户读取待审核单', initial, diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 5451d495a..4ba170505 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -199,19 +199,30 @@ Assert-Contains $rootAgents 'docs/独立任务协作登记.md' '根测试协作 Assert-Contains $engineeringConstraints '不得同时加载两表后混合选取任务' '工程协作登记选择门禁' Assert-Contains $docsIndex '[测试环境独立任务协作登记](独立任务协作登记.md)' '文档测试登记入口' Assert-Contains $docsIndex '[开发环境独立任务协作登记](开发环境独立任务协作登记.md)' '文档开发登记入口' -# 当前测试登记已明确清空;历史 projectId/task_id 不得成为恢复调度的依据。 -Assert-Contains $testTaskRegistry '当前无登记任务' '测试登记清空明示' -Assert-Contains $testTaskRegistry '清空全部任务登记与调度规则' '测试登记清空状态' -Assert-Contains $testTaskRegistry '不根据旧登记、历史消息或历史台账自动派工、恢复任务或回传进度' '测试登记禁止历史派工' -Assert-Contains $testTaskRegistry '不作为当前调度或授权依据' '测试登记清空授权边界' -Assert-NotContains $testTaskRegistry '已重新启用测试环境总控调度' '测试登记不得隐式启用' -$checked++ -if ($testTaskRegistry -match '(?m)^\|\s*`[0-9a-f-]{36}`\s*\|') { - Add-Failure '测试登记不得残留任务行' +# BEGIN CURRENT TEST REGISTRY CONTRACT +function Assert-TestTaskRegistry([string]$registry) { + $current = ($registry -split '(?m)^## 2026-10-04 全流程唯一持续目标与车辆交接(当前)')[0] + Assert-Contains $current '## 2026-10-04 用户确认的三个独立执行对话(当前派工)' '当前全流程协作入口' + Assert-Contains $current '用户明确选择“按这 3 个独立对话创建”' '测试任务精确数量授权' + Assert-Contains $current '所属唯一总控仍为 `退税报关` / `01a0d0da-130f-7d43-97b5-9c76abc1b5f1`' '测试所属总控' + Assert-Contains $current '`c64729ff-6710-43c6-8ef2-d84e9666cc1e`' '测试保存项目' + Assert-Contains $current '`C:/Users/BY-OY/Desktop/code/chongchong-global`' '测试工作目录' + Assert-Contains $current 'host `local` 与 cwd' '测试授权环境边界' + Assert-Contains $current '旧对话保持原状态,不恢复或唤醒' '测试任务禁止历史恢复' + $expectedIds = @('01a10686-c55c-75a3-9639-655b50cbce6c','01a10686-cae9-7943-b976-27e78ad92723','01a10686-cfdd-7f50-8500-7704e6fbc768') + $actualIds = @([regex]::Matches($current, '(?m)^\|\s*`([0-9a-f-]{36})`\s*\|') | ForEach-Object { $_.Groups[1].Value }) + $script:checked++ + if ($actualIds.Count -ne 3 -or @($actualIds | Select-Object -Unique).Count -ne 3 -or + @($expectedIds | Where-Object { $actualIds -notcontains $_ }).Count -gt 0) { + Add-Failure '测试当前登记必须恰为三个已授权独立任务,归属于唯一全流程总控' + } + Assert-NotContains $registry '当前无登记任务' '测试登记不得混入过期清空状态' + Assert-NotContains $registry '01a0194a-259c-7171-8967-074731a27618' '测试登记历史总控' + Assert-NotContains $registry '01a06c0b-0515-7c60-b329-77342b54e666' '测试登记开发总控' + Assert-NotContains $registry '6f874ec3-d3b2-49bd-8625-327f4d685f98' '测试登记开发项目' } -Assert-NotContains $testTaskRegistry '01a0194a-259c-7171-8967-074731a27618' '测试登记历史总控' -Assert-NotContains $testTaskRegistry '01a06c0b-0515-7c60-b329-77342b54e666' '测试登记当前开发总控' -Assert-NotContains $testTaskRegistry '6f874ec3-d3b2-49bd-8625-327f4d685f98' '测试登记开发 projectId' +# END CURRENT TEST REGISTRY CONTRACT +Assert-TestTaskRegistry $testTaskRegistry Assert-Contains $devTaskRegistry 'projectId `6f874ec3-d3b2-49bd-8625-327f4d685f98`' '开发登记保存项目' Assert-Contains $devTaskRegistry 'C:\Users\BY-OY\Desktop\code\chongchong-global-dev`' '开发登记工作目录' Assert-Contains $devTaskRegistry '`01a06c0b-0515-7c60-b329-77342b54e666`' '开发环境全流程总控' @@ -534,7 +545,6 @@ Assert-Matches $cabinetEvidenceService '(?s)mapper\.byOperation\(taskId, key\).{ $mapperModuleAgents = @( @{ Module = 'audit-service'; Application = 'BaiyueAuditApplication.java' }, @{ Module = 'auth'; Application = 'BaiyueAuthApplication.java' }, - @{ Module = 'event-bridge-service'; Application = 'EventBridgeApplication.java' }, @{ Module = 'file'; Application = 'FileServiceApplication.java' }, @{ Module = 'firstleg'; Application = 'BaiyueFirstlegApplication.java' }, @{ Module = 'iam'; Application = 'BaiyueIamApplication.java' }, @@ -618,29 +628,24 @@ foreach ($route in @( Assert-Matches $clientOverseasRoutes $routePattern "Client 正式路由 $route" } -# 根索引中的海外浏览器 API 必须被海外 Gateway 显式覆盖。 -$overseasGatewayConfigPath = Join-Path $backendRoot 'gateway-overseas/src/main/resources/application-overseas.yml' -$overseasGatewayConfig = Read-RequiredText $overseasGatewayConfigPath -foreach ($gatewayPath in @( - '/api/merchant/v1/last-leg/**', - '/api/platform/v1/last-leg/**', - '/api/pda/v1/last-leg/**', - '/api/platform/v1/warehouses/**', - '/api/platform/v1/warehouse-admin/**' -)) { - Assert-Contains $overseasGatewayConfig $gatewayPath 'Overseas Gateway 路由' +# 四端统一 Gateway,保留服务与会话均来自 TEST 配置。 +$unifiedGateway = Read-RequiredText (Join-Path $backendRoot 'gateway/src/main/resources/application.yml') +foreach ($gatewayPath in @('/api/merchant/v1/last-leg/**', '/api/platform/v1/last-leg/**', '/api/pda/v1/last-leg/**', '/api/platform/v1/warehouse-admin/**', '/api/platform/v1/overseas-warehouse-resources/**')) { + Assert-Contains $unifiedGateway $gatewayPath '统一 Gateway 路由' } -Assert-Contains $overseasGatewayConfig 'uri: lb://lastleg-service' 'Overseas Gateway Lastleg 路由' -Assert-Contains $overseasGatewayConfig 'uri: lb://warehouse-overseas-service' 'Overseas Gateway Warehouse 路由' - -# 海外安全投影已并入 Lastleg,Gateway 校验和 Event Bridge 消费均指向同一海外服务。 +Assert-Contains $unifiedGateway 'uri: lb://lastleg-service' 'Lastleg 服务路由' +Assert-Contains $unifiedGateway 'uri: lb://warehouse-overseas-service' '海外仓资源路由' $serviceMap = Read-RequiredText (Join-Path $backendRoot 'deploy/service-map.yml') -$overseasCompose = Read-RequiredText (Join-Path $backendRoot 'deploy/docker-compose.overseas.yml') +$testCompose = Read-RequiredText (Join-Path $backendRoot 'deploy/docker-compose.yml') $serviceJenkinsfile = Read-RequiredText (Join-Path $backendRoot 'Jenkinsfile.service') -Assert-Contains $serviceMap 'lastleg-service:' 'Lastleg 服务部署映射' -Assert-Contains $overseasCompose '/internal/lastleg/security/v1/events' 'Lastleg 安全事件消费地址' -Assert-Contains $overseasCompose 'LASTLEG_SECURITY_SERVICE_URL' 'Lastleg 安全校验地址' -Assert-NotContains $serviceJenkinsfile "'overseas-security-service'" '独立海外安全服务构建入口' +Assert-Contains $testCompose 'AUTH_SESSION_VALIDATION_URL: http://auth:9100/internal/auth/accounts/sessions/validate' '统一权威会话校验' +Assert-Contains $testCompose 'REDIS_TOKEN_KEY_PREFIX' '统一会话前缀' +Assert-Contains $testCompose 'SPRING_PROFILES_ACTIVE: test' '唯一 TEST profile' +foreach ($retired in @('gateway-overseas:', 'event-bridge-service:')) { + Assert-NotContains $serviceMap $retired '部署映射' + Assert-NotContains $testCompose $retired 'TEST Compose' +} +Assert-Contains $backendAgents 'config/nacos/test/' '唯一 Nacos 配置入口' # 高变化语义必须在最近指引与服务契约中保持一致。 Assert-Contains $adminAgents '海外模块内部快速索引(海外仓全流程)' 'Admin 海外仓全流程范围' @@ -842,7 +847,6 @@ Assert-Contains $pdaAgents 'tests/page-contract.test.mjs' '国内 PDA 页面合 Assert-Contains $pdaAgents 'tests/pda-loading-api.test.mjs' '国内 PDA API 合同' # 国内柜费分摊与海外费用跨区入池必须能从根索引直达,并锁住归属、金额、版本与直接已付隔离。 $financeAgents = Read-RequiredText (Join-Path $backendRoot 'finance/AGENTS.md') -$eventBridgeAgents = Read-RequiredText (Join-Path $backendRoot 'event-bridge-service/AGENTS.md') $commonEventAgents = Read-RequiredText (Join-Path $backendRoot 'common-event-contract/AGENTS.md') $adminFinanceAgents = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/finance/AGENTS.md') $containerFeeOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/container-fees-v1.yaml') @@ -851,7 +855,8 @@ $containerFeeService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/m $overseasFinanceOpenApi = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/resources/openapi/overseas-finance-fees-v1.yaml') $businessFeeEvent = Read-RequiredText (Join-Path $backendRoot 'common-event-contract/src/main/java/com/baiyue/common/event/BusinessFeeSourceChangedV1.java') $eventTypes = Read-RequiredText (Join-Path $backendRoot 'common-event-contract/src/main/java/com/baiyue/common/event/EventTypes.java') -$overseasDispatcher = Read-RequiredText (Join-Path $backendRoot 'event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java') +$directEventClient = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/client/DirectEventClient.java') +$financeBusinessClient = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceBusinessEventClient.java') $financeEventConsumer = Read-RequiredText (Join-Path $backendRoot 'finance/src/main/java/com/baiyue/finance/service/BusinessFeeSourceEventService.java') $financePhase1Service = Read-RequiredText (Join-Path $backendRoot 'finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java') $overseasFinancePhase0ContractPath = Join-Path $workspaceRoot 'docs/财务管理/海外仓费用统一接入阶段0技术契约.md' @@ -889,11 +894,10 @@ Assert-Contains $eventTypes 'BUSINESS_FEE_SOURCE_CHANGED = "BusinessFeeSourceCha Assert-Contains $businessFeeEvent 'int sourceVersion' '海外费用事件来源版本' Assert-Contains $businessFeeEvent 'List paymentVoucherFileIds' '海外费用事件付款证据快照' Assert-Contains $businessFeeEvent 'String sourceHash' '海外费用事件来源哈希' -Assert-Contains $eventBridgeAgents '`BUSINESS_FEE_SOURCE_CHANGED` 与 `WALLET_COMMAND_REQUESTED` 定向投 Finance' 'Event Bridge Finance 定向路由' -Assert-Contains $eventBridgeAgents '/internal/finance/v1/business-fee-source-events' 'Event Bridge Finance 内部入口' -Assert-Contains $overseasDispatcher 'EventTypes.BUSINESS_FEE_SOURCE_CHANGED' 'Event Bridge 海外费用事件 Finance 路由集合' -Assert-Contains $overseasDispatcher 'EventTypes.WALLET_COMMAND_REQUESTED' 'Event Bridge 海外资金指令 Finance 路由集合' -Assert-Matches $overseasDispatcher '(?s)FINANCE_EVENT_TYPES\.contains\(eventType\).{0,150}?financeEventClient\.deliver\(event\)' 'Event Bridge Finance 事件定向投递' +Assert-Contains $directEventClient 'EventTypes.BUSINESS_FEE_SOURCE_CHANGED -> checkedFinance(finance.fee(token, event))' '费用事件定向 Finance' +Assert-Contains $directEventClient 'EventTypes.WALLET_COMMAND_REQUESTED -> checkedFinance(finance.wallet(token, event))' '资金指令定向 Finance' +Assert-Contains $financeBusinessClient '/internal/finance/v1/business-fee-source-events' 'Finance 费用内部入口' +Assert-Contains $financeBusinessClient '/internal/finance/v1/wallet-command-events' 'Finance 资金内部入口' Assert-Contains $financeAgents '`eventId + payloadHash` 防重放冲突' 'Finance 海外事件幂等边界' Assert-Contains $financeAgents '`PAYABLE + DIRECT_PAID` 审核版本必须生成 `BUSINESS_DIRECT_PAID`' 'Finance 直接已付隔离边界' Assert-Contains $businessIndex '入库扣货、解除与凭证' '根入库扣货快速路由' @@ -905,8 +909,12 @@ Assert-Contains $backendAgents '客户资金账户、充值、授信、冻结、 Assert-Contains $lastlegAgents '海外业务资金指令与结果' 'Lastleg 跨区钱包入口' Assert-Contains $lastlegAgents '拆柜打托成本与客户应收分摊' 'Lastleg 拆柜打托费用入口' Assert-Contains $clientAgents '换标申请必须提交正数 CNY `chargeAmount`' 'Client 换标钱包金额边界' -Assert-Contains $businessIndex '海外 Token 不携带角色或权限集' '根海外小 Token 边界' -Assert-Contains $backendAgents '海外 Token 只携带稳定身份、客户/仓库范围和版本声明,不携带角色或权限集' '后端海外小 Token 边界' +Assert-Contains $businessIndex '统一认证与服务通信契约.md' '根统一认证契约入口' +Assert-Contains $backendAgents '各端共用 Gateway 和 Redis 会话' '后端统一认证目标' +$unifiedCommunication = Read-RequiredText (Join-Path $workspaceRoot 'docs/统一认证与服务通信契约.md') +Assert-Contains $unifiedCommunication 'JWT 不携带角色或权限集合' '统一 JWT 最小身份边界' +Assert-Contains $unifiedCommunication '平台/客户/PDA' '统一认证访问域隔离' +Assert-Matches $unifiedCommunication '整体迁移(尚未完成|验收尚未关闭)' '认证目标与落地状态分离' Assert-Contains $financeEventConsumer 'envelope.payloadHash().equals(row.get("payload_hash"))' 'Finance 海外事件 Payload 哈希幂等' Assert-Contains $financeEventConsumer "cost_origin='BUSINESS_DIRECT_PAID'" 'Finance 海外直接已付成本投影' Assert-Contains $financePhase1Service "f.settlement_mode='ON_ACCOUNT'" 'Finance AP 只纳入账期应付' @@ -923,6 +931,8 @@ Assert-Contains $pdaAgents 'tests/picking-location-display.test.mjs' '国内 PDA # Maven 聚合声明的全部历史/当前模块必须在后端根指引中可定位。 [xml]$pom = Get-Content -LiteralPath (Join-Path $backendRoot 'pom.xml') -Raw +if (@($pom.project.modules.module).Count -ne 20) { Add-Failure '当前聚合必须为20模块(7公共/依赖+13服务)' } +if ([regex]::Matches($serviceMap, '(?m)^ [a-z][a-z-]+:').Count -ne 13) { Add-Failure '当前发布映射必须为13服务' } foreach ($module in $pom.project.modules.module) { Assert-Contains $backendAgents "$module/" '后端 AGENTS.md' } @@ -973,7 +983,7 @@ $criticalPaths = @( 'apps/admin-web/src/modules/iam/pages/InternalUsersView.vue', 'apps/admin-web/src/modules/iam/api/platformIam.ts', 'apps/admin-web/src/modules/unified-orders/api/unifiedOrders.ts', - 'apps/client-web/src/api/overseasHttp.ts', + 'apps/client-web/src/api/http.ts', 'apps/client-web/src/modules/overseas-warehouse/routes.ts', 'apps/client-web/src/modules/overseas-warehouse/api/warehouseServices.ts', 'apps/client-web/src/modules/overseas-warehouse/views/WarehouseServiceView.vue', @@ -1080,11 +1090,9 @@ $criticalPaths = @( 'baiyue-firstleg/firstleg/scripts/analyze-firstleg-dependencies.mjs', 'baiyue-firstleg/firstleg/scripts/analyze-firstleg-dependencies.test.mjs', 'baiyue-firstleg/firstleg/src/test/resources/architecture/firstleg-slice-dependencies.json', - 'baiyue-firstleg/auth/src/main/java/com/baiyue/auth/service/OverseasTokenService.java', - 'baiyue-firstleg/auth/src/main/java/com/baiyue/auth/service/OverseasSessionValidationService.java', + 'baiyue-firstleg/auth/src/main/java/com/baiyue/auth/config/UnifiedJwtConfiguration.java', + 'baiyue-firstleg/auth/src/main/java/com/baiyue/auth/service/SessionStateService.java', 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/service/PlatformUserManagementService.java', - 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxService.java', - 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/service/SecurityEventOutboxRelay.java', 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/entity/IamMembership.java', 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionRuleInitializationService.java', 'baiyue-firstleg/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java', @@ -1093,15 +1101,13 @@ $criticalPaths = @( 'baiyue-firstleg/iam/src/main/resources/db/customer-dimension-initialization-task.sql', 'baiyue-firstleg/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java', 'baiyue-firstleg/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java', + 'baiyue-firstleg/common-security/src/main/java/com/baiyue/common/security/HttpSessionStateVerifier.java', + 'baiyue-firstleg/gateway/src/main/java/com/baiyue/gateway/config/RedisTokenSessionWebFilter.java', + 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/client/DirectEventClient.java', 'baiyue-firstleg/common-openapi/src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java', 'baiyue-firstleg/common-web/src/main/java/com/baiyue/common/web/RestClientTraceInterceptor.java', 'baiyue-firstleg/common-web/src/main/java/com/baiyue/common/web/RestClientTracePropagationAutoConfiguration.java', 'baiyue-firstleg/common-web/src/test/java/com/baiyue/common/web/RestClientTraceInterceptorTest.java', - 'baiyue-firstleg/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java', - 'baiyue-firstleg/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java', - 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java', - 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityProjectionService.java', - 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/security/service/LastlegSecurityValidationService.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressSummary.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressDetail.java', @@ -1144,8 +1150,6 @@ $criticalPaths = @( 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/portschedule/entity/CarrierScheduleExceptionFactEntity.java', 'baiyue-firstleg/integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleStandardNode.java', 'baiyue-firstleg/integration-service/src/main/java/com/baiyue/integration/domain/CarrierScheduleExceptionCode.java', - 'baiyue-firstleg/gateway-overseas/src/test/java/com/baiyue/gateway/config/OverseasLastlegRouteContractTest.java', - 'baiyue-firstleg/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/FirstlegEventClient.java', 'baiyue-firstleg/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV2.java', 'baiyue-firstleg/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegLoadingPlanCompletedV3.java', 'baiyue-firstleg/common-event-contract/src/main/java/com/baiyue/common/event/FirstLegContainerShippingNodeRecordedV1.java', @@ -1177,6 +1181,13 @@ $criticalPaths = @( 'docs/财务管理/Firstleg-Finance跨服务契约.md' ) $criticalPaths | ForEach-Object { Assert-Path $_ } +# phase4前端定向合同是本地测试入口;不代表真实页面或环境验收通过。 +foreach ($frontendTestPath in @('apps/admin-web/vitest.phase4-test.config.ts', 'apps/admin-web/scripts/phase4-test-frontend.acceptance.ts', 'apps/admin-web/scripts/phase4-test-frontend-acceptance.mjs', 'apps/admin-web/scripts/phase4-test-frontend-contract.test.mjs')) { + Assert-Path $frontendTestPath +} +$adminPackage = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/package.json') +Assert-Contains $adminPackage '"test:phase4:test": "node scripts/phase4-test-frontend-acceptance.mjs"' 'Admin phase4 TEST入口' + foreach ($removedOverseasWarehouseMasterPath in @( 'baiyue-firstleg/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseController.java', @@ -1209,7 +1220,6 @@ $indexedAgentFiles = @( (Join-Path $backendRoot 'common-openapi/AGENTS.md'), (Join-Path $backendRoot 'warehouse-overseas-service/AGENTS.md'), (Join-Path $backendRoot 'warehouse/AGENTS.md'), - (Join-Path $backendRoot 'event-bridge-service/AGENTS.md'), (Join-Path $backendRoot 'file/AGENTS.md'), (Join-Path $backendRoot 'common-event-contract/AGENTS.md') ) @@ -1248,5 +1258,5 @@ if ($failures.Count -gt 0) { exit 1 } -Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、开发/测试任务登记隔离、前端模块、Client active 路由、客户中文批量下单、箱规算法与测量计费、一件代发预装前计价、称重整票库位块与暂估箱数校准、入库扣货履约门禁、全局智能装柜组分页、客户账单入仓码联查、客户钱包与跨区资金指令、导出退避任务重试、脚本目录治理、Finance 扩展 HTTP 模块、柜费跨客户原子分摊、海外费用跨区入池与直接已付隔离、企业联系信息与登录标识解耦、选填法人身份证独立上传、待审核订单状态优先、订单申报版本同步与渠道门禁、装柜证据内容哈希重放、业务编号与内部 ID 隔离、装柜清单生命周期与真实柜号后补、订舱级拖车与柜级证据、海外仓全流程范围、柜级待处理计划筛选与范围漂移、柜级全柜工作台、计划快照与托盘 Saga、海外 PDA 多计划标志、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、海外小 Token 与安全投影、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、同步 Trace、关键入口和排除目录。" -ForegroundColor Green +Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、开发/测试任务登记隔离、前端模块、Client active 路由、客户中文批量下单、箱规算法与测量计费、一件代发预装前计价、称重整票库位块与暂估箱数校准、入库扣货履约门禁、全局智能装柜组分页、客户账单入仓码联查、客户钱包与跨区资金指令、导出退避任务重试、脚本目录治理、Finance 扩展 HTTP 模块、柜费跨客户原子分摊、海外费用跨区入池与直接已付隔离、企业联系信息与登录标识解耦、选填法人身份证独立上传、待审核订单状态优先、订单申报版本同步与渠道门禁、装柜证据内容哈希重放、业务编号与内部 ID 隔离、装柜清单生命周期与真实柜号后补、订舱级拖车与柜级证据、海外仓全流程范围、柜级待处理计划筛选与范围漂移、柜级全柜工作台、计划快照与托盘 Saga、海外 PDA 多计划标志、仓库主档唯一权威、订舱候选与柜量语义、整柜出仓与船节点单调性、统一 Token 与权威会话、Gateway 覆盖、PDA 页面/运行配置/独立更新/语言预留/能力开关、人工分段验收链、Maven 模块、同步 Trace、关键入口和排除目录。" -ForegroundColor Green exit 0 diff --git a/scripts/agents-index-registry-contract.test.mjs b/scripts/agents-index-registry-contract.test.mjs index cba8c5e78..3e009b82f 100644 --- a/scripts/agents-index-registry-contract.test.mjs +++ b/scripts/agents-index-registry-contract.test.mjs @@ -1,24 +1,35 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' +import { execFileSync } from 'node:child_process' import test from 'node:test' -const source = readFileSync(new URL('./agents-index-contract.ps1', import.meta.url), 'utf8') - -test('cleared test registry is mandatory and cannot inherit historical task authorization', () => { - for (const token of [ - "Assert-Contains $testTaskRegistry '当前无登记任务'", - "Assert-Contains $testTaskRegistry '清空全部任务登记与调度规则'", - "Assert-Contains $testTaskRegistry '不根据旧登记、历史消息或历史台账自动派工、恢复任务或回传进度'", - "Assert-Contains $testTaskRegistry '不作为当前调度或授权依据'", - "Assert-NotContains $testTaskRegistry '已重新启用测试环境总控调度'", - ]) assert.ok(source.includes(token), token) - assert.match(source, /if \(\$testTaskRegistry -match '\(\?m\)\^\\\|\\s\*`\[0-9a-f-\]\{36\}`\\s\*\\\|'\)/) -}) - -test('cleared registry rejects every retired controller and project identity', () => { - for (const retired of [ - '01a0194a-259c-7171-8967-074731a27618', - '01a06c0b-0515-7c60-b329-77342b54e666', - '6f874ec3-d3b2-49bd-8625-327f4d685f98', - ]) assert.match(source, new RegExp(`Assert-NotContains \\$testTaskRegistry '${retired}'`)) +const source = readFileSync(new URL('./agents-index-contract.ps1', import.meta.url), 'utf8').replaceAll('\r\n', '\n') +const registry = readFileSync(new URL('../../docs/独立任务协作登记.md', import.meta.url), 'utf8') +const contract = source.split('# BEGIN CURRENT TEST REGISTRY CONTRACT\n')[1]?.split('# END CURRENT TEST REGISTRY CONTRACT')[0] +assert.ok(contract, 'test exercises the production PowerShell registry validator') +function failures(text) { + const encoded = Buffer.from(text).toString('base64') + const command = `$ErrorActionPreference='Stop' +$script:failures=[System.Collections.Generic.List[string]]::new();$script:checked=0 +function Add-Failure([string]$message){$script:failures.Add($message)} +function Assert-Contains([string]$text,[string]$needle,[string]$scope){if(-not $text.Contains($needle)){Add-Failure $scope}} +function Assert-NotContains([string]$text,[string]$needle,[string]$scope){if($text.Contains($needle)){Add-Failure $scope}} +${contract} +Assert-TestTaskRegistry ([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}'))) +ConvertTo-Json -InputObject @($script:failures) -Compress` + return JSON.parse(execFileSync('pwsh', ['-NoProfile', '-NonInteractive', '-Command', command], { encoding: 'utf8' })) +} +test('actual explicitly authorized TEST registry passes the production validator', () => { + assert.deepEqual(failures(registry), []) }) +for (const [name, mutate] of [ + ['missing creation authorization', s => s.replace('用户明确选择“按这 3 个独立对话创建”', '安排任务')], + ['wrong controller', s => s.replace('所属唯一总控仍为 `退税报关` / `01a0d0da-130f-7d43-97b5-9c76abc1b5f1`', 'task_id=unknown')], + ['wrong project', s => s.replace('`c64729ff-6710-43c6-8ef2-d84e9666cc1e`', 'projectId=wrong')], + ['wrong workspace', s => s.replace('`C:/Users/BY-OY/Desktop/code/chongchong-global`', '`C:/Users/BY-OY/Desktop/code/chongchong-global-dev`')], + ['duplicate task', s => s.replace('01a10686-cae9-7943-b976-27e78ad92723', '01a10686-c55c-75a3-9639-655b50cbce6c')], + ['unregistered replacement', s => s.replace('01a10686-cae9-7943-b976-27e78ad92723', '00000000-0000-0000-0000-000000000000')], + ['obsolete empty status', s => s + '\n当前无登记任务'], + ['foreign project identity', s => s + '\n6f874ec3-d3b2-49bd-8625-327f4d685f98'], + ['retired controller', s => s + '\n01a0194a-259c-7171-8967-074731a27618'], +]) test(`production validator rejects ${name}`, () => assert.ok(failures(mutate(registry)).length > 0)) diff --git a/scripts/audit-deployment-contract.test.mjs b/scripts/audit-deployment-contract.test.mjs index 09dd959af..3424924e8 100644 --- a/scripts/audit-deployment-contract.test.mjs +++ b/scripts/audit-deployment-contract.test.mjs @@ -9,7 +9,7 @@ test('基础 Compose 显式配置审计服务地址,避免静默退化为日 const compose = await read('deploy/docker-compose.yml') assert.match(compose, - /BAIYUE_AUDIT_ENDPOINT: \$\{BAIYUE_AUDIT_ENDPOINT:-http:\/\/audit-service:9700\/internal\/audit\/events\}/) + /BAIYUE_AUDIT_ENDPOINT: http:\/\/audit-service:9700\/internal\/audit\/events/) assert.doesNotMatch(compose, /^\s+AUDIT_SERVICE_URL:/m) assert.match(compose, /^ audit-service:\s*$/m) assert.match(compose, /container_name: firstleg-\$\{DEPLOY_ENV:-test\}-audit/) diff --git a/scripts/backend-full-regression.ps1 b/scripts/backend-full-regression.ps1 index 0fa1ee893..ff1d2e60a 100644 --- a/scripts/backend-full-regression.ps1 +++ b/scripts/backend-full-regression.ps1 @@ -38,10 +38,10 @@ $requiredTests = @( "warehouse/src/test/java/com/baiyue/warehouse/repository/OverseasStorageFeeSqlContractTest.java", "warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformOverseasStorageFeeControllerContractTest.java" ) -$mavenArgs = @("-f", $pom, "-pl", "order,firstleg,iam,gateway,file,warehouse", "-am", "test") +$mavenArgs = @("-f", $pom, "test") Write-Host "Backend regression root: $repoRoot" -Write-Host "Scope: order + firstleg + iam + gateway + file + warehouse Maven reactor (includes all module tests)" +Write-Host "Scope: complete root POM reactor (all active modules and their tests)" Write-Host "Database/API access: none" if (-not (Test-Path -LiteralPath $pom)) { diff --git a/scripts/blue-green-deployment-contract.test.mjs b/scripts/blue-green-deployment-contract.test.mjs deleted file mode 100644 index 6abd984a6..000000000 --- a/scripts/blue-green-deployment-contract.test.mjs +++ /dev/null @@ -1,104 +0,0 @@ -import assert from 'node:assert/strict' -import { readFile } from 'node:fs/promises' -import test from 'node:test' - -const read = async (path) => (await readFile(new URL(`../${path}`, import.meta.url), 'utf8')) - .replaceAll('\r\n', '\n') - -test('all backend business services have blue-green runtime contracts', async () => { - const script = await read('deploy/scripts/deploy-blue-green-service.sh') - for (const service of [ - 'gateway', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'audit-service', 'gateway-overseas', 'lastleg-service', - 'warehouse-overseas-service', 'event-bridge-service' - ]) assert.match(script, new RegExp(`${service.replaceAll('-', '\\-')}:`)) - - assert.match(script, /target_slot="green"/) - assert.match(script, /target_port=\$\(\(BASE_PORT \+ 20000\)\)/) - assert.match(script, /target_port=\$\(\(BASE_PORT \+ 10000\)\)/) - assert.match(script, /compose run -d --no-deps --name "\$candidate"/) - assert.match(script, /wait_for_container "\$candidate"/) - assert.match(script, /BAIYUE_RUNTIME_SCHEDULING_ENABLED=false/) - assert.match(script, /NACOS_REGISTER_ENABLED=false/) - assert.match(script, /start_worker_candidate/) - assert.match(script, /worker_java_options="-Xms512m -Xmx2g/) - assert.match(script, /worker_java_options="-Xms256m -Xmx1g/) - assert.match(script, /connect_routing_aliases "\$candidate" "\$ROUTING_ALIAS" "\$NACOS_SERVICE"/) - assert.match(script, /file,file-service:file/) - assert.match(script, /docker stop --time "\$STOP_TIMEOUT" "\$active_container"/) - assert.match(script, /DRAIN_SECONDS="\$\{BLUE_GREEN_DRAIN_SECONDS:-30\}"/) - assert.match(script, /蓝绿切换后连接排空/) - assert.match(script, /sleep "\$DRAIN_SECONDS"/) - assert.match(script, /mv -f "\$STATE_FILE\.tmp" "\$STATE_FILE"/) -}) - -test('candidate failure keeps the active container and successful cutover stops it last', async () => { - const script = await read('deploy/scripts/deploy-blue-green-service.sh') - const start = script.lastIndexOf('compose run -d') - const healthy = script.indexOf('if ! wait_for_container "$candidate"') - const route = script.indexOf('connect_routing_aliases "$candidate"') - const gateway = script.indexOf('if ! switch_gateway_proxy') - const stop = script.indexOf('docker stop --time "$STOP_TIMEOUT" "$active_container"') - const state = script.indexOf('mv -f "$STATE_FILE.tmp" "$STATE_FILE"') - assert.ok(start >= 0 && start < healthy) - assert.ok(healthy < route && route < gateway && gateway < stop && stop < state) - assert.match(script.slice(healthy, route), /cleanup_candidate/) -}) - -test('gateway cutover is atomic and restores nginx config on validation failure', async () => { - const script = await read('deploy/scripts/deploy-blue-green-service.sh') - assert.match(script, /00-blue-green-gateway\.conf/) - assert.match(script, /00-blue-green-gateway-overseas\.conf/) - assert.doesNotMatch(script, /api-cn\.buggoglobal\.com\.conf|api-global\.buggoglobal\.com\.conf/) - assert.match(script, /尚未完成首次蓝绿迁移,拒绝切换 Gateway/) - assert.match(script, /frontend-\$frontend_service\.active/) - assert.match(script, /尚未使用稳定上游 \$required_alias,拒绝切换 Gateway/) - assert.match(script, /reload_frontend_proxies/) - assert.match(script, /cp -f "\$config" "\$backup"/) - assert.match(script, /nginx -t \|\| ! nginx -s reload/) - assert.match(script, /cp -f "\$backup" "\$config"/) -}) - -test('compose templates preserve rolling defaults and expose slot metadata', async () => { - const domestic = await read('deploy/docker-compose.yml') - const overseas = await read('deploy/docker-compose.overseas.yml') - for (const compose of [domestic, overseas]) { - assert.match(compose, /BAIYUE_DEPLOY_SLOT: \$\{DEPLOY_SLOT:-rolling\}/) - assert.match(compose, /BAIYUE_RELEASE_ID: \$\{RELEASE_ID:-local\}/) - assert.match(compose, /SERVER_SHUTDOWN: graceful/) - assert.match(compose, /stop_grace_period: \$\{STOP_GRACE_PERIOD:-45s\}/) - assert.match(compose, /\$\{DEPLOY_SLOT_SUFFIX:-\}/) - } -}) - -test('every scheduled worker service can disable Nacos registration', async () => { - for (const application of [ - 'iam/src/main/resources/application.yml', - 'firstleg/src/main/resources/application.yml', - 'finance/src/main/resources/application.yml', - 'order/src/main/resources/application.yml', - 'file/src/main/resources/application.yml', - 'event-bridge-service/src/main/resources/application.yml', - 'audit-service/src/main/resources/application.yml', - 'lastleg-service/src/main/resources/application.yml' - ]) { - assert.match(await read(application), /register-enabled: \$\{NACOS_REGISTER_ENABLED:true\}/, application) - } -}) - -test('production single-service pipeline dispatches the blue-green executor', async () => { - const deploy = await read('deploy/scripts/deploy-built-service.sh') - const batch = await read('deploy/scripts/deploy-services.sh') - assert.match(deploy, /run_scp "deploy\/scripts\/deploy-blue-green-service\.sh"/) - assert.match(deploy, /if \[ "\$DEPLOY_ENV" = "production" \]/) - assert.match(deploy, /run_ssh sh "\$REMOTE_DIR\/\$REMOTE_BLUE_GREEN_NAME"/) - assert.match(deploy, /TARGET_IS_LOCAL[\s\S]*?deploy-blue-green-service\.sh/) - assert.match(batch, /sh deploy\/scripts\/deploy-blue-green-service\.sh/) -}) - -test('test environment uses single-slot compose updates', async () => { - const deploy = await read('deploy/scripts/deploy-built-service.sh') - const batch = await read('deploy/scripts/deploy-services.sh') - assert.doesNotMatch(deploy, /if \[ "\$DEPLOY_ENV" = "test" \] \\\n\s*\|\|[\s\S]*?deploy-blue-green-service\.sh/) - assert.match(batch, /if \[ "\$DEPLOY_ENV" = test \]; then[\s\S]*?up -d --no-deps --pull never "\$service"/) -}) diff --git a/scripts/blue-green-deployment-runtime.test.mjs b/scripts/blue-green-deployment-runtime.test.mjs deleted file mode 100644 index 6e4a7026a..000000000 --- a/scripts/blue-green-deployment-runtime.test.mjs +++ /dev/null @@ -1,165 +0,0 @@ -import assert from 'node:assert/strict' -import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { existsSync } from 'node:fs' -import { delimiter, join, resolve } from 'node:path' -import { tmpdir } from 'node:os' -import { spawnSync } from 'node:child_process' -import test from 'node:test' - -const root = resolve(import.meta.dirname, '..') -const script = resolve(root, 'deploy/scripts/deploy-blue-green-service.sh') -const shell = process.platform === 'win32' ? 'C:/Program Files/Git/bin/bash.exe' : '/bin/sh' - -const dockerMock = `#!/usr/bin/env sh -set -eu -printf '%s\\n' "$*" >> "$MOCK_LOG" -command="$1" -shift -case "$command" in - network) - [ "$1" = inspect ] && { echo '{}'; exit 0; } - exit 0 - ;; - compose) - container_name='' - previous='' - for argument in "$@"; do - if [ "$previous" = --name ]; then container_name="$argument"; fi - previous="$argument" - if [ "$argument" = run ]; then - action=run - fi - done - if [ "\${action:-}" = run ]; then - case "$container_name" in - *-worker) : > "$MOCK_WORKER_EXISTS" ;; - *) : > "$MOCK_CANDIDATE_EXISTS" ;; - esac - exit 0 - fi - exit 0 - ;; - inspect) - target='' - for argument in "$@"; do target="$argument"; done - case "$target" in - firstleg-production-file) - [ ! -f "$MOCK_LEGACY_REMOVED" ] - ;; - firstleg-production-file-green) - [ -f "$MOCK_CANDIDATE_EXISTS" ] || exit 1 - case "$*" in - *State.Health*) printf '%s\\n' "\${MOCK_CANDIDATE_HEALTH:-healthy}" ;; - *Config.Image*) printf '%s\\n' 'registry.local/group/file:newtag' ;; - esac - ;; - firstleg-production-file-green-worker) - [ -f "$MOCK_WORKER_EXISTS" ] || exit 1 - case "$*" in - *State.Health*) printf '%s\\n' healthy ;; - *Config.Image*) printf '%s\\n' 'registry.local/group/file:newtag' ;; - esac - ;; - *) exit 1 ;; - esac - ;; - stop) - target='' - for argument in "$@"; do target="$argument"; done - [ "$target" = firstleg-production-file ] && : > "$MOCK_LEGACY_STOPPED" - ;; - rm) - target='' - for argument in "$@"; do target="$argument"; done - case "$target" in - firstleg-production-file) : > "$MOCK_LEGACY_REMOVED" ;; - firstleg-production-file-green) rm -f "$MOCK_CANDIDATE_EXISTS" ;; - firstleg-production-file-green-worker) rm -f "$MOCK_WORKER_EXISTS" ;; - esac - ;; - logs) echo 'candidate logs' ;; - *) echo "unexpected docker command: $command $*" >&2; exit 2 ;; -esac -` - -async function runBlueGreen(health) { - const temp = await mkdtemp(join(tmpdir(), 'blue-green-runtime-')) - const bin = join(temp, 'bin') - const docker = join(bin, 'docker') - const sleep = join(bin, 'sleep') - const envFile = join(temp, 'deployment.env') - const compose = join(temp, 'compose.yml') - const stateRoot = join(temp, 'state') - const log = join(temp, 'docker.log') - await mkdir(bin) - await mkdir(stateRoot) - await writeFile(docker, dockerMock, 'utf8') - await writeFile(sleep, '#!/usr/bin/env sh\nexit 0\n', 'utf8') - await chmod(docker, 0o755) - await chmod(sleep, 0o755) - await writeFile(envFile, 'FILE_EXPORT_CONSUMER_ENABLED=true\n', 'utf8') - await writeFile(compose, 'services: {}\n', 'utf8') - await writeFile(log, '', 'utf8') - - const paths = { - candidate: join(temp, 'candidate'), stopped: join(temp, 'legacy-stopped'), - removed: join(temp, 'legacy-removed'), worker: join(temp, 'worker') - } - const result = spawnSync(shell, [ - script.replaceAll('\\', '/'), 'production', 'registry.local/group', 'newtag', 'file', - envFile.replaceAll('\\', '/'), compose.replaceAll('\\', '/'), 'firstleg-production' - ], { - cwd: root, encoding: 'utf8', env: { - ...process.env, - PATH: `${bin}${delimiter}${process.env.PATH ?? ''}`, - BLUE_GREEN_STATE_ROOT: stateRoot, - BLUE_GREEN_STABILIZATION_SECONDS: '0', - BLUE_GREEN_DRAIN_SECONDS: '0', - DEPLOY_HEALTH_TIMEOUT_SECONDS: '2', - DEPLOY_HEALTH_POLL_SECONDS: '1', - MOCK_LOG: log, - MOCK_CANDIDATE_EXISTS: paths.candidate, - MOCK_WORKER_EXISTS: paths.worker, - MOCK_LEGACY_STOPPED: paths.stopped, - MOCK_LEGACY_REMOVED: paths.removed, - MOCK_CANDIDATE_HEALTH: health - } - }) - const output = `${result.stdout}\n${result.stderr}` - const dockerLog = await readFile(log, 'utf8') - const stateFile = join(stateRoot, 'firstleg-production-file.active') - const state = existsSync(stateFile) ? await readFile(stateFile, 'utf8') : '' - const evidence = { - ...result, output, dockerLog, state, - stopped: existsSync(paths.stopped), removed: existsSync(paths.removed), - worker: existsSync(paths.worker) - } - await rm(temp, { recursive: true, force: true }) - return evidence -} - -test('healthy candidate becomes active only after health and old instance drains', async (context) => { - if (!existsSync(shell)) context.skip(`shell unavailable: ${shell}`) - const result = await runBlueGreen('healthy') - assert.equal(result.status, 0, result.output) - assert.equal(result.stopped, true) - assert.equal(result.removed, true) - assert.equal(result.worker, true) - assert.match(result.state, /^green\|firstleg-production-file-green\|19206\|registry\.local\/group\/file:newtag\|firstleg-production-file-green-worker/) - const run = result.dockerLog.indexOf('compose --project-name firstleg-production') - const stop = result.dockerLog.indexOf('stop --time 45 firstleg-production-file') - assert.ok(run >= 0 && run < stop) - assert.match(result.dockerLog, - /network connect --alias file --alias file-service firstleg-production-routing firstleg-production-file-green/) -}) - -test('unhealthy candidate is removed while old instance and state remain untouched', async (context) => { - if (!existsSync(shell)) context.skip(`shell unavailable: ${shell}`) - const result = await runBlueGreen('unhealthy') - assert.equal(result.status, 1, result.output) - assert.equal(result.stopped, false) - assert.equal(result.removed, false) - assert.equal(result.worker, false) - assert.equal(result.state, '') - assert.doesNotMatch(result.dockerLog, /stop --time .*firstleg-production-file/) -}) diff --git a/scripts/ci-maven-resource-contract.test.mjs b/scripts/ci-maven-resource-contract.test.mjs new file mode 100644 index 000000000..321f7321f --- /dev/null +++ b/scripts/ci-maven-resource-contract.test.mjs @@ -0,0 +1,67 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync, mkdtempSync, writeFileSync, rmSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawnSync } from 'node:child_process' + +const root = fileURLToPath(new URL('..', import.meta.url)) +const wrapper = resolve(root, 'deploy/scripts/run-ci-maven.sh') +const bash = process.platform === 'win32' ? 'C:/Program Files/Git/bin/bash.exe' : 'bash' +const shellAvailable = process.platform !== 'win32' || existsSync(bash) + +test('both Jenkins Maven paths share one cross-job lock and bounded host entry', () => { + for (const file of ['Jenkinsfile', 'Jenkinsfile.service']) { + const source = readFileSync(resolve(root, file), 'utf8') + assert.match(source, /lock\(resource: 'firstleg-test-maven'\)\s*\{\s*timeout\(time: 50, unit: 'MINUTES'\)/) + assert.match(source, /sh deploy\/scripts\/run-ci-maven\.sh --settings/) + assert.doesNotMatch(source, /(?:^|\n)\s*mvn\s|sh "mvn /) + } + const source = readFileSync(wrapper, 'utf8') + assert.match(source, /exec 9>/) + assert.match(source, /flock -n 9/) + assert.match(source, /exec timeout --signal=TERM --kill-after=30s 45m mvn -Pci-bounded/) + assert.doesNotMatch(source, /exec timeout[^\n]*--foreground/) +}) + +test('test JVM gets a separate heap, JaCoCo late binding, and parent-loss cleanup', () => { + const pom = readFileSync(resolve(root, 'pom.xml'), 'utf8') + const profile = pom.match(/\s*ci-bounded<\/id>([\s\S]*?)<\/profile>/)?.[1] + assert.ok(profile) + assert.match(profile, /@\{argLine\}[^<]*-Xmx1024m[^<]*-XX:\+ExitOnOutOfMemoryError<\/argLine>/) + for (const [name, value] of Object.entries({ forkCount: '1', enableProcessChecker: 'ping', shutdown: 'kill', + forkedProcessTimeoutInSeconds: '1200', forkedProcessExitTimeoutInSeconds: '30' })) + assert.ok(profile.includes(`<${name}>${value}`)) +}) + +test('shell rejects parallel reactors and held host lock before invoking Maven; propagates failure', { skip: !shellAvailable }, () => { + const directory = mkdtempSync(join(tmpdir(), 'ci-maven-command-test-')) + try { + // Stand-ins exercise command wiring, not Linux flock or process-group signalling. + writeFileSync(join(directory, 'flock'), '#!/bin/sh\nexit "${TEST_LOCK_STATUS:-0}"\n', { mode: 0o755 }) + writeFileSync(join(directory, 'mvn'), '#!/bin/sh\nexit 99\n', { mode: 0o755 }) + writeFileSync(join(directory, 'timeout'), '#!/bin/sh\nprintf "%s\\n" "$MAVEN_OPTS" "$@"\nexit "${TEST_CHILD_STATUS:-0}"\n', { mode: 0o755 }) + const run = (args, extra = {}) => spawnSync(bash, ['-c', + 'testbin=$(cd "$1" && pwd); shift; PATH="$testbin:$PATH"; export PATH; exec sh "$@"', + 'fixture', directory.replaceAll('\\', '/'), wrapper.replaceAll('\\', '/'), ...args], + { encoding: 'utf8', timeout: 10000, env: { ...process.env, ...extra } }) + for (const args of [['-T', '3', 'test'], ['-T3', 'test'], ['--threads=3', 'test']]) { + const result = run(args) + assert.equal(result.status, 64, result.stderr) + assert.equal(result.stdout, '') + } + const held = run(['test'], { TEST_LOCK_STATUS: '1' }) + assert.equal(held.status, 75, held.stderr) + assert.equal(held.stdout, '') + const normal = run(['-pl', 'file', '-am', 'test'], { TEST_CHILD_STATUS: '23', MAVEN_OPTS: '-Xmx8g' }) + assert.equal(normal.status, 23, normal.stderr) + assert.match(normal.stdout, /-Xmx768m/) + assert.doesNotMatch(normal.stdout, /-Xmx8g/) + assert.match(normal.stdout, /--signal=TERM\r?\n--kill-after=30s\r?\n45m\r?\nmvn\r?\n-Pci-bounded\r?\n-pl\r?\nfile/) + } finally { + // Only this newly-created fixture directory; no project copies or shared caches removed. + assert.equal(resolve(directory).startsWith(resolve(tmpdir()) + (process.platform === 'win32' ? '\\' : '/')), true) + rmSync(directory, { recursive: true }) + } +}) diff --git a/scripts/client-address-isolation-test-env.mjs b/scripts/client-address-isolation-test-env.mjs new file mode 100644 index 000000000..5df8763e9 --- /dev/null +++ b/scripts/client-address-isolation-test-env.mjs @@ -0,0 +1,134 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { resolve, dirname } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { parseEnv } from 'node:util' +import { createHash } from 'node:crypto' +import { executionOptions } from './client-workbench-test-env.mjs' +import { customerProfilePath } from './client-import-template-test-env.mjs' + +const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex') +class CheckFailure extends Error {} +function requireFact(ok, name) { if (!ok) throw new CheckFailure(name) } + +export function addressDraft(kind, address, warehouse, channel, destination) { + return { customerName: '地址隔离验收', transportMode: 'SEA', tradeTerm: 'FOB', destinationCountryCode: 'US', + receivingWarehouseId: warehouse.id, receivingWarehouseName: warehouse.warehouseName, + channelCode: channel.channelCode, channelName: channel.channelName, taxMode: 'BUY_DECLARATION', + pricingTaxMode: 'TAX_INCLUDED', containerMode: 'LCL', customsInspection: false, fulfillmentType: 'STANDARD', + destinationType: kind === 'PICKUP' ? 'FBA' : 'PRIVATE', pickupMethod: kind === 'PICKUP' ? 'PICKUP' : 'SELF_TRUCK', + ...(kind === 'PICKUP' ? { senderAddressId: address.id, destinationWarehouseId: destination.id, + destinationWarehouseName: destination.warehouseName, amazonWarehouseCode: destination.warehouseCode } + : { privateDestinationAddressId: address.id }), + cargoes: [{ cargoName: '地址边界测试货物', packageType: 'CARTON', packageCount: 1, grossWeightKg: 1, + volumeCbm: 0.001, unitsPerPackage: 1, totalQuantity: 1, woodenProduct: false, dangerousGoods: false }] } +} + +export function assertAddressRejected(response, batch = false) { + const body = response.body + if (batch) { + requireFact(response.http === 200 && String(body?.code) === '200', 'Batch transport failed before row-level address validation') + const data = body.data + requireFact(data?.total === 1 && data.succeeded === 0 && data.failed === 1 && data.items?.length === 1, 'Unexpected batch success or shape') + const row = data.items[0] + requireFact(row.success === false && row.orderId == null && row.errorCode === 'A0404' + && row.errorMessage === '客户地址不存在或已停用', 'Batch did not reject specifically at the customer-address boundary') + } else { + requireFact([200, 404].includes(response.http) && body?.code === 'A0404' + && body.message === '客户地址不存在或已停用' && body.data == null, 'Create did not reject specifically at the customer-address boundary') + } +} + +export async function execute(args = process.argv.slice(2), execArgs = process.execArgv, env = process.env) { + if (!args.length || args.length === 1 && args[0] === '--plan') return { plan: true, + requests: 'Two independent logins; existing source addresses; two create and two single-row batch rejection probes; logout both sessions', + writes: 'Negative creation probes must return the precise address-not-found error; stop immediately on unexpected success or unknown outcome' } + requireFact(args.length === 4, 'Explicit TEST run and customer profile required') + const options = executionOptions(args.slice(0, 3), execArgs, env) + const profilePath = customerProfilePath(args[3], execArgs[0].slice(11)) + const control = parseEnv(await readFile(profilePath, 'utf8')) + requireFact(control.MERCHANT_TEST_USERNAME && control.MERCHANT_TEST_PASSWORD, 'Missing control credentials') + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') + const directory = resolve(root, 'reports', `client-address-isolation-${options.runId}`) + await mkdir(directory) + const report = { startedAt: new Date().toISOString(), success: false, requests: [], checks: [], sessionsClosed: [] } + const sessions = [] + const save = () => writeFile(resolve(directory, 'report.json'), JSON.stringify(report, null, 2)) + async function request(session, method, path, body, key) { + requireFact(method === 'GET' || ['/auth/login', '/auth/logout', '/merchant/v1/orders/first-leg', '/merchant/v1/orders/first-leg/batch'].includes(path), 'Unexpected write endpoint') + const entry = { session: session.name, method, path: path.split('?')[0], outcome: 'PENDING', key, + payloadSha256: body && !path.startsWith('/auth/') ? digest(body) : undefined } + report.requests.push(entry); await save() + try { + const result = await fetch(options.base + path, { method, signal: AbortSignal.timeout(30000), redirect: 'error', + headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), + ...(session.token ? { Authorization: `Bearer ${session.token}` } : {}), ...(key ? { 'Idempotency-Key': key } : {}) }, body: body ? JSON.stringify(body) : undefined }) + const payload = await result.json() + Object.assign(entry, { outcome: 'RECEIVED', http: result.status, code: payload.code, requestId: payload.requestId, traceId: payload.traceId }) + if (method === 'POST' && path.startsWith('/merchant/v1/orders/first-leg')) { + entry.returnedOrderIds = Array.isArray(payload.data?.items) ? payload.data.items.filter(r => r.orderId != null).map(r => String(r.orderId)) + : typeof payload.data === 'string' || typeof payload.data === 'number' ? [String(payload.data)] : [] + entry.rowErrorCodes = payload.data?.items?.map(r => r.errorCode) + } + await save() + return { http: result.status, body: payload } + } catch (error) { entry.outcome = 'UNKNOWN_STOP'; await save(); throw error } + } + async function successful(session, method, path, body) { + const response = await request(session, method, path, body) + requireFact(response.http === 200 && response.body?.code === '200', 'Setup or cleanup request rejected') + return response.body.data + } + try { + for (const [name, username, password] of [['source', env.CHONGCHONG_MERCHANT_USERNAME, env.CHONGCHONG_MERCHANT_PASSWORD], + ['control', control.MERCHANT_TEST_USERNAME, control.MERCHANT_TEST_PASSWORD]]) { + const session = { name }; sessions.push(session) + const auth = await successful(session, 'POST', '/auth/login', { loginId: username, password }) + session.token = auth?.accessToken; session.refresh = auth?.refreshToken + requireFact(session.token && session.refresh, 'Incomplete login') + session.profile = await successful(session, 'GET', '/merchant/v1/iam/tenant/profile') + requireFact(session.profile?.id != null, 'Missing authenticated customer identity') + } + const [source, target] = sessions + requireFact(String(source.profile.id) !== String(target.profile.id), 'Two different customers required') + report.customerIdentitySha256 = sessions.map(s => digest(String(s.profile.id))) + const warehouses = await successful(target, 'GET', '/merchant/v1/warehouses?warehouseType=DOMESTIC&status=ACTIVE&page=1&size=200') + const warehouse = warehouses.records.find(w => w.warehouseType === 'DOMESTIC' && w.status === 'ACTIVE') + requireFact(warehouse, 'A real active domestic warehouse is required') + const channels = await successful(target, 'GET', `/merchant/v1/first-leg/pricing/channels?originWarehouseId=${warehouse.id}`) + requireFact(channels.length > 0, 'A real channel is required') + const destinationPage = await successful(target, 'GET', '/merchant/v1/warehouses?warehouseType=FBA&status=ACTIVE&page=1&size=200') + const destination = destinationPage.records.find(w => w.warehouseType === 'FBA' && w.status === 'ACTIVE') + requireFact(destination, 'A real active FBA destination is required') + for (const kind of ['PICKUP', 'PRIVATE_DESTINATION']) { + const path = `/merchant/v1/orders/first-leg/addresses?addressType=${kind}` + const foreign = await successful(source, 'GET', path), own = await successful(target, 'GET', path) + requireFact(foreign.length > 0, 'Source address sample missing') + requireFact(!own.some(a => String(a.id) === String(foreign[0].id)), 'Source address leaked into target directory') + const draft = addressDraft(kind, foreign[0], warehouse, channels[0], destination) + for (const batch of [false, true]) { + const key = `ADDR-${options.runId}-${kind}-${batch ? 'batch' : 'single'}` + const body = batch ? [{ clientRowId: kind, draft }] : draft + const response = await request(target, 'POST', '/merchant/v1/orders/first-leg' + (batch ? '/batch' : ''), body, key) + assertAddressRejected(response, batch) + report.checks.push({ kind, endpoint: batch ? 'batch' : 'single', passed: true, payloadSha256: digest(body) }) + await save() + } + } + report.success = true + report.limitation = 'Proves JSON single/batch address rejection; XLSX upload parsing and database zero-drift evidence remain separate' + } catch (error) { + report.failure = error instanceof CheckFailure ? error.message : 'Transport, setup or response format failure; no automatic retry' + } finally { + for (const session of sessions) if (session.token) { + try { await successful(session, 'POST', '/auth/logout', { refreshToken: session.refresh }); report.sessionsClosed.push(session.name) } + catch { report.success = false; report.logoutFailure = true } + } + report.success = report.success && report.sessionsClosed.length === 2 + report.completedAt = new Date().toISOString(); await save() + } + return { success: report.success, checks: report.checks.length, report: resolve(directory, 'report.json') } +} +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + execute().then(result => { console.log(JSON.stringify(result)); if (result.success === false) process.exitCode = 1 }) + .catch(() => { console.error('Address isolation probe did not start; check explicit TEST profile and new run-id.'); process.exitCode = 1 }) +} diff --git a/scripts/client-address-isolation-test-env.test.mjs b/scripts/client-address-isolation-test-env.test.mjs new file mode 100644 index 000000000..62b95626a --- /dev/null +++ b/scripts/client-address-isolation-test-env.test.mjs @@ -0,0 +1,27 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { addressDraft, assertAddressRejected, execute } from './client-address-isolation-test-env.mjs' + +test('negative probe plan does not require credentials or execute HTTP', async () => { + assert.equal((await execute(['--plan'], [], {})).plan, true) +}) +test('each payload probes exactly one foreign address field', () => { + const args = [{ id: 'foreign' }, { id: 'domestic', warehouseName: '国内仓' }, { channelCode: 'SEA', channelName: '海运' }, { id: 'fba', warehouseCode: 'FBA-A', warehouseName: '目的仓' }] + const pickup = addressDraft('PICKUP', ...args), destination = addressDraft('PRIVATE_DESTINATION', ...args) + assert.equal(pickup.senderAddressId, 'foreign'); assert.equal(pickup.privateDestinationAddressId, undefined) + assert.equal(pickup.destinationType, 'FBA'); assert.equal(pickup.destinationWarehouseId, 'fba') + assert.equal(destination.privateDestinationAddressId, 'foreign'); assert.equal(destination.senderAddressId, undefined) + assert.equal(destination.destinationType, 'PRIVATE'); assert.equal(destination.pickupMethod, 'SELF_TRUCK') +}) +test('accepts only the precise address-boundary rejection, never unrelated validation or success', () => { + const single = { http: 200, body: { code: 'A0404', message: '客户地址不存在或已停用', data: null } } + const item = { success: false, orderId: null, errorCode: 'A0404', errorMessage: '客户地址不存在或已停用' } + const batch = { http: 200, body: { code: '200', data: { total: 1, succeeded: 0, failed: 1, items: [item] } } } + assert.doesNotThrow(() => assertAddressRejected(single)) + assert.doesNotThrow(() => assertAddressRejected(batch, true)) + for (const code of ['200', 'A0422', 'A0403']) assert.throws(() => assertAddressRejected({ ...single, body: { ...single.body, code } })) + assert.throws(() => assertAddressRejected({ ...single, body: { ...single.body, message: '仓库不存在' } })) + for (const change of [{ success: true, orderId: 'created' }, { errorCode: 'A0422' }, { errorMessage: '字段不能为空' }]) { + assert.throws(() => assertAddressRejected({ ...batch, body: { code: '200', data: { ...batch.body.data, items: [{ ...item, ...change }] } } }, true)) + } +}) diff --git a/scripts/client-import-template-inspect.py b/scripts/client-import-template-inspect.py new file mode 100644 index 000000000..0280bb4ed --- /dev/null +++ b/scripts/client-import-template-inspect.py @@ -0,0 +1,196 @@ +"""Read-only TEST template audit. Credentials and workbook bytes stay in memory.""" +import hashlib +import io +import json +import re +import sys +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from pathlib import Path +from openpyxl import load_workbook + + +def require(value, message): + if not value: + raise AssertionError(message) + + +def labels(values, labeler): + result = [] + for item in values: + base = labeler(item) + label, n = base, 2 + while label in result: + label = f"{base}(同名选项 {n})" + n += 1 + result.append(label) + return result + + +def label(name, code): + return code if not name or name.lower() == code.lower() else f"{name}|{code}" + + +def compare_options(actual, expected): + return {"expectedCount": len(expected), "actualCount": len(actual), + "missingCount": len(set(expected) - set(actual)), + "extraCount": len(set(actual) - set(expected)), + "duplicateCount": len(actual) - len(set(actual)), + "passed": sorted(actual) == sorted(expected), + "actualSha256": hashlib.sha256(json.dumps(sorted(actual), ensure_ascii=False).encode()).hexdigest()} + + +def inspect_template(content, expected): + book = load_workbook(io.BytesIO(content), data_only=False) + try: + require(set(book.sheetnames) == {"订单导入", "选项数据", "填写说明"}, "Unexpected workbook sheets") + require(book["选项数据"].sheet_state == "hidden", "Options sheet must be hidden") + sheet = book["订单导入"] + headers = {str(cell.value).removesuffix(" *"): cell.column for cell in sheet[2] if cell.value} + checks = {} + for title, values in expected.items(): + require(title in headers, "Missing expected header") + col = headers[title] + validations = [v for v in sheet.data_validations.dataValidation + if any(r.min_col <= col <= r.max_col and r.min_row <= 3 <= r.max_row for r in v.sqref.ranges)] + if not values: + require(not validations, "Empty customer directory unexpectedly has choices") + checks[title] = compare_options([], []) + continue + require(len(validations) == 1, "Missing or overlapping dropdown validation") + validation = validations[0] + require(validation.type == "list" and validation.showErrorMessage, "Dropdown must reject invalid choices") + require(any(r.min_row == 3 and r.max_row == 52 and r.min_col == col and r.max_col == col + for r in validation.sqref.ranges), "Dropdown must cover all 50 input rows") + name = book.defined_names.get(validation.formula1) + require(name is not None, "Dropdown name does not resolve") + destinations = list(name.destinations) + require(len(destinations) == 1 and destinations[0][0] == "选项数据", "Invalid dropdown destination") + actual = [cell.value for row in book[destinations[0][0]][destinations[0][1]] for cell in row] + require(all(isinstance(v, str) and v for v in actual), "Dropdown contains blank or invalid entries") + checks[title] = compare_options(actual, values) + return {"checks": checks, "passed": all(v["passed"] for v in checks.values()), + "workbookSha256": hashlib.sha256(content).hexdigest(), "headerCount": len(headers), + "dropdowns": len(sheet.data_validations.dataValidation)} + finally: + book.close() + + +def main(config): + require(config.get("environment") == "TEST" and re.fullmatch(r"[A-Za-z0-9_-]{1,60}", config.get("runId", "")), "Explicit TEST run required") + directory = Path(__file__).resolve().parent.parent / "reports" / ("client-template-" + config["runId"]) + directory.mkdir() # Never overwrite an earlier run. + report_path = directory / "report.json" + report = {"startedAt": datetime.now(timezone.utc).isoformat(), "readOnlyBusiness": True, + "success": False, "sessionClosed": False, "requests": []} + token, refresh = "", "" + + def save(): + report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + + def request(method, path, body=None, binary=False): + require(method == "GET" or method == "POST" and path in ["/auth/login", "/auth/logout"], "Business writes forbidden") + parsed = urllib.parse.urlsplit(path) + query = urllib.parse.parse_qs(parsed.query) + record = {"method": method, "path": parsed.path, "outcome": "PENDING", + "page": query.get("page"), "size": query.get("size")} + report["requests"].append(record) + save() + headers = {"Accept": "application/json"} + if token: + headers["Authorization"] = "Bearer " + token + if body is not None: + headers["Content-Type"] = "application/json" + req = urllib.request.Request(config["base"] + path, method=method, headers=headers, + data=json.dumps(body).encode() if body is not None else None) + try: + with urllib.request.urlopen(req, timeout=30) as response: + data = response.read() + record.update(outcome="RECEIVED", http=response.status, + requestId=response.headers.get("X-Request-ID"), traceId=response.headers.get("X-Trace-ID")) + if binary: + require(data[:2] == b"PK", "Template response is not XLSX") + record["sha256"] = hashlib.sha256(data).hexdigest() + save() + return data + payload = json.loads(data) + record.update(code=payload.get("code"), requestId=payload.get("requestId") or record["requestId"], + traceId=payload.get("traceId") or record["traceId"]) + save() + require(str(payload.get("code")) == "200", "API business response rejected") + return payload["data"] + except urllib.error.HTTPError as error: + record.update(outcome="RECEIVED", http=error.code) + save() + raise AssertionError("HTTP response rejected") from None + except Exception: + if record["outcome"] == "PENDING": + record["outcome"] = "UNKNOWN" + save() + raise + + try: + auth = request("POST", "/auth/login", {"loginId": config["username"], "password": config["password"]}) + token, refresh = auth.get("accessToken", ""), auth.get("refreshToken", "") + require(token and refresh, "Incomplete login") + profile = request("GET", "/merchant/v1/iam/tenant/profile") + require(profile.get("id") is not None, "Missing authenticated customer identity") + report["customerIdentitySha256"] = hashlib.sha256(str(profile["id"]).encode()).hexdigest() + warehouses, total, page_no = [], None, 1 + while True: + page = request("GET", f"/merchant/v1/warehouses?status=ACTIVE&page={page_no}&size=200") + if total is None: + total = int(page["total"]) + require(int(page["page"]) == page_no and int(page["size"]) == 200 and int(page["total"]) == total + and int(page["pages"]) == (total + 199) // 200, "Warehouse pagination changed") + require(len(page["records"]) == min(200, max(0, total - (page_no - 1) * 200)), "Warehouse page incomplete") + warehouses.extend(page["records"]) + if page_no >= int(page["pages"]): + break + require(page_no < 1000, "Warehouse page limit exceeded") + page_no += 1 + require(len({str(w["id"]) for w in warehouses}) == total and all(w["status"] == "ACTIVE" for w in warehouses), "Invalid warehouse set") + expected = {} + for kind, title in [("DOMESTIC", "国内收货仓"), ("FBA", "FBA目的仓"), ("OVERSEAS", "海外仓目的仓")]: + expected[title] = labels([w for w in warehouses if w["warehouseType"] == kind], lambda w: label(w["warehouseName"], w["warehouseCode"])) + channels = [] + for warehouse in warehouses: + if warehouse["warehouseType"] != "DOMESTIC": + continue + values = request("GET", "/merchant/v1/first-leg/pricing/channels?originWarehouseId=" + str(warehouse["id"])) + channels.extend(label(c["channelName"], c["channelCode"]) for c in values) + expected["运输渠道"] = list(dict.fromkeys(channels)) + for kind, title in [("PICKUP", "取货地址"), ("PRIVATE_DESTINATION", "私人目的仓地址")]: + addresses = request("GET", "/merchant/v1/orders/first-leg/addresses?addressType=" + kind) + expected[title] = labels(addresses, lambda a: "|".join(a[key] for key in ["addressName", "contactName", "city", "addressLine"])) + content = request("GET", "/merchant/v1/orders/first-leg/batch-imports/template", binary=True) + report["template"] = inspect_template(content, expected) + report["warehousePages"] = page_no + report["warehouseTotal"] = total + report["success"] = report["template"]["passed"] + report["limitations"] = ["No import submission or Excel desktop interaction performed", + "Second-customer address rejection requires separate evidence"] + except Exception as error: + report["failure"] = str(error) if isinstance(error, AssertionError) else type(error).__name__ + finally: + if token: + try: + request("POST", "/auth/logout", {"refreshToken": refresh}) + report["sessionClosed"] = True + except Exception: + report["logoutFailure"] = True + report["success"] = report["success"] and report["sessionClosed"] + report["completedAt"] = datetime.now(timezone.utc).isoformat() + save() + print(json.dumps({"success": report["success"], "sessionClosed": report["sessionClosed"], "report": str(report_path)})) + return 0 if report["success"] else 1 + + +if __name__ == "__main__": + try: + sys.exit(main(json.load(sys.stdin))) + except Exception: + print("Template audit did not start; verify the new report directory and runtime.", file=sys.stderr) + sys.exit(1) diff --git a/scripts/client-import-template-inspect.test.py b/scripts/client-import-template-inspect.test.py new file mode 100644 index 000000000..2dda6fbed --- /dev/null +++ b/scripts/client-import-template-inspect.test.py @@ -0,0 +1,53 @@ +import importlib.util +import io +import unittest +import zipfile +from pathlib import Path +from xml.sax.saxutils import escape + +spec = importlib.util.spec_from_file_location("template_audit", Path(__file__).with_name("client-import-template-inspect.py")) +audit = importlib.util.module_from_spec(spec) +spec.loader.exec_module(audit) + + +def fixture(options=("测试仓|WH-A", "测试仓|WH-B"), rows="A3:A52", target="option_0", hidden=True): + ns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" + rel = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" + body = io.BytesIO() + with zipfile.ZipFile(body, "w") as z: + z.writestr("[Content_Types].xml", '') + z.writestr("_rels/.rels", f'') + z.writestr("xl/workbook.xml", f'选项数据!$A$1:$A${len(options)}') + z.writestr("xl/_rels/workbook.xml.rels", '' + ''.join(f'' for i in (1, 2, 3)) + '') + z.writestr("xl/worksheets/sheet1.xml", f'国内收货仓 *{target}') + data = ''.join(f'{escape(value)}' for i, value in enumerate(options, 1)) + z.writestr("xl/worksheets/sheet2.xml", f'{data}') + z.writestr("xl/worksheets/sheet3.xml", f'') + return body.getvalue() + + +class TemplateAuditTest(unittest.TestCase): + def test_reads_actual_xlsx_names_validation_and_all_options(self): + result = audit.inspect_template(fixture(), {"国内收货仓": ["测试仓|WH-A", "测试仓|WH-B"]}) + self.assertTrue(result["passed"]) + self.assertEqual(result["checks"]["国内收货仓"]["actualCount"], 2) + + def test_missing_extra_and_duplicate_options_are_reported_without_values(self): + result = audit.inspect_template(fixture(), {"国内收货仓": ["测试仓|WH-A", "其他仓|WH-C"]}) + self.assertFalse(result["passed"]) + self.assertEqual(result["checks"]["国内收货仓"]["missingCount"], 1) + self.assertEqual(result["checks"]["国内收货仓"]["extraCount"], 1) + self.assertNotIn("WH-A", str(result)) + self.assertFalse(audit.compare_options(["A", "A"], ["A"])["passed"]) + + def test_refuses_short_ranges_unresolved_names_and_exposed_options(self): + for options in ({"rows": "A3:A51"}, {"target": "missing"}, {"hidden": False}): + with self.assertRaises(AssertionError): + audit.inspect_template(fixture(**options), {"国内收货仓": ["测试仓|WH-A", "测试仓|WH-B"]}) + + def test_duplicate_business_labels_remain_distinct_choices(self): + self.assertEqual(audit.labels(["A", "A", "A"], lambda x: x), ["A", "A(同名选项 2)", "A(同名选项 3)"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/client-import-template-test-env.mjs b/scripts/client-import-template-test-env.mjs new file mode 100644 index 000000000..ab2ef9620 --- /dev/null +++ b/scripts/client-import-template-test-env.mjs @@ -0,0 +1,56 @@ +import { spawn } from 'node:child_process' +import { readFile } from 'node:fs/promises' +import { parseEnv } from 'node:util' +import { resolve, dirname } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { executionOptions } from './client-workbench-test-env.mjs' + +export async function execute(args = process.argv.slice(2), execArgs = process.execArgv, env = process.env) { + if (!args.length || args.length === 1 && args[0] === '--plan') return { plan: true, businessWrites: false, + checks: ['full warehouse catalog', 'channel union', 'own customer addresses', 'named dropdown ranges', '50 input rows'], + required: 'Explicit TEST profile, new run-id, and --python=' } + if (![4, 5].includes(args.length) || !args[3].startsWith('--python=')) throw new Error('Explicit bundled Python required') + const options = executionOptions(args.slice(0, 3), execArgs, env) + const python = resolve(args[3].slice(9)) + if (!python.replaceAll('\\', '/').endsWith('/codex-primary-runtime/dependencies/python/python.exe')) throw new Error('Use bundled workspace Python') + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') + const config = { environment: 'TEST', base: options.base, username: env.CHONGCHONG_MERCHANT_USERNAME, + password: env.CHONGCHONG_MERCHANT_PASSWORD, runId: options.runId } + if (args[4]) { + const profile = customerProfilePath(args[4], execArgs[0].slice(11)) + const credentials = parseEnv(await readFile(profile, 'utf8')) + if (!credentials.MERCHANT_TEST_USERNAME || !credentials.MERCHANT_TEST_PASSWORD) throw new Error('Missing TEST customer credentials') + config.username = credentials.MERCHANT_TEST_USERNAME + config.password = credentials.MERCHANT_TEST_PASSWORD + } + const child = spawn(python, [resolve(root, 'scripts/client-import-template-inspect.py')], { cwd: root, windowsHide: true, stdio: ['pipe', 'inherit', 'inherit'] }) + child.stdin.end(JSON.stringify(config)) + const code = await new Promise((accept, reject) => { child.on('error', reject); child.on('close', accept) }) + return { success: code === 0, report: resolve(root, 'reports', `client-template-${options.runId}`, 'report.json') } +} +export function customerProfilePath(argument, environmentFile) { + if (!argument.startsWith('--customer-profile=')) throw new Error('Unknown argument') + const profile = resolve(argument.slice(19)) + if (dirname(profile) !== dirname(resolve(environmentFile)) || !/^test-ui-[A-Za-z0-9_-]+\.env$/.test(profile.split(/[\\/]/).at(-1))) + throw new Error('Customer profile must be an explicit TEST UI file beside the authorized TEST configuration') + return profile +} +export function compareCustomerTemplates(primary, control) { + const checks = { + bothRunsPassed: primary.success === true && control.success === true, + bothSessionsClosed: primary.sessionClosed === true && control.sessionClosed === true, + differentCustomers: typeof primary.customerIdentitySha256 === 'string' && typeof control.customerIdentitySha256 === 'string' + && primary.customerIdentitySha256 !== control.customerIdentitySha256, + primaryHasPrivateAddress: primary.template?.checks?.['私人目的仓地址']?.actualCount > 0, + controlHasNoPrivateAddress: control.template?.checks?.['私人目的仓地址']?.actualCount === 0, + privateOptionsMatchOwnDirectory: [primary, control].every(r => r.template?.checks?.['私人目的仓地址']?.passed === true), + pickupOptionsMatchOwnDirectory: [primary, control].every(r => r.template?.checks?.['取货地址']?.passed === true), + differentPickupAddressSamples: primary.template?.checks?.['取货地址']?.actualSha256 !== control.template?.checks?.['取货地址']?.actualSha256, + } + return { success: Object.values(checks).every(Boolean), checks, + limitation: 'Read-time customer isolation only; importing another customer address remains a separate negative test' } +} +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + execute().then(result => { console.log(JSON.stringify(result)); if (result.success === false) process.exitCode = 1 }) + .catch(() => { console.error('Template audit did not start. Check explicit TEST profile, runtime and new run-id.'); process.exitCode = 1 }) +} diff --git a/scripts/client-import-template-test-env.test.mjs b/scripts/client-import-template-test-env.test.mjs new file mode 100644 index 000000000..0d5de2e5d --- /dev/null +++ b/scripts/client-import-template-test-env.test.mjs @@ -0,0 +1,28 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { resolve } from 'node:path' +import { customerProfilePath, compareCustomerTemplates, execute } from './client-import-template-test-env.mjs' + +const directory = 'C:/Users/BY-OY/.codex/env/chongchong-global' +test('template plan performs no login and requires no runtime', async () => { + assert.equal((await execute(['--plan'], [], {})).plan, true) +}) +test('cross-customer read evidence rejects same identity, leaked options and incomplete cleanup', () => { + const report = (id, privateCount) => ({ success: true, sessionClosed: true, customerIdentitySha256: id, + template: { checks: { '私人目的仓地址': { actualCount: privateCount, passed: true }, '取货地址': { actualSha256: id, passed: true } } } }) + const primary = report('customer-A', 1), control = report('customer-B', 0) + assert.equal(compareCustomerTemplates(primary, control).success, true) + assert.equal(compareCustomerTemplates(primary, primary).success, false) + assert.equal(compareCustomerTemplates(primary, report('customer-B', 1)).success, false) + assert.equal(compareCustomerTemplates(primary, { ...control, sessionClosed: false }).success, false) + const failed = report('customer-B', 0); failed.template.checks['私人目的仓地址'].passed = false + assert.equal(compareCustomerTemplates(primary, failed).success, false) +}) +test('explicit existing TEST UI customer profile cannot select production or escape the authorized directory', () => { + const argument = `--customer-profile=${directory}/test-ui-fixture.env` + assert.equal(customerProfilePath(argument, directory + '/test-acceptance.env'), resolve(directory, 'test-ui-fixture.env')) + for (const value of ['prod-acceptance.env', '../test-ui-fixture.env', 'test-database.env', 'test-ui-fixture.env/another.env']) { + assert.throws(() => customerProfilePath(`--customer-profile=${directory}/${value}`, directory + '/test-acceptance.env')) + } + assert.throws(() => customerProfilePath('--unknown=file', directory + '/test-acceptance.env')) +}) diff --git a/scripts/client-test-env-acceptance-contract.test.mjs b/scripts/client-test-env-acceptance-contract.test.mjs index 576890d63..2b8daf794 100644 --- a/scripts/client-test-env-acceptance-contract.test.mjs +++ b/scripts/client-test-env-acceptance-contract.test.mjs @@ -1,6 +1,9 @@ import test from 'node:test' import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' +import vm from 'node:vm' +import { createHash } from 'node:crypto' +import { inflateRawSync, deflateRawSync } from 'node:zlib' const source = await readFile(new URL('./client-test-env-acceptance.mjs', import.meta.url), 'utf8') @@ -11,3 +14,493 @@ test('client registration distinguishes incomplete-registration replay from cred assert.match(source, /duplicate username with different credentials is rejected/) assert.match(source, /body: \{ \.\.\.registrationBody, password: `Different/) }) + +const helperSource = source.slice(source.indexOf('// BEGIN ORDER ACCEPTANCE HELPERS'), source.indexOf('// END ORDER ACCEPTANCE HELPERS')) +const workflowSource = source.slice(source.indexOf('async function ownedOrderSnapshot('), source.indexOf('async function apiStep(')) +function runtime(extra = {}) { + const context = vm.createContext({ Buffer, createHash, inflateRawSync, deflateRawSync, structuredClone, Blob, FormData, + assertEqual: (actual, expected, label) => assert.equal(actual, expected, label), + assertTrue: (value, label) => assert.ok(value, label), + assertPresent: (value, label) => assert.ok(value !== null && value !== undefined && value !== '', label), + requireSuccess: json => assert.equal(String(json?.code), '200'), ...extra }) + vm.runInContext(`${helperSource}\n${workflowSource}`, context) + return context +} +const plain = value => JSON.parse(JSON.stringify(value)) + +function priceBookSource(r) { + const channels=[{channelCode:'MATSON_CLX_TRUCK',sourceSheetName:'美森正班海卡'}, + {channelCode:'HEDE_TRUCK',sourceSheetName:'合德海卡'}] + const rates=channels.map(channel=>({channelCode:channel.channelCode,originWarehouseCode:'YIWU',originWarehouseId:'2', + destinationType:'FBA',destinationCode:'ONT8',unitPrice:10})) + const entries=new Map([ + ['xl/workbook.xml',''], + ['xl/_rels/workbook.xml.rels',''], + ['xl/worksheets/sheet1.xml',''], + ['xl/worksheets/sheet2.xml',''], + ['xl/media/image1.png',Buffer.from([0x89,0x50,0x4e,0x47,0xff,0x00,0x80])], + ]) + const bytes=r.orderZipWrite(entries,true) + const version={id:'100',customerId:'0',status:'ACTIVE',sourceSha256:createHash('sha256').update(bytes).digest('hex')} + return {bytes,entries,reference:{version,channels,rates}} +} + +test('private B removes target sheet only, preserves binary source parts and uses published mapping IDs',()=>{ + const r=runtime(),source=priceBookSource(r) + const b=r.channelGateWorkbook(source.bytes,source.reference,'MATSON_CLX_TRUCK'),entries=r.orderZipRead(b.bytes) + assert.deepEqual(plain(b.removedCodes),['MATSON_CLX_TRUCK']) + assert.doesNotMatch(entries.get('xl/workbook.xml'),/美森正班海卡/) + assert.match(entries.get('xl/workbook.xml'),/合德海卡/) + for(const [name,original] of source.entries)if(name!=='xl/workbook.xml')assert.deepEqual(entries.get(name),original) + assert.deepEqual(plain(r.channelGateMappings({rates:[source.reference.rates[1]]},source.reference)),{ + originMappings:[{originWarehouseCode:'YIWU',warehouseId:'2'}],overseasMappings:[], + }) + assert.throws(()=>r.channelGateWorkbook(source.bytes,{...source.reference,channels:[source.reference.channels[0]]},'MATSON_CLX_TRUCK'),/retain another/) + assert.throws(()=>r.channelGateMappings({rates:[source.reference.rates[1]]},{rates:[{...source.reference.rates[1],originWarehouseId:null}]}),/unique published/) + assert.throws(()=>r.channelGateMappings({rates:[source.reference.rates[1]]},{rates:[source.reference.rates[1],{...source.reference.rates[1],originWarehouseId:'9'}]}),/unique published/) +}) + +test('shared parcel source sheet removes all its channel codes and cannot leave an empty price book',()=>{ + const r=runtime(),source=priceBookSource(r) + source.reference.channels.push({channelCode:'SAME_SHEET_SECOND_CODE',sourceSheetName:'美森正班海卡'}) + assert.deepEqual(plain(r.channelGateWorkbook(source.bytes,source.reference,'MATSON_CLX_TRUCK').removedCodes),['MATSON_CLX_TRUCK','SAME_SHEET_SECOND_CODE']) +}) + +function channelGateHarness(fault='') { + const r=runtime(),source=priceBookSource(r),calls=[],books=new Map(),publicVersions=[structuredClone(source.reference.version)] + const report={resources:{tenantId:'20',orderIds:[]},steps:[{module:'registration',name:'enterprise self-registration',passed:true,evidence:{tenantId:'20'}}], + orderSubmissionGates:{balance:{restoration:'RESTORED_WITH_AUDIT'},remaining:['Channel-offline requires fixture','Other pending scope']}} + const order={order:{id:'42',tenantId:'20',orderNo:'FL42',status:'DRAFT',version:7,channelCode:'MATSON_CLX_TRUCK'}, + cargoes:[{skuNo:'SKU'}],parties:[],internalNotes:[],statusHistory:[]} + let nextId=201,nextKey=0,task=null + if(fault==='unowned-customer')report.steps[0].evidence.tenantId='21' + const base='/api/platform/v1/fba-pricing/carrier-rates/price-books' + Object.assign(r,{report,marker:'channel-fixture',merchantToken:'owner',platformToken:'platform',newIdempotencyKey:()=>`key-${++nextKey}`, + orderAcceptanceFixture:{masterData:{receiving:{id:'2'},channel:{priceBookVersionId:'100'}},payload:{channelCode:'MATSON_CLX_TRUCK',cargoes:[{skuNo:'SKU'}]}}, + apiStep:async(_module,name,request,validate)=>{ + calls.push({name,path:request.path,method:request.method,body:request.body,token:request.token,headers:request.headers}) + const url=new URL(request.path,'https://offline.invalid') + let data=null,bytes=null,code='200',message='success' + if(url.pathname.endsWith('/tenant/profile'))data={id:'20',status:'ACTIVE'} + else if(url.pathname===base && request.method!=='POST')data=url.searchParams.get('customerId')==='0'?structuredClone(publicVersions):[...books.values()].map(book=>structuredClone(book.version)) + else if(request.path===`${base}/100`)data=structuredClone(source.reference) + else if(request.path===`${base}/100/source`)bytes=source.bytes + else if(url.pathname===base && request.method==='POST') { + assert.equal(url.searchParams.get('customerId'),'20') + assert.equal(request.token,'platform') + const file=request.body.get('file'),raw=Buffer.from(await file.arrayBuffer()),entries=r.orderZipRead(raw) + const containsTarget=entries.get('xl/workbook.xml').includes('美森正班海卡') + const id=String(nextId++),channels=source.reference.channels.filter(channel=>containsTarget||channel.channelCode!=='MATSON_CLX_TRUCK') + data={version:{id,customerId:fault==='wrong-version-owner'?'0':'20',status:'PENDING_CONFIRMATION',sourceSha256:createHash('sha256').update(raw).digest('hex')}, + channels:structuredClone(channels),rates:structuredClone(source.reference.rates.filter(rate=>channels.some(channel=>channel.channelCode===rate.channelCode)))} + books.set(id,data) + } else if(url.pathname.startsWith(`${base}/`)) { + const id=url.pathname.slice(base.length+1).split('/')[0],book=books.get(id) + assert.ok(book,'only this run uploaded version may be read/published') + if(request.method==='POST') { + assert.ok(request.path.endsWith('/publish'));assert.equal(book.version.customerId,'20') + assert.deepEqual(plain(request.body),{originMappings:[{originWarehouseCode:'YIWU',warehouseId:'2'}],overseasMappings:[]}) + for(const previous of books.values())if(previous.version.status==='ACTIVE')previous.version.status='SUPERSEDED' + book.version.status='ACTIVE';book.version.blockingErrorCount=0 + if(fault==='public-drift')publicVersions[0].status='SUPERSEDED' + } + data=structuredClone(book) + } else if(url.pathname.endsWith('/pricing/channels')) { + const active=[...books.values()].find(book=>book.version.status==='ACTIVE') + let channels=active.channels + if(fault==='channel-retained'&&active.version.id==='202')channels=source.reference.channels + data=channels.map(channel=>({...channel,priceBookVersionId:active.version.id})) + } else if(url.pathname==='/api/merchant/v1/orders/first-leg'&&request.method==='POST')data='42' + else if(url.pathname==='/api/merchant/v1/orders/first-leg/42')data=structuredClone(order) + else if(url.pathname.includes('/tasks/by-order/'))data=structuredClone(task) + else if(url.pathname==='/api/platform/inbounds')data={page:1,total:0,records:[]} + else if(url.pathname.endsWith('/submit')) { + assert.equal(request.body.expectedVersion,7) + code='A0409';message=fault==='wrong-conflict'?'订单版本已变化,请刷新后重试':'所选渠道已下线,请重新选择' + if(fault==='order-drift')order.order.version++ + if(fault==='task-drift')task={id:'task',orderId:'42',tenantId:'20'} + } else throw new Error(`unhandled offline request ${request.path}`) + const response={status:200,json:{code,message,data},bytes};validate(response);return response + }, + }) + return {r,report,calls,books} +} + +test('channel gate publishes private A then B, proves client removal and rejects both original-version submits',async()=>{ + const {r,report,calls,books}=channelGateHarness() + await r.orderSubmissionChannelGateModule() + assert.equal(report.orderSubmissionGates.channel.status,'REJECTIONS_VERIFIED') + assert.equal(report.orderSubmissionGates.channel.publicVersionsUnchanged,true) + assert.deepEqual(report.orderSubmissionGates.remaining,['Other pending scope']) + assert.equal(books.get('201').version.status,'SUPERSEDED');assert.equal(books.get('202').version.status,'ACTIVE') + assert.equal(JSON.stringify(report.orderSubmissionGates.channel.versions.map(version=>[version.statusAtPublication,version.status])),JSON.stringify([['ACTIVE','SUPERSEDED'],['ACTIVE','ACTIVE']])) + assert.equal(calls.filter(call=>call.path.endsWith('/publish')).length,2) + assert.equal(calls.filter(call=>call.path.includes('/submit')).length,2) + assert.ok(calls.filter(call=>call.method==='POST').every(call=>!call.path.includes('customerId=0')&&!call.path.includes('/100/'))) +}) + +for(const fault of ['unowned-customer','wrong-version-owner','channel-retained','wrong-conflict','order-drift','task-drift','public-drift'])test(`channel gate fails closed: ${fault}`,async()=>{ + const {r,calls}=channelGateHarness(fault) + await assert.rejects(r.orderSubmissionChannelGateModule()) + if(['unowned-customer','wrong-version-owner'].includes(fault))assert.equal(calls.some(call=>call.path.endsWith('/publish')),false) +}) + +function balanceGateHarness(fault='') { + const calls=[],ledger=[],report={resources:{tenantId:'20',orderIds:[]},steps:[{module:'registration',name:'enterprise self-registration',passed:true,evidence:{tenantId:'20'}}]} + const wallet={currencyCode:'CNY',version:0,creditActive:false,actualBalance:0,availableBalance:0,frozenBalance:0, + creditLimit:0,creditUsed:0,creditFrozen:0,availableCredit:0} + const detail={order:{id:'42',tenantId:'20',customerId:'20',orderNo:'FL42',status:'DRAFT',version:3},cargoes:[{skuNo:'SKU'}],parties:[],internalNotes:[],statusHistory:[]} + let sequence=0,task=null + if(fault==='other-customer')report.steps[0].evidence.tenantId='21' + if(fault==='funded-account')wallet.actualBalance=1 + const r=runtime({report,marker:'isolated-fixture',merchantToken:'owner',platformToken:'platform',newIdempotencyKey:()=>`key-${++sequence}`, + orderAcceptanceFixture:{payload:{cargoes:[{skuNo:'SKU'}]}}, + apiStep:async(_module,name,request,validate)=>{ + calls.push({name,request:structuredClone(request)}) + let data=null,code='200',message='success' + if(request.path.endsWith('/tenant/profile'))data={id:'20',status:'ACTIVE'} + else if(request.path==='/api/client/finance/v1/account')data=structuredClone(wallet) + else if(request.path.includes('/account/ledger'))data={total:ledger.length,records:structuredClone(ledger)} + else if(request.path.endsWith('/adjustments')) { + assert.equal(request.path,'/api/platform/finance/v1/accounts/20/adjustments') + assert.equal(request.token,'platform');assert.ok(request.headers['Idempotency-Key']) + assert.equal(request.body.amount,0.01);assert.equal(request.body.expectedAccountVersion,wallet.version) + const deduct=request.body.type==='DEDUCTION' + if(deduct&&fault==='deduction-not-applied')throw new Error('not applied') + wallet.actualBalance+=deduct?-0.01:0.01;wallet.availableBalance=wallet.actualBalance;wallet.version++ + ledger.push({sourceType:deduct?'MANUAL_DEDUCTION':'MANUAL_RECHARGE',referenceNo:request.body.referenceNo,amount:deduct?-0.01:0.01}) + if(deduct&&fault==='lost-deduction-response')throw new Error('response lost') + data=ledger.at(-1) + } else if(request.method==='POST'&&request.path==='/api/merchant/v1/orders/first-leg')data='42' + else if(request.path.includes('/submit')) { + assert.equal(wallet.actualBalance,-0.01) + assert.equal(request.body.expectedVersion,3) + assert.ok(request.headers['Idempotency-Key']) + code='A0403';message=fault==='wrong-gate'?'企业未审核':'账户实际余额为负,请充值后再下单' + if(fault==='order-drift')detail.order.version++ + if(fault==='task-drift')task={orderId:'42',tenantId:'20',id:'unexpected'} + } else if(request.path.includes('/tasks/by-order/'))data=structuredClone(task) + else if(request.path.includes('/inbounds?'))data={page:1,total:0,records:[]} + else if(request.path==='/api/merchant/v1/orders/first-leg/42')data=structuredClone(detail) + else throw new Error(`unhandled offline request ${request.path}`) + const response={status:200,json:{code,message,data}};validate(response);return response + }, + }) + return {r,calls,report,wallet,ledger} +} + +test('negative actual funds fixture touches only the new registered customer and restores an audited cent',async()=>{ + const {r,calls,report,wallet,ledger}=balanceGateHarness() + await r.orderSubmissionBalanceGateModule() + assert.equal(wallet.actualBalance,0);assert.equal(wallet.version,2);assert.equal(ledger.length,2) + assert.equal(report.orderSubmissionGates.balance.status,'REJECTIONS_VERIFIED') + assert.equal(report.orderSubmissionGates.balance.restoration,'RESTORED_WITH_AUDIT') + assert.equal(calls.filter(call=>call.request.path.includes('/submit')).length,2) + assert.equal(calls.filter(call=>call.request.path.endsWith('/adjustments')).length,2) + assert.ok(calls.every(call=>!call.request.path.includes('channels')&&!call.request.path.includes('price-books'))) +}) + +for(const fault of ['other-customer','funded-account'])test(`financial fixture refuses unsafe scope: ${fault}`,async()=>{ + const {r,calls}=balanceGateHarness(fault) + await assert.rejects(r.orderSubmissionBalanceGateModule()) + assert.equal(calls.some(call=>call.request.method==='POST'),false) +}) +for(const fault of ['wrong-gate','order-drift','task-drift','lost-deduction-response'])test(`gate evidence fails and restores its proven deduction after ${fault}`,async()=>{ + const {r,wallet,ledger}=balanceGateHarness(fault) + await assert.rejects(r.orderSubmissionBalanceGateModule()) + assert.equal(wallet.actualBalance,0);assert.equal(ledger.length,2) +}) +test('unapplied deduction does not trigger a blind restoring credit',async()=>{ + const {r,wallet,ledger,calls}=balanceGateHarness('deduction-not-applied') + await assert.rejects(r.orderSubmissionBalanceGateModule(),/not applied/) + assert.equal(wallet.actualBalance,0);assert.equal(ledger.length,0) + assert.equal(calls.filter(call=>call.request.path.endsWith('/adjustments')).length,1) +}) + +test('frozen edit command preserves original version, key and nested payload through retries', () => { + const r = runtime(), snapshot = { order: { version: 7 } }, payload = { draft: { cargoes: [{ skuNo: 'ORIGINAL' }] } } + const command = r.orderMutationCommand(snapshot, payload, 'original-key') + snapshot.order.version = 99; payload.draft.cargoes[0].skuNo = 'CHANGED' + assert.deepEqual(plain(command), { headers: { 'Idempotency-Key': 'original-key' }, body: { expectedVersion: 7, draft: { cargoes: [{ skuNo: 'ORIGINAL' }] } } }) + assert.ok(Object.isFrozen(command.body.draft.cargoes[0])) + assert.throws(() => r.orderMutationCommand({ order: {} }, payload, 'key'), /version/) + assert.throws(() => r.orderMutationCommand(snapshot, payload, ''), /key/) +}) + +test('submit command carries only frozen expectedVersion with the same original replay key',()=>{ + const r=runtime(),detail={order:{version:4}} + const command=r.orderMutationCommand(detail,{},'submit-original') + detail.order.version=9 + assert.deepEqual(plain(command),{headers:{'Idempotency-Key':'submit-original'},body:{expectedVersion:4}}) + assert.match(source,/const submitCommand = orderMutationCommand\(submitBefore, \{\}, submitKey\)/) + assert.match(source,/submit replay keeps version and history unchanged/) + assert.match(source,/submit same key different version conflicts/) + assert.match(source,/submit new key cannot submit already submitted order/) + for(const name of ['pre-approval-submit','incomplete-submit','cancelled-submit','cancelled-submit-proof']) { + assert.match(source,new RegExp(`orderMutationCommand\\([^\\n]+(?:\\n[^\\n]+)?newIdempotencyKey\\('${name}'\\)`)) + } +}) + +test('zero-drift validator detects changes in every aggregate and preserves array ordering', () => { + const r = runtime(), before = { order: { version: 2 }, cargoes: [{ id: '1', amount: 2 }], parties: [], internalNotes: [], statusHistory: [{ id: 'h1' }, { id: 'h2' }] } + r.assertOrderUnchanged(before, structuredClone(before)) + for (const field of Object.keys(before)) { + const changed = structuredClone(before) + if (field === 'order') changed.order.version++ + else changed[field].push({ id: 'new' }) + assert.throws(() => r.assertOrderUnchanged(before, changed), /unchanged/) + } + assert.throws(() => r.assertOrderUnchanged(before, { ...before, statusHistory: [...before.statusHistory].reverse() }), /unchanged/) + r.assertOrderUnchanged({ b: 2, a: 1 }, { a: 1, b: 2 }) +}) + +test('partial import validates physical row, exact counts, failure details and identities', () => { + const r = runtime(), data = { mode: 'PARTIAL', batchNo: 'batch', sourceFileId: 'f', errorReportFileId: 'e', result: { + total: 2, succeeded: 1, failed: 1, items: [ + { index: 0, clientRowId: 'excel-row-3', success: false, orderId: null, errorCode: 'A0422', errorMessage: 'cargo required' }, + { index: 1, clientRowId: 'excel-row-4', success: true, orderId: '99', orderNo: 'FL99' }, + ], + } } + r.assertImportResult(data, 'PARTIAL', [false, true]) + for (const mutate of [ + value => value.result.succeeded++, value => value.result.items[0].clientRowId = 'excel-row-2', + value => value.result.items[0].orderId = 'unexpected', value => value.result.items[0].errorCode = 'S0500', + value => value.result.items[1].orderNo = '', value => value.errorReportFileId = null, + ]) { + const changed = structuredClone(data); mutate(changed) + assert.throws(() => r.assertImportResult(changed, 'PARTIAL', [false, true])) + } +}) + +test('PARTIAL wire response accepts only null or omitted failed-row identifiers', () => { + const r = runtime() + // Shape captured from r1: Jackson omits all null fields on both failed and successful rows. + const wire = JSON.parse('{"mode":"PARTIAL","batchNo":"OBI-contract","sourceFileId":"101","errorReportFileId":"102","result":{"total":2,"succeeded":1,"failed":1,"items":[{"index":0,"clientRowId":"excel-row-3","success":false,"errorCode":"A0422","errorMessage":"货物名称不能为空"},{"index":1,"clientRowId":"excel-row-4","success":true,"orderId":"2106016937331458049","orderNo":"FL2106016937331458049"}]}}') + r.assertImportResult(wire, 'PARTIAL', [false, true]) + for (const field of ['orderId', 'orderNo', 'inboundCode']) { + const explicitNull = structuredClone(wire); explicitNull.result.items[0][field] = null + r.assertImportResult(explicitNull, 'PARTIAL', [false, true]) + for (const value of ['2106016937331458049', 'FL-example', 'NYEXX-example', '', 0, false, NaN, [], {}]) { + const invalid = structuredClone(wire); invalid.result.items[0][field] = value + assert.throws(() => r.assertImportResult(invalid, 'PARTIAL', [false, true]), new RegExp(`no ${field}`)) + } + } + for (const field of ['orderId', 'orderNo']) { + const missingSuccess = structuredClone(wire); delete missingSuccess.result.items[1][field] + assert.throws(() => r.assertImportResult(missingSuccess, 'PARTIAL', [false, true]), /created order/) + } +}) + +function templateFixture(r) { + return new Map([ + ['xl/workbook.xml', ''], + ['xl/sharedStrings.xml', '运输方式 *客户备注仓库|CODE'], + ['xl/worksheets/sheet1.xml', '订单基础01old data'], + ['xl/worksheets/sheet2.xml', '2'], + ]) +} + +test('XLSX mutation preserves downloaded headers/options and writes 31 columns past Z', () => { + const r = runtime(), template = templateFixture(r), row = Array.from({ length: 31 }, (_, i) => `column-${i}`) + row[30] = '备注<&"\'中文' + const bytes = r.orderWorkbookRows(template, [row]), parsed = r.orderZipRead(bytes), rows = r.orderSheetRows(parsed) + assert.equal(parsed.get('xl/worksheets/sheet2.xml'), template.get('xl/worksheets/sheet2.xml')) + assert.match(parsed.get('xl/worksheets/sheet1.xml'), /dataValidations/) + assert.deepEqual(plain(rows.find(item => item.number === 2).cells.map(cell => cell.text)), ['运输方式 *', '', '客户备注']) + assert.equal(rows[2].cells[30].ref, 'AE'); assert.equal(rows[2].cells[30].text, row[30]) + assert.doesNotMatch(parsed.get('xl/worksheets/sheet1.xml'), /old data/) + assert.deepEqual(plain(r.orderSheetRows(parsed, 'xl/worksheets/sheet2.xml')[0].cells), [{ ref: 'A', text: '仓库|CODE' }]) + const corrupted = Buffer.from(bytes); corrupted[80] ^= 1 + assert.throws(() => r.orderZipRead(corrupted)) + assert.throws(() => r.orderZipRead(Buffer.from('not zip'))) +}) + +test('ZIP reader accepts independently compressed entries and rejects damaged checksums', () => { + const r = runtime(), data = Buffer.from('中文'), name = Buffer.from('xl/worksheets/sheet1.xml') + const compressed = deflateRawSync(data) + let checksum = 0xffffffff + for (const byte of data) { checksum ^= byte; for (let i = 0; i < 8; i++) checksum = checksum & 1 ? 0xedb88320 ^ (checksum >>> 1) : checksum >>> 1 } + checksum = (checksum ^ 0xffffffff) >>> 0 + const local = Buffer.alloc(30); local.writeUInt32LE(0x04034b50); local.writeUInt16LE(8, 8); local.writeUInt16LE(name.length, 26) + const central = Buffer.alloc(46); central.writeUInt32LE(0x02014b50); central.writeUInt16LE(8, 10) + central.writeUInt32LE(checksum, 16); central.writeUInt32LE(compressed.length, 20); central.writeUInt32LE(data.length, 24); central.writeUInt16LE(name.length, 28) + const end = Buffer.alloc(22); end.writeUInt32LE(0x06054b50); end.writeUInt16LE(1, 10); end.writeUInt32LE(local.length + name.length + compressed.length, 16) + const archive = Buffer.concat([local, name, compressed, central, name, end]) + assert.equal(r.orderZipRead(archive).get(name.toString()), data.toString()) + central.writeUInt32LE((checksum ^ 1) >>> 0, 16) + assert.throws(() => r.orderZipRead(Buffer.concat([local, name, compressed, central, name, end])), /checksum/) +}) + +test('rollback plan changes only row 3 validity and row 4 hash without moving physical rows', () => { + const r = runtime(), first = Array.from({ length: 31 }, (_, index) => `first-${index}`), second = Array.from({ length: 31 }, (_, index) => `second-${index}`) + const plan = r.importRollbackRows(first, second) + assert.equal(plan.seed[0][10], ''); assert.deepEqual(plain(plan.seed[1]), second) + assert.deepEqual(plain(plan.conflict[0]), first) + assert.deepEqual(plain(plan.conflict[1].slice(0, 30)), second.slice(0, 30)) + assert.notEqual(plan.conflict[1][30], second[30]); assert.deepEqual(plain(plan.retry), [first, second]) + const rows = r.orderSheetRows(r.orderZipRead(r.orderWorkbookRows(templateFixture(r), plan.conflict))) + assert.deepEqual(plain(rows.slice(2).map(row => row.number)), [3, 4]) +}) + +test('actual Java path derives row keys without mode and joins both writes to the atomic transaction', async () => { + const service = await readFile(new URL('../order/src/main/java/com/baiyue/order/service/MerchantBatchDraftService.java', import.meta.url), 'utf8') + const item = await readFile(new URL('../order/src/main/java/com/baiyue/order/service/MerchantBatchDraftItemService.java', import.meta.url), 'utf8') + const orders = await readFile(new URL('../order/src/main/java/com/baiyue/order/service/OrderApplicationService.java', import.meta.url), 'utf8') + assert.match(service, /batchKey\.trim\(\) \+ "\\n" \+ rowId/) + assert.match(service, /@Transactional\s+public MerchantBatchDraftDtos.Response createAtomic/) + assert.match(service, /itemService.createJoined\(tenantId, userId, username, item.draft\(\),\s*derivedKey\(batchKey, item.clientRowId\(\)\)\)/) + assert.match(item, /@Transactional\(propagation = Propagation.MANDATORY\)/) + assert.match(orders, /requestHash\("CREATE_DRAFT", request\)/) +}) + +function mutationHarness(fault = '') { + let nextId = 100, nextKey = 0, nextHistory = 0 + const draft = { customerRemark: 'original', cargoes: [{ skuNo: 'SKU', packageCount: 2, model: 'M', productImageFileId: 'img' }] } + const make = (id, status, body) => ({ order: { id, orderNo: `FL${id}`, inboundCode: null, tenantId: 'tenant', customerId: 'customer', version: 0, status, customerRemark: body.customerRemark }, + cargoes: structuredClone(body.cargoes), parties: [], internalNotes: [], statusHistory: [] }) + const orders = new Map([['submitted', make('submitted', 'SUBMITTED', draft)]]), commands = new Map(), requests = [], report = { resources: { orderIds: [] } } + const r = runtime({ merchantToken: 'owner', existingClientToken: 'other', marker: 'local-contract', report, + orderAcceptanceFixture: { payload: draft, orderId: 'submitted' }, newIdempotencyKey: () => `key-${++nextKey}`, + apiStep: async (module, name, request, validate) => { + requests.push({ name, request: structuredClone(request) }) + const id = request.path.split('/')[6], current = orders.get(id) + let code = '200', data = null + if (!request.method || request.method === 'GET') data = structuredClone(current) + else if (request.path.endsWith('/first-leg')) { + data = String(nextId++); orders.set(data, make(data, 'DRAFT', request.body)) + } else if (request.token === 'other') code = 'A0404' + else if (request.path.endsWith('/submit')) code = 'A0409' + else { + const key = request.headers?.['Idempotency-Key'], payload = request.body + const signature = JSON.stringify(payload), stored = commands.get(key) + if (!key) code = 'A0400' + else if (payload.expectedVersion == null || (request.method === 'POST' && !payload.reason?.trim()) || payload.draft?.cargoes[0].productImageFileId === -1) code = 'A0422' + else if (stored) { + code = stored === signature ? '200' : 'A0409' + if (fault === 'replay-write' && code === '200') current.order.version++ + } else if (payload.expectedVersion !== current.order.version || current.order.status !== 'DRAFT') code = 'A0409' + else { + commands.set(key, signature); current.order.version++ + const cancelled = request.method === 'POST' + if (cancelled) current.order.status = 'CANCELLED' + else { current.order.customerRemark = payload.draft.customerRemark; current.cargoes = structuredClone(payload.draft.cargoes) } + current.statusHistory.push({ id: `h${++nextHistory}`, action: cancelled ? 'CANCELLED' : 'UPDATE', fromStatus: 'DRAFT', toStatus: current.order.status, + reason: payload.reason ?? null, operatorId: 'owner', occurredAt: '2026-10-02T12:00:00' }) + } + } + if (fault === 'rejection-drift' && code !== '200' && current) current.parties.push({ id: 'unexpected' }) + const response = { status: 200, json: { code, data } }; validate(response); return response + }, + }) + return { r, requests, report, orders } +} + +test('F06 actual orchestration covers frozen success replay, stale versions, missing protocol and isolation', async () => { + const { r, requests, report } = mutationHarness() + await r.orderMutationEvidenceModule() + const firstEdit = requests.find(item => item.name === 'versioned draft edit succeeds').request + const editReplay = requests.find(item => item.name === 'original edit key and frozen payload replay successfully').request + assert.deepEqual(editReplay, firstEdit) + const firstCancel = requests.find(item => item.name === 'cancel owned draft with reason').request + const cancelReplay = requests.find(item => item.name === 'original cancellation key payload and version replay successfully').request + assert.deepEqual(cancelReplay, firstCancel) + const newCancel = requests.find(item => item.name === 'repeat cancellation cannot add history').request + assert.notEqual(newCancel.headers['Idempotency-Key'], firstCancel.headers['Idempotency-Key']) + assert.ok(newCancel.body.expectedVersion > firstCancel.body.expectedVersion) + assert.ok(report.orderMutationRequests.every(item => !('token' in item))) + assert.ok(requests.some(item => item.name === 'edit missing version is rejected')) + assert.ok(requests.some(item => item.name === 'cancel missing key is rejected')) +}) + +for (const fault of ['replay-write', 'rejection-drift']) test(`F06 refuses false success when ${fault}`, async () => { + const { r } = mutationHarness(fault) + await assert.rejects(() => r.orderMutationEvidenceModule(), /unchanged/) +}) + +test('full order collection detects leaked rows, duplicated pages and truncated results', async () => { + for (const records of [[{ orderId: 'expected' }, { orderId: 'leaked' }], [{ orderId: 'expected' }, { orderId: 'expected' }]]) { + const r = runtime({ merchantToken: 'owner', apiStep: async (_module, _name, _request, validate) => { + validate({ json: { code: '200', data: { page: 1, total: 2, records } } }) + } }) + await assert.rejects(() => r.importOrderSet('rollback evidence', ['expected'])) + } + const r = runtime({ merchantToken: 'owner', apiStep: async (_module, _name, _request, validate) => { + validate({ json: { code: '200', data: { page: 1, total: 201, records: [{ orderId: 'only-one' }] } } }) + } }) + await assert.rejects(() => r.importOrderSet('complete pagination'), /intermediate/) +}) + +function importHarness(fault = '') { + const report = { resources: { orderIds: [], tenantId: 'tenant' } }, orders = new Map(), requests = [] + const masterData = { receiving: { id: 'domestic', warehouseName: '国内仓', warehouseCode: 'CN' }, + destination: { id: 'fba', warehouseName: '目的仓', warehouseCode: 'US' }, channel: { channelName: '海运渠道', channelCode: 'SEA' } } + let nextKey = 0, r + const resultItem = (index, success, orderId) => Object.fromEntries(Object.entries({ index, clientRowId: `excel-row-${index + 3}`, success, + orderId: success ? orderId : null, orderNo: success ? `FL${orderId}` : null, + errorCode: success ? null : 'A0422', errorMessage: success ? null : 'cargo required' }).filter(([, value]) => value !== null)) + const responseData = (mode, items) => ({ mode, batchNo: 'batch', sourceFileId: 'source-file', errorReportFileId: 'error-file', + result: { total: items.length, succeeded: items.filter(item => item.success).length, failed: items.filter(item => !item.success).length, items } }) + const addDraft = (id, cells) => orders.set(id, { order: { id, orderNo: `FL${id}`, version: 0, status: 'DRAFT', tenantId: 'tenant', + customerRemark: cells[30], referenceId: cells[27], receivingWarehouseId: 'domestic', destinationWarehouseId: 'fba', channelCode: 'SEA' }, + cargoes: [{ skuNo: cells[18], packageCount: 2 }], parties: [], internalNotes: [], statusHistory: [] }) + const cells = (values, row) => values.map((value, index) => `${r.orderXmlEncode(value)}`).join('') + r = runtime({ report, marker: 'contract', merchantToken: 'owner', orderAcceptanceFixture: { masterData, payload: { pricingTaxMode: 'TAX_INCLUDED' } }, + newIdempotencyKey: () => `key-${++nextKey}`, + apiStep: async (_module, name, request, validate) => { + requests.push({ name, path: request.path, key: request.headers?.['Idempotency-Key'] }) + let code = '200', data = null, bytes = null + if (request.path.endsWith('/template')) { + const template = templateFixture(r) + const headers = Array.from({ length: 31 }, (_, index) => index === 0 ? '运输方式 *' : index === 30 ? '客户备注' : `字段${index}`) + template.set('xl/worksheets/sheet1.xml', `${cells(['订单基础'], 1)}${cells(headers, 2)}`) + template.set('xl/worksheets/sheet2.xml', `${cells(['国内仓|CN', '目的仓|US', '海运渠道|SEA'], 1)}`) + bytes = r.orderZipWrite(template) + } else if (request.path.includes('/error-reports/')) { + const physicalRow = fault === 'wrong-error-row' ? '4.0' : '3.0' + bytes = r.orderZipWrite(new Map([['xl/worksheets/sheet1.xml', `${cells(['Excel行号', '错误代码', '错误说明'], 1)}${cells(['3', 'A0422', 'cargo required'], 2).replace(//, `${physicalRow}`)}`]])) + } else if (request.method === 'POST') { + const file = request.body.get('file'), body = Buffer.from(await file.arrayBuffer()) + if (name.startsWith('PARTIAL')) { + const parsed = r.orderSheetRows(r.orderZipRead(body)).slice(2).map(row => row.cells.map(cell => cell.text)) + if (!orders.has('second')) addDraft('second', parsed[1]) + data = responseData('PARTIAL', [resultItem(0, false), resultItem(1, true, 'second')]) + } else if (name.startsWith('ATOMIC retry')) { + const parsed = r.orderSheetRows(r.orderZipRead(body)).slice(2).map(row => row.cells.map(cell => cell.text)) + addDraft('first', parsed[0]); data = responseData('ATOMIC', [resultItem(0, true, 'first'), resultItem(1, true, 'second')]) + } else if (name.startsWith('ATOMIC later')) { + code = 'A0409' + if (fault === 'leaked-first-row') { + const parsed = r.orderSheetRows(r.orderZipRead(body)).slice(2).map(row => row.cells.map(cell => cell.text)) + addDraft('leaked-first', parsed[0]) + } + if (fault === 'changed-existing-row') orders.get('second').order.customerRemark = 'incorrectly-overwritten' + } else code = name === 'reject over five MiB' ? 'A0400' : 'A0422' + } else if (request.path.includes('?page=')) { + data = { page: 1, total: orders.size, records: [...orders.keys()].map(orderId => ({ orderId })) } + } else data = structuredClone(orders.get(request.path.split('/').at(-1))) + const response = { status: 200, headers: new Map([['content-type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']]), bytes, json: { code, data, message: code === 'A0400' ? '上传文件超过服务允许大小' : undefined } } + validate(response); return response + }, + }) + return { r, report, requests } +} + +test('F04 runs actual import orchestration with the same seed/conflict/retry key and records limitations', async () => { + const { r, report, requests } = importHarness() + await r.orderExcelImportModule() + const keys = requests.filter(item => item.key && /PARTIAL preserves|PARTIAL same key|ATOMIC later|ATOMIC retry/.test(item.name)).map(item => item.key) + assert.equal(keys.length, 4); assert.equal(new Set(keys).size, 1) + assert.deepEqual(report.resources.orderIds, ['second', 'first']) + assert.ok(report.orderImportLimitations.some(item => item.includes('remote source uploads'))) + assert.ok(requests.some(item => item.name === 'forged option adds no orders: complete order page 1')) +}) + +for (const fault of ['leaked-first-row', 'changed-existing-row']) test(`F04 rejects apparent ATOMIC failure with ${fault}`, async () => { + const { r } = importHarness(fault) + await assert.rejects(() => r.orderExcelImportModule(), /unchanged/) +}) + +test('F04 still rejects a numeric error row pointing to the successful row', async () => { + const { r } = importHarness('wrong-error-row') + await assert.rejects(() => r.orderExcelImportModule(), /actual Excel error row/) +}) diff --git a/scripts/client-test-env-acceptance.mjs b/scripts/client-test-env-acceptance.mjs index ad6943a31..9a30f9b82 100644 --- a/scripts/client-test-env-acceptance.mjs +++ b/scripts/client-test-env-acceptance.mjs @@ -13,11 +13,13 @@ */ import { mkdir, writeFile } from 'node:fs/promises' -import { randomUUID } from 'node:crypto' +import { randomUUID, createHash } from 'node:crypto' import { resolve } from 'node:path' import { createInterface } from 'node:readline/promises' +import { inflateRawSync, deflateRawSync } from 'node:zlib' const args = parseArgs(process.argv.slice(2)) +if ((args.environment && args.environment !== 'test') || args['overseas-gateway'] || process.env.ADMIN_OVERSEAS_GATEWAY) throw new Error('Only one TEST gateway is supported; remove regional options') const gateway = trimSlash(args.gateway ?? process.env.ADMIN_TEST_GATEWAY) const platformUsername = args['platform-username'] ?? process.env.ADMIN_TEST_USERNAME ?? await promptText('平台审核账号', '') @@ -83,11 +85,13 @@ let merchantToken let merchantRefreshToken let platformToken let existingClientToken +let orderAcceptanceFixture let ownerPasswordNeedsRestore = false let temporaryOwnerPassword try { await registrationModule() + await platformReviewerLogin() await merchantBeforeApprovalModule() await qualificationAndReviewModule() await merchantAfterApprovalModule() @@ -96,6 +100,10 @@ try { await rbacAndMemberModule() await orderLifecycleModule() await isolationModule() + await orderSubmissionBalanceGateModule() + await orderMutationEvidenceModule() + await orderExcelImportModule() + await orderSubmissionChannelGateModule() await notificationInboxModule() await accountSecurityModule() report.success = report.steps.every((step) => step.passed) @@ -214,13 +222,29 @@ async function merchantBeforeApprovalModule() { assertEqual(String(json.data), String(orderId), 'replayed order id') }, ({ json }) => ({ orderId: json.data, replayed: true })) + const qualificationBefore = await ownedOrderSnapshot('order-before-approval', 'freeze pre-approval submit version', orderId) + const qualificationFulfillmentBefore = await submissionGateFulfillmentSnapshot(qualificationBefore.order, 'before qualification rejection') await apiStep('order-before-approval', 'submit is blocked before approval', { method: 'POST', path: `/api/merchant/v1/orders/first-leg/${orderId}/submit`, token: merchantToken, - headers: { 'Idempotency-Key': newIdempotencyKey('pre-approval-submit') }, + ...orderMutationCommand(qualificationBefore, {}, newIdempotencyKey('pre-approval-submit')), }, ({ json, status }) => { assertEqual(status, 200, 'HTTP status') assertEqual(String(json?.code), 'A0403', 'business code') }, ({ json }) => ({ orderId, rejectionCode: json.code })) + await ownedOrderSnapshot('order-before-approval', 'qualification rejection preserves complete order facts', orderId, qualificationBefore) + assertOrderUnchanged(qualificationFulfillmentBefore, + await submissionGateFulfillmentSnapshot(qualificationBefore.order, 'after qualification rejection')) + report.qualificationRejectionEvidence = { orderId, orderSha256: orderDigest(qualificationBefore), fulfillmentSha256: orderDigest(qualificationFulfillmentBefore) } +} + +async function platformReviewerLogin() { + const platformLogin = await apiStep('qualification-review', 'platform reviewer login', { + method: 'POST', path: '/api/auth/login', body: { loginId: platformUsername, password: platformPassword }, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.accessToken, 'platform accessToken') + }, () => ({ username: platformUsername })) + platformToken = platformLogin.json.data.accessToken } async function qualificationAndReviewModule() { @@ -300,14 +324,6 @@ async function qualificationAndReviewModule() { assertEqual(json.data?.status, 'PENDING', 'tenant status') }, ({ json }) => ({ tenantId: json.data.id, status: json.data.status })) - const platformLogin = await apiStep('qualification-review', 'platform reviewer login', { - method: 'POST', path: '/api/auth/login', body: { loginId: platformUsername, password: platformPassword }, - }, ({ json }) => { - requireSuccess(json) - assertPresent(json.data?.accessToken, 'platform accessToken') - }, () => ({ username: platformUsername })) - platformToken = platformLogin.json.data.accessToken - const dimensionPath = `/api/platform/v1/iam/tenants/${report.resources.tenantId}/dimension-initialization` for (let attempt = 0; attempt < 30; attempt += 1) { const statusResponse = await httpRequest({ method: 'GET', path: dimensionPath, token: platformToken }) @@ -1113,7 +1129,7 @@ async function orderLifecycleModule() { report.resources.orderIds.push(draft.json.data) await softApiStep('order-validation', `${testCase.name} is rejected on submit`, { method: 'POST', path: `/api/merchant/v1/orders/first-leg/${draft.json.data}/submit`, token: merchantToken, - headers: { 'Idempotency-Key': newIdempotencyKey('incomplete-submit') }, + ...orderMutationCommand(await ownedOrderSnapshot('order-validation', 'freeze incomplete submit version', draft.json.data), {}, newIdempotencyKey('incomplete-submit')), }, ({ json, status }) => { assertEqual(status, 200, 'HTTP status') assertEqual(String(json?.code), 'A0422', 'business code') @@ -1210,8 +1226,11 @@ async function orderLifecycleModule() { editedPayload.customerRemark = `edited-${marker}` editedPayload.cargoes[0].cargoRemark = `edited-cargo-${marker}` editedPayload.cargoes[0].model = `MODEL-EDIT-${marker}`.slice(0, 64) + const editBefore = await ownedOrderSnapshot('order-lifecycle', 'freeze original draft edit version', orderId) + const editCommand = orderMutationCommand(editBefore, { draft: editedPayload }, newIdempotencyKey('edit-draft')) + recordOrderMutationRequest('initial lifecycle edit', { method: 'PUT', path: `/api/merchant/v1/orders/first-leg/${orderId}`, ...editCommand }, editBefore) await apiStep('order-lifecycle', 'edit draft', { - method: 'PUT', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: merchantToken, body: editedPayload, + method: 'PUT', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: merchantToken, ...editCommand, }, ({ json }) => requireSuccess(json), () => ({ orderId, customerRemark: editedPayload.customerRemark })) const detail = await apiStep('order-lifecycle', 'order detail preserves complete product fields', { @@ -1230,6 +1249,7 @@ async function orderLifecycleModule() { cargo: cargoEvidence(json.data.cargoes[0]), })) const orderNo = detail.json.data.order.orderNo + orderAcceptanceFixture = { masterData, payload: editedPayload, orderId } await apiStep('order-lifecycle', 'order list contains edited draft', { method: 'GET', path: `/api/merchant/v1/orders/first-leg?page=1&size=50&orderNo=${encodeURIComponent(orderNo)}`, token: merchantToken, @@ -1260,15 +1280,29 @@ async function orderLifecycleModule() { }, ({ json }) => ({ total: json.data?.total, orderId, status: 'DRAFT' })) const submitKey = newIdempotencyKey('approved-submit') + const submitBefore = await ownedOrderSnapshot('order-lifecycle', 'freeze submission page version', orderId) + const submitCommand = orderMutationCommand(submitBefore, {}, submitKey) + recordOrderMutationRequest('initial lifecycle submit', {method:'POST',path:`/api/merchant/v1/orders/first-leg/${orderId}/submit`,...submitCommand}, submitBefore) await apiStep('order-lifecycle', 'submit order', { method: 'POST', path: `/api/merchant/v1/orders/first-leg/${orderId}/submit`, token: merchantToken, - headers: { 'Idempotency-Key': submitKey }, + ...submitCommand, }, ({ json }) => requireSuccess(json), () => ({ orderId })) + const submittedBeforeReplay = await ownedOrderSnapshot('order-lifecycle', 'freeze successful submission facts', orderId) await apiStep('order-lifecycle', 'submit order idempotent replay', { method: 'POST', path: `/api/merchant/v1/orders/first-leg/${orderId}/submit`, token: merchantToken, - headers: { 'Idempotency-Key': submitKey }, + ...submitCommand, }, ({ json }) => requireSuccess(json), () => ({ orderId, replayed: true })) + await ownedOrderSnapshot('order-lifecycle', 'submit replay keeps version and history unchanged', orderId, submittedBeforeReplay) + const submitPath = `/api/merchant/v1/orders/first-leg/${orderId}/submit` + await rejectOrderMutation('submit same key different version conflicts', orderId, { + method: 'POST', path: submitPath, token: merchantToken, headers: submitCommand.headers, + body: { expectedVersion: submittedBeforeReplay.order.version }, + }, ['A0409']) + await rejectOrderMutation('submit new key cannot submit already submitted order', orderId, { + method: 'POST', path: submitPath, token: merchantToken, + ...orderMutationCommand(submittedBeforeReplay, {}, newIdempotencyKey('new-key-submit')), + }, ['A0409']) await apiStep('order-lifecycle', 'submitted status is persisted', { method: 'GET', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: merchantToken, @@ -1320,7 +1354,9 @@ async function orderLifecycleModule() { }, ({ json }) => ({ orderId: 1, rejectionCode: json?.code })) await softApiStep('order-boundary', 'submitted order cannot be edited by client', { - method: 'PUT', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: merchantToken, body: editedPayload, + method: 'PUT', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: merchantToken, + ...orderMutationCommand(await ownedOrderSnapshot('order-boundary', 'freeze submitted edit attempt', orderId), + { draft: editedPayload }, newIdempotencyKey('submitted-edit')), }, ({ json, status }) => { assertEqual(status, 200, 'HTTP status') assertEqual(String(json?.code), 'A0409', 'business code') @@ -1355,10 +1391,13 @@ async function orderLifecycleModule() { }, ({ json }) => ({ orderId: json.data })) const cancelOrderId = cancelDraft.json.data report.resources.orderIds.push(cancelOrderId) + const cancelBefore = await ownedOrderSnapshot('order-lifecycle', 'freeze cancellation version', cancelOrderId) + const cancelCommand = orderMutationCommand(cancelBefore, { reason: `客户端自动化取消 ${marker}` }, newIdempotencyKey('cancel-draft')) + recordOrderMutationRequest('initial lifecycle cancellation', { method: 'POST', path: `/api/merchant/v1/orders/first-leg/${cancelOrderId}/cancel`, ...cancelCommand }, cancelBefore) await apiStep('order-lifecycle', 'cancel second draft', { method: 'POST', path: `/api/merchant/v1/orders/first-leg/${cancelOrderId}/cancel`, token: merchantToken, - body: { reason: `客户端自动化取消 ${marker}` }, + ...cancelCommand, }, ({ json }) => requireSuccess(json), () => ({ orderId: cancelOrderId })) await apiStep('order-lifecycle', 'cancelled status is persisted', { @@ -1370,14 +1409,15 @@ async function orderLifecycleModule() { await softApiStep('order-boundary', 'cancelled order cannot be cancelled again', { method: 'POST', path: `/api/merchant/v1/orders/first-leg/${cancelOrderId}/cancel`, token: merchantToken, - body: { reason: `重复取消 ${marker}` }, + ...orderMutationCommand(await ownedOrderSnapshot('order-boundary', 'freeze repeat cancellation attempt', cancelOrderId), + { reason: `重复取消 ${marker}` }, newIdempotencyKey('repeat-cancel')), }, ({ json, status }) => { assertEqual(status, 200, 'HTTP status') assertEqual(String(json?.code), 'A0409', 'business code') }, ({ json }) => ({ orderId: cancelOrderId, rejectionCode: json.code, message: json.message })) await softApiStep('order-boundary', 'cancelled order cannot be submitted', { method: 'POST', path: `/api/merchant/v1/orders/first-leg/${cancelOrderId}/submit`, token: merchantToken, - headers: { 'Idempotency-Key': newIdempotencyKey('cancelled-submit') }, + ...orderMutationCommand(await ownedOrderSnapshot('order-boundary', 'freeze cancelled submit version', cancelOrderId), {}, newIdempotencyKey('cancelled-submit')), }, ({ json, status }) => { assertEqual(status, 200, 'HTTP status') assertEqual(String(json?.code), 'A0409', 'business code') @@ -1991,6 +2031,775 @@ function buildOrderPayload(masterData, productImageFileId, suffix, remark) { } } +// BEGIN ORDER ACCEPTANCE HELPERS — loaded without CLI side effects by the direct contract. +function orderCanonical(value) { + if (Array.isArray(value)) return value.map(orderCanonical) + if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, orderCanonical(value[key])])) + return value +} + +function orderDigest(value) { return createHash('sha256').update(JSON.stringify(orderCanonical(value))).digest('hex') } +function assertOrderUnchanged(before, after) { + assertEqual(orderDigest(after), orderDigest(before), 'order, cargo, parties, notes and history must remain unchanged') +} +function assertOrderDetail(data, id) { + assertEqual(String(data?.order?.id), String(id), 'owned order identity') + for (const field of ['cargoes', 'parties', 'internalNotes', 'statusHistory']) assertTrue(Array.isArray(data?.[field]), `complete ${field} snapshot required`) + assertPresent(data.order.version, 'order version') +} +function orderMutationCommand(snapshot, payload, key) { + assertTrue(Number.isInteger(snapshot?.order?.version) && snapshot.order.version >= 0, 'frozen order version required') + assertTrue(typeof key === 'string' && key.length > 0, 'frozen idempotency key required') + const command = structuredClone({ headers: { 'Idempotency-Key': key }, body: { ...payload, expectedVersion: snapshot.order.version } }) + const freeze = value => { if (value && typeof value === 'object') { Object.values(value).forEach(freeze); Object.freeze(value) } } + freeze(command) + return command +} +function assertImportResult(data, mode, expected) { + assertEqual(data?.mode, mode, 'import mode') + assertPresent(data?.batchNo, 'batch number') + assertPresent(data?.sourceFileId, 'source file') + const result = data?.result + assertEqual(result?.total, expected.length, 'import total') + assertEqual(result?.succeeded, expected.filter(Boolean).length, 'import succeeded') + assertEqual(result?.failed, expected.filter(value => !value).length, 'import failed') + assertEqual(result?.items?.length, expected.length, 'import item count') + result.items.forEach((item, index) => { + assertEqual(item.index, index, 'result index') + assertEqual(item.clientRowId, `excel-row-${index + 3}`, 'physical Excel row') + assertEqual(item.success, expected[index], 'row success') + if (item.success) { assertPresent(item.orderId, 'created order ID'); assertPresent(item.orderNo, 'created order number') } + else { + for (const field of ['orderId', 'orderNo', 'inboundCode']) { + assertTrue(item[field] === null || item[field] === undefined, `failed row has no ${field}`) + } + assertEqual(item.errorCode, 'A0422', 'invalid row code') + assertPresent(item.errorMessage, 'row error reason') + } + }) + if (result.failed) assertPresent(data.errorReportFileId, 'downloadable error report') +} + +// Read the downloaded POI template, preserving its headers, options and validations. +// ZIP64/encrypted archives are deliberately rejected; template fixtures are small XLSX files. +function orderZipRead(bytes) { + const input = Buffer.from(bytes) + let end = input.length - 22 + while (end >= Math.max(0, input.length - 65557) && input.readUInt32LE(end) !== 0x06054b50) end-- + assertTrue(end >= 0 && input.readUInt32LE(end) === 0x06054b50, 'valid XLSX ZIP directory') + const entries = new Map() + let offset = input.readUInt32LE(end + 16) + for (let index = 0; index < input.readUInt16LE(end + 10); index++) { + assertEqual(input.readUInt32LE(offset), 0x02014b50, 'ZIP entry signature') + const method = input.readUInt16LE(offset + 10), size = input.readUInt32LE(offset + 20) + const length = input.readUInt32LE(offset + 24), nameLength = input.readUInt16LE(offset + 28) + const name = input.subarray(offset + 46, offset + 46 + nameLength).toString('utf8') + const local = input.readUInt32LE(offset + 42) + assertTrue(!(input.readUInt16LE(offset + 8) & 1) && [0, 8].includes(method) && length <= 16 * 1024 * 1024, 'supported bounded ZIP entry') + assertEqual(input.readUInt32LE(local), 0x04034b50, 'ZIP local signature') + const start = local + 30 + input.readUInt16LE(local + 26) + input.readUInt16LE(local + 28) + const packed = input.subarray(start, start + size) + const content = method === 8 ? inflateRawSync(packed, { maxOutputLength: 16 * 1024 * 1024 }) : packed + assertEqual(content.length, length, 'ZIP uncompressed size') + assertEqual(orderCrc(content), input.readUInt32LE(offset + 16), 'ZIP checksum') + assertTrue(!entries.has(name), 'unique ZIP entry') + entries.set(name, /\.(xml|rels)$/i.test(name) ? content.toString('utf8') : Buffer.from(content)) + offset += 46 + nameLength + input.readUInt16LE(offset + 30) + input.readUInt16LE(offset + 32) + } + return entries +} +function orderCrc(bytes) { + let crc = 0xffffffff + for (const byte of bytes) { crc ^= byte; for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0) } + return (crc ^ 0xffffffff) >>> 0 +} +function orderZipWrite(entries, compress = false) { + const local = [], directory = [] + let offset = 0 + for (const [name, text] of entries) { + const filename = Buffer.from(name), data = Buffer.from(text), crc = orderCrc(data) + const packed = compress ? deflateRawSync(data) : data + const header = Buffer.alloc(30) + header.writeUInt32LE(0x04034b50); header.writeUInt16LE(20, 4); header.writeUInt16LE(0x800, 6) + header.writeUInt16LE(compress ? 8 : 0, 8) + header.writeUInt32LE(crc, 14); header.writeUInt32LE(packed.length, 18); header.writeUInt32LE(data.length, 22); header.writeUInt16LE(filename.length, 26) + const central = Buffer.alloc(46) + central.writeUInt32LE(0x02014b50); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6); central.writeUInt16LE(0x800, 8) + central.writeUInt16LE(compress ? 8 : 0, 10) + central.writeUInt32LE(crc, 16); central.writeUInt32LE(packed.length, 20); central.writeUInt32LE(data.length, 24) + central.writeUInt16LE(filename.length, 28); central.writeUInt32LE(offset, 42) + local.push(header, filename, packed); directory.push(central, filename) + offset += header.length + filename.length + packed.length + } + const central = Buffer.concat(directory), end = Buffer.alloc(22) + end.writeUInt32LE(0x06054b50); end.writeUInt16LE(entries.size, 8); end.writeUInt16LE(entries.size, 10) + end.writeUInt32LE(central.length, 12); end.writeUInt32LE(offset, 16) + return Buffer.concat([...local, central, end]) +} +function orderXmlDecode(text) { + return text.replace(/&#(x[0-9a-f]+|[0-9]+);|&(lt|gt|quot|apos|amp);/gi, (match, code, named) => code + ? String.fromCodePoint(code[0].toLowerCase() === 'x' ? parseInt(code.slice(1), 16) : Number(code)) + : ({ lt: '<', gt: '>', quot: '"', apos: "'", amp: '&' })[named.toLowerCase()]) +} +function orderXmlEncode(text) { return String(text).replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]) } +function orderColumn(index) { + let result = '' + for (let value = index + 1; value; value = Math.floor((value - 1) / 26)) result = String.fromCharCode(65 + (value - 1) % 26) + result + return result +} +function orderSheetRows(entries, path = 'xl/worksheets/sheet1.xml') { + const strings = [...(entries.get('xl/sharedStrings.xml') ?? '').matchAll(/]*>([\s\S]*?)<\/si>/g)].map(item => + [...item[1].matchAll(/]*>([\s\S]*?)<\/t>/g)].map(text => orderXmlDecode(text[1])).join('')) + const sheet = entries.get(path) + assertPresent(sheet, `template ${path}`) + return [...sheet.matchAll(/]*\br="(\d+)"[^>]*>([\s\S]*?)<\/row>/g)].map(row => ({ number: Number(row[1]), cells: + [...row[2].matchAll(/]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)].map(cell => { + const ref = cell[1].match(/\br="([A-Z]+)\d+"/)?.[1] + const content = cell[2] ?? '' + const value = content.match(/]*>([\s\S]*?)<\/v>/)?.[1] + const text = /\bt="s"/.test(cell[1]) ? strings[Number(value)] : /\bt="inlineStr"/.test(cell[1]) + ? [...content.matchAll(/]*>([\s\S]*?)<\/t>/g)].map(item => orderXmlDecode(item[1])).join('') : orderXmlDecode(value ?? '') + return { ref, text } + }) })) +} +function orderWorkbookRows(template, rows) { + const entries = new Map(template), sheet = entries.get('xl/worksheets/sheet1.xml') + const headers = [...sheet.matchAll(/]*\br="([12])"[^>]*>[\s\S]*?<\/row>/g)].map(row => row[0]).join('') + assertTrue(headers.includes('r="1"') && headers.includes('r="2"'), 'both original header rows preserved') + const data = rows.map((row, index) => `${row.map((text, col) => `${orderXmlEncode(text)}`).join('')}`).join('') + entries.set('xl/worksheets/sheet1.xml', sheet.replace(/]*>[\s\S]*?<\/sheetData>/, `${headers}${data}`)) + return orderZipWrite(entries) +} +function importRollbackRows(validFirst, validSecond) { + const invalid = [...validFirst]; invalid[10] = '' // mandatory cargo name: seed only the second row + const changed = [...validSecond]; changed[30] += '-changed' // SaveRequest.customerRemark participates in CREATE_DRAFT hash + return { seed: [invalid, validSecond], conflict: [validFirst, changed], retry: [validFirst, validSecond] } +} +function channelGateWorkbook(sourceBytes, reference, channelCode) { + const matches = reference.channels.filter(channel => channel.channelCode === channelCode) + assertEqual(matches.length, 1, 'one authoritative target channel') + const sourceSheetName = matches[0].sourceSheetName + assertPresent(sourceSheetName, 'target channel source sheet') + const removedCodes = reference.channels.filter(channel => channel.sourceSheetName === sourceSheetName).map(channel => channel.channelCode) + assertTrue(reference.channels.some(channel => !removedCodes.includes(channel.channelCode)), 'B must retain another real channel') + const entries = orderZipRead(sourceBytes), workbook = entries.get('xl/workbook.xml') + assertTrue(typeof workbook === 'string', 'source workbook XML') + let removed = 0 + const updated = workbook.replace(/]*\/>/g, element => { + const name = element.match(/\bname="([^"]*)"/)?.[1] + if (name !== undefined && orderXmlDecode(name) === sourceSheetName) { removed++; return '' } + return element + }) + assertEqual(removed, 1, 'remove exactly the authoritative channel sheet from workbook directory') + // Preserve all parts and relationships byte-for-byte. The SAX price importer enumerates only workbook-listed sheets. + entries.set('xl/workbook.xml', updated) + return { bytes: orderZipWrite(entries, true), sourceSheetName, removedCodes } +} +function channelGateMappings(detail, reference) { + const mapping = (kind, keyField, idField) => { + const keys = [...new Set(detail.rates.filter(rate => kind === 'origin' || rate.destinationType === 'OVERSEAS').map(rate => rate[keyField]))] + return keys.map(key => { + assertPresent(key, `price ${kind} source label`) + const ids = [...new Set(reference.rates.filter(rate => (kind === 'origin' || rate.destinationType === 'OVERSEAS') + && rate[keyField] === key).map(rate => String(rate[idField] ?? '')))] + assertTrue(ids.length === 1 && /^[1-9]\d*$/.test(ids[0]), `unique published ${kind} mapping for ${key}`) + return kind === 'origin' ? { originWarehouseCode: key, warehouseId: ids[0] } : { sourceDestinationName: key, warehouseId: ids[0] } + }) + } + assertTrue(Array.isArray(detail.rates) && detail.rates.length > 0, 'nonempty parsed prices required') + return { originMappings: mapping('origin','originWarehouseCode','originWarehouseId'), + overseasMappings: mapping('overseas','destinationName','destinationWarehouseId') } +} +// END ORDER ACCEPTANCE HELPERS + +async function ownedOrderSnapshot(module, name, id, expected, validate = () => {}) { + const response = await apiStep(module, name, { + path: `/api/merchant/v1/orders/first-leg/${id}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json); assertOrderDetail(json.data, id) + if (expected) assertOrderUnchanged(expected, json.data) + validate(json.data) + }, ({ json }) => ({ orderId: String(id), status: json.data?.order?.status, version: json.data?.order?.version, snapshotSha256: orderDigest(json.data) })) + return response.json.data +} + +function recordOrderMutationRequest(name, request, before) { + report.orderMutationRequests ??= [] + report.orderMutationRequests.push({ name, method: request.method, path: request.path, + idempotencyKey: request.headers?.['Idempotency-Key'] ?? null, body: structuredClone(request.body), + beforeVersion: before.order.version, beforeSnapshotSha256: orderDigest(before) }) +} + +async function rejectOrderMutation(name, id, request, codes, statuses = [200]) { + const before = await ownedOrderSnapshot('order-mutation-evidence', `${name}: before`, id) + recordOrderMutationRequest(name, request, before) + await apiStep('order-mutation-evidence', name, request, ({ json, status }) => { + assertTrue(statuses.includes(status), `expected HTTP ${statuses}, actual=${status}`) + assertTrue(codes.includes(String(json?.code)), `expected ${codes}, actual=${json?.code}`) + }, ({ json }) => ({ orderId: String(id), rejectionCode: json?.code })) + await ownedOrderSnapshot('order-mutation-evidence', `${name}: all facts unchanged`, id, before) +} + +async function orderMutationEvidenceModule() { + const module = 'order-mutation-evidence', base = '/api/merchant/v1/orders/first-leg' + assertPresent(orderAcceptanceFixture, 'completed order lifecycle fixture') + assertPresent(existingClientToken, 'independent client session') + const payload = structuredClone(orderAcceptanceFixture.payload) + payload.customerRemark = `mutation-${marker}` + const created = await apiStep(module, 'create owned mutable draft for isolation and cancellation', { + method: 'POST', path: base, token: merchantToken, body: payload, headers: { 'Idempotency-Key': newIdempotencyKey('mutation-draft') }, + }, ({ json }) => { requireSuccess(json); assertPresent(json.data, 'draft ID') }, ({ json }) => ({ orderId: json.data })) + const id = created.json.data + report.resources.orderIds.push(id) + const original = await ownedOrderSnapshot(module, 'freeze draft page version and request', id) + const editPayload = structuredClone(payload); editPayload.customerRemark += '-edited' + const editCommand = orderMutationCommand(original, { draft: editPayload }, newIdempotencyKey('versioned-edit')) + const editRequest = { method: 'PUT', path: `${base}/${id}`, token: merchantToken, ...editCommand } + recordOrderMutationRequest('versioned draft edit', editRequest, original) + await apiStep(module, 'versioned draft edit succeeds', editRequest, ({ json }) => requireSuccess(json), + () => ({ orderId: id, expectedVersion: original.order.version })) + const edited = await ownedOrderSnapshot(module, 'edited draft full snapshot', id, null, data => { + assertEqual(data.order.status, 'DRAFT', 'edit stays draft') + assertTrue(data.order.version > original.order.version, 'edit increments version') + assertEqual(data.order.customerRemark, editPayload.customerRemark, 'edited source remark') + for (const field of ['id', 'orderNo', 'inboundCode', 'tenantId', 'customerId']) assertEqual(data.order[field], original.order[field], `stable edit ${field}`) + assertEqual(data.cargoes.length, editPayload.cargoes.length, 'complete edited cargo set') + data.cargoes.forEach((cargo, index) => { + for (const field of ['skuNo', 'packageCount', 'model', 'productImageFileId']) assertEqual(cargo[field], editPayload.cargoes[index][field], `edited cargo ${field}`) + }) + const oldIds = new Set(original.statusHistory.map(item => String(item.id))) + const added = data.statusHistory.filter(item => !oldIds.has(String(item.id))) + assertOrderUnchanged(original.statusHistory, data.statusHistory.filter(item => oldIds.has(String(item.id)))) + assertEqual(added.length, 1, 'single edit audit'); assertEqual(added[0].action, 'UPDATE', 'edit audit action') + }) + recordOrderMutationRequest('original edit replay', editRequest, original) + await apiStep(module, 'original edit key and frozen payload replay successfully', editRequest, ({ json }) => requireSuccess(json), + () => ({ orderId: id, expectedVersion: original.order.version, replayed: true })) + await ownedOrderSnapshot(module, 'edit replay adds no version cargo or history', id, edited) + const changedEdit = structuredClone(editCommand); changedEdit.body.draft.customerRemark += '-conflict' + await rejectOrderMutation('edit original key different payload conflicts', id, { + method: 'PUT', path: `${base}/${id}`, token: merchantToken, ...changedEdit, + }, ['A0409']) + await rejectOrderMutation('old page version cannot overwrite newer draft', id, { + method: 'PUT', path: `${base}/${id}`, token: merchantToken, + ...orderMutationCommand(original, { draft: payload }, newIdempotencyKey('stale-edit')), + }, ['A0409']) + for (const operation of ['edit', 'cancel']) { + const method = operation === 'edit' ? 'PUT' : 'POST', path = `${base}/${id}${operation === 'edit' ? '' : '/cancel'}` + const command = orderMutationCommand(edited, operation === 'edit' ? { draft: editPayload } : { reason: 'missing protocol guard' }, newIdempotencyKey(`missing-${operation}`)) + const withoutVersion = structuredClone(command); delete withoutVersion.body.expectedVersion + await rejectOrderMutation(`${operation} missing version is rejected`, id, { method, path, token: merchantToken, ...withoutVersion }, ['A0422']) + await rejectOrderMutation(`${operation} missing key is rejected`, id, { method, path, token: merchantToken, body: command.body }, ['A0400'], [200, 400]) + } + await rejectOrderMutation('other customer cannot edit owned draft', id, { + method: 'PUT', path: `${base}/${id}`, token: existingClientToken, + ...orderMutationCommand(edited, { draft: payload }, newIdempotencyKey('foreign-edit')), + }, ['A0403', 'A0404']) + await rejectOrderMutation('other customer cannot cancel owned draft', id, { + method: 'POST', path: `${base}/${id}/cancel`, token: existingClientToken, + ...orderMutationCommand(edited, { reason: `foreign-${marker}` }, newIdempotencyKey('foreign-cancel')), + }, ['A0403', 'A0404']) + const invalid = structuredClone(payload); invalid.cargoes[0].productImageFileId = -1 + await rejectOrderMutation('invalid product image edit rolls back all facts', id, { + method: 'PUT', path: `${base}/${id}`, token: merchantToken, + ...orderMutationCommand(edited, { draft: invalid }, newIdempotencyKey('invalid-edit')), + }, ['A0422']) + await rejectOrderMutation('submitted order cannot be overwritten as draft', orderAcceptanceFixture.orderId, { + method: 'PUT', path: `${base}/${orderAcceptanceFixture.orderId}`, token: merchantToken, + ...orderMutationCommand(await ownedOrderSnapshot(module, 'freeze submitted order edit request', orderAcceptanceFixture.orderId), + { draft: payload }, newIdempotencyKey('submitted-edit-evidence')), + }, ['A0409']) + await rejectOrderMutation('blank cancellation reason preserves all facts', id, { + method: 'POST', path: `${base}/${id}/cancel`, token: merchantToken, + ...orderMutationCommand(edited, { reason: ' ' }, newIdempotencyKey('empty-reason')), + }, ['A0422']) + await rejectOrderMutation('old page version cannot cancel updated draft', id, { + method: 'POST', path: `${base}/${id}/cancel`, token: merchantToken, + ...orderMutationCommand(original, { reason: 'stale page' }, newIdempotencyKey('stale-cancel')), + }, ['A0409']) + const before = await ownedOrderSnapshot(module, 'before valid cancellation', id) + const reason = `F06-cancel-${marker}` + const cancelCommand = orderMutationCommand(before, { reason }, newIdempotencyKey('versioned-cancel')) + const cancelRequest = { method: 'POST', path: `${base}/${id}/cancel`, token: merchantToken, ...cancelCommand } + recordOrderMutationRequest('versioned cancellation', cancelRequest, before) + await apiStep(module, 'cancel owned draft with reason', cancelRequest, ({ json }) => requireSuccess(json), () => ({ orderId: id, reason })) + await apiStep(module, 'cancellation persists one attributed history and preserves cargo', { + path: `${base}/${id}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json); assertOrderDetail(json.data, id) + assertEqual(json.data.order.status, 'CANCELLED', 'cancelled status') + assertTrue(json.data.order.version > before.order.version, 'version increases') + for (const field of ['cargoes', 'parties', 'internalNotes']) assertOrderUnchanged(before[field], json.data[field]) + for (const field of ['id', 'orderNo', 'inboundCode', 'tenantId', 'customerId']) assertEqual(json.data.order[field], before.order[field], `stable ${field}`) + const oldIds = new Set(before.statusHistory.map(item => String(item.id))) + const added = json.data.statusHistory.filter(item => !oldIds.has(String(item.id))) + assertEqual(added.length, 1, 'one cancellation history') + assertOrderUnchanged(before.statusHistory, json.data.statusHistory.filter(item => oldIds.has(String(item.id)))) + assertEqual(added[0].action, 'CANCELLED', 'cancellation action'); assertEqual(added[0].reason, reason, 'cancellation reason') + assertEqual(added[0].fromStatus, 'DRAFT', 'from draft'); assertEqual(added[0].toStatus, 'CANCELLED', 'to cancelled') + assertPresent(added[0].operatorId, 'operator'); assertPresent(added[0].occurredAt, 'server operation time') + }, ({ json }) => ({ orderId: id, status: json.data?.order?.status, snapshotSha256: orderDigest(json.data) })) + const cancelled = await ownedOrderSnapshot(module, 'freeze cancelled facts before replay', id) + recordOrderMutationRequest('original cancellation replay', cancelRequest, before) + await apiStep(module, 'original cancellation key payload and version replay successfully', cancelRequest, + ({ json }) => requireSuccess(json), () => ({ orderId: id, expectedVersion: before.order.version, replayed: true })) + await ownedOrderSnapshot(module, 'cancel replay creates no second history or version', id, cancelled) + const changedCancel = structuredClone(cancelCommand); changedCancel.body.reason += '-conflict' + await rejectOrderMutation('cancellation original key different reason conflicts', id, { + method: 'POST', path: `${base}/${id}/cancel`, token: merchantToken, ...changedCancel, + }, ['A0409']) + await rejectOrderMutation('repeat cancellation cannot add history', id, { + method: 'POST', path: `${base}/${id}/cancel`, token: merchantToken, + ...orderMutationCommand(cancelled, { reason }, newIdempotencyKey('new-key-repeat-cancel')), + }, ['A0409']) + await rejectOrderMutation('cancelled draft cannot be submitted', id, { + method: 'POST', path: `${base}/${id}/submit`, token: merchantToken, + ...orderMutationCommand(cancelled, {}, newIdempotencyKey('cancelled-submit-proof')), + }, ['A0409']) + report.orderMutationLimitations = [ + 'Requires deployed Merchant edit/cancel versioned idempotent protocol; local contracts do not prove TEST deployment.', + 'Irreversible-fulfillment cancellation is not exercised by these draft/submitted fixtures.', + ] +} + +async function submissionGateFulfillmentSnapshot(order, name) { + const module = 'order-submission-gates', customerId = String(report.resources.tenantId) + let task + await apiStep(module, `${name}: exact fulfillment task`, { + path: `/api/platform/v1/first-leg/tasks/by-order/${order.id}?tenantId=${customerId}`, token: platformToken, + }, ({ json, status }) => { + assertEqual(status, 200, 'task lookup HTTP') + assertTrue(['200','A0404'].includes(String(json?.code)), 'task lookup must succeed or explicitly not exist') + task = String(json.code) === 'A0404' ? null : json.data ?? null + if (task) { + assertEqual(String(task.orderId), String(order.id), 'task source order') + assertEqual(String(task.tenantId), customerId, 'task customer') + } + }, () => ({ orderId: order.id, taskSha256: orderDigest(task) })) + const inbounds = [] + let total + for (let page = 1; ; page++) { + await apiStep(module, `${name}: inbound page ${page}`, { + path: `/api/platform/inbounds?tenantId=${customerId}&orderNo=${encodeURIComponent(order.orderNo)}&page=${page}&size=200`, token: platformToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(Array.isArray(json.data?.records), 'inbound records required') + assertTrue(Number.isSafeInteger(Number(json.data.total)) && Number(json.data.total) >= 0, 'inbound total required') + if (total === undefined) total = Number(json.data.total) + assertEqual(Number(json.data.total), total, 'stable inbound total') + assertEqual(Number(json.data.page), page, 'inbound page identity') + for (const row of json.data.records) { + assertEqual(row.orderNo, order.orderNo, 'exact inbound source') + assertEqual(String(row.tenantId), customerId, 'inbound customer') + assertTrue(!inbounds.some(before => String(before.id) === String(row.id)), 'no duplicate inbound across pages') + inbounds.push(row) + } + assertEqual(json.data.records.length, Math.min(200, Math.max(0, total - (page - 1) * 200)), 'complete inbound page') + }, () => ({ orderId: order.id, total, collected: inbounds.length, snapshotSha256: orderDigest(inbounds) })) + if (page * 200 >= total) break + } + return { task, inbounds } +} + +async function orderSubmissionBalanceGateModule() { + const module = 'order-submission-gates', customerId = String(report.resources.tenantId) + const proof = report.orderSubmissionGates = { customerId, balance: { status: 'PREPARING' }, remaining: [ + 'Channel-offline requires this run customer-owned price-book switch excluding the draft channel; no public channel or public price-book is changed.', + 'Finance/IAM unavailable-response rejection requires controlled isolated upstream fault evidence; not injected here.', + ] } + await apiStep(module, 'guard newly registered customer ownership before financial fixture', { + path: '/api/merchant/v1/iam/tenant/profile', token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(String(json.data?.id), customerId, 'current primary client identity') + assertEqual(json.data?.status, 'ACTIVE', 'approved active customer') + assertTrue(report.steps.some(step => step.module === 'registration' && step.name === 'enterprise self-registration' + && step.passed === true && String(step.evidence?.tenantId) === customerId), 'only this run newly registered customer may be adjusted') + }, () => ({ customerId, scope: 'current-run-registration-only' })) + const readWallet = async label => { + const response = await apiStep(module, label, { path: '/api/client/finance/v1/account', token: merchantToken }, ({ json }) => { + requireSuccess(json); assertTrue(Number.isSafeInteger(json.data?.version), 'wallet version required') + assertEqual(json.data.currencyCode, 'CNY', 'isolated wallet currency') + for (const field of ['actualBalance','availableBalance','frozenBalance','creditLimit','creditUsed','creditFrozen','availableCredit']) { + assertTrue(json.data[field] != null && Number.isFinite(Number(json.data[field])), `wallet ${field} required`) + } + assertEqual(json.data.creditActive, false, 'no active credit may be altered') + }, ({ json }) => ({ customerId, account: json.data })) + return response.json.data + } + const readLedger = async label => { + const response = await apiStep(module, label, { path: '/api/client/finance/v1/account/ledger?page=1&size=200', token: merchantToken }, ({ json }) => { + requireSuccess(json); assertTrue(Array.isArray(json.data?.records), 'ledger records required') + assertEqual(Number(json.data.total), json.data.records.length, 'entire isolated ledger fits one page') + }, ({ json }) => ({ total: json.data.total, ledgerSha256: orderDigest(json.data.records) })) + return response.json.data.records + } + const baseline = await readWallet('freeze pristine account') + for (const field of ['actualBalance','availableBalance','frozenBalance','creditLimit','creditUsed','creditFrozen','availableCredit']) assertEqual(Number(baseline[field]), 0, `pristine ${field}`) + assertEqual((await readLedger('require no prior ledger')).length, 0, 'never debit an existing funded account') + const payload = { ...structuredClone(orderAcceptanceFixture.payload), customerRemark: `BALANCE-GATE-${marker}` } + const created = await apiStep(module, 'create only one fresh balance-gate draft', { + method:'POST', path:'/api/merchant/v1/orders/first-leg', token:merchantToken, body:payload, + headers:{'Idempotency-Key':newIdempotencyKey('balance-gate-create')}, + }, ({json})=>{requireSuccess(json);assertPresent(json.data,'gate order')}, ({json})=>({orderId:json.data})) + const orderId=created.json.data + report.resources.orderIds.push(orderId);proof.balance.orderId=orderId + const before = await ownedOrderSnapshot(module,'freeze rejected-submit draft facts',orderId) + assertEqual(before.order.status,'DRAFT','unfulfilled balance fixture') + const fulfillmentBefore = await submissionGateFulfillmentSnapshot(before.order,'before balance rejection') + const occurredDate = new Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Shanghai',year:'numeric',month:'2-digit',day:'2-digit'}).format(new Date()) + const referenceNo = `BALANCE-GATE-${marker}`.slice(0,128) + const adjust = async (type, account, suffix) => { + const command={headers:{'Idempotency-Key':newIdempotencyKey(`balance-${suffix}`)},body:{type,amount:0.01,occurredDate,paymentMethod:'OTHER', + referenceNo,remark:`${marker} isolated order gate ${suffix}`,expectedAccountVersion:account.version}} + proof.balance[`${suffix}Command`]=command + await apiStep(module,`isolated wallet ${suffix}`,{method:'POST',path:`/api/platform/finance/v1/accounts/${customerId}/adjustments`, + token:platformToken,...command},({json})=>requireSuccess(json),({json})=>({customerId,ledger:json.data})) + } + let attempted=false + try { + attempted=true + await adjust('DEDUCTION',baseline,'deduct') + const negative=await readWallet('verify actual balance minus one cent') + assertEqual(Number(negative.actualBalance),-0.01,'negative actual funds, not insufficient available credit') + assertEqual(negative.version,baseline.version+1,'one intentional deduction') + const negativeLedger=await readLedger('freeze deduction ledger') + assertEqual(negativeLedger.length,1,'only one fixture deduction') + for(const role of ['merchant','platform']) { + const command=orderMutationCommand(before,{},newIdempotencyKey(`negative-balance-${role}`)) + await apiStep(module,`${role} negative actual balance rejects submission`,{ + method:'POST',path:`/api/${role}/v1/orders/first-leg/${orderId}/submit${role==='platform'?`?tenantId=${customerId}`:''}`, + token:role==='merchant'?merchantToken:platformToken,...command, + },({json,status})=>{assertEqual(status,200,'submit HTTP');assertEqual(String(json?.code),'A0403','balance gate code'); + assertEqual(json?.message,'账户实际余额为负,请充值后再下单','balance-specific reason, not qualification rejection')},()=>({orderId,customerId,expectedVersion:command.body.expectedVersion})) + await ownedOrderSnapshot(module,`${role} rejected order aggregate unchanged`,orderId,before) + const after=await submissionGateFulfillmentSnapshot(before.order,`${role} rejection`) + assertOrderUnchanged(fulfillmentBefore,after) + assertOrderUnchanged(negative,await readWallet(`${role} rejection leaves wallet unchanged`)) + assertOrderUnchanged(negativeLedger,await readLedger(`${role} rejection leaves ledger unchanged`)) + } + proof.balance.status='REJECTIONS_VERIFIED' + } finally { + if(attempted) { + const current=await readWallet('resolve isolated deduction before restoring') + const ledger=await readLedger('resolve exact fixture ledger before restoring') + if(Number(current.actualBalance)===0 && current.version===baseline.version && ledger.length===0) proof.balance.restoration='DEDUCTION_NOT_APPLIED' + else { + assertEqual(Number(current.actualBalance),-0.01,'restore only exact isolated negative balance') + assertEqual(current.version,baseline.version+1,'restore requires unchanged deduction version') + assertTrue(ledger.length===1 && ledger[0].sourceType==='MANUAL_DEDUCTION' && ledger[0].referenceNo===referenceNo + && Number(ledger[0].amount)===-0.01,'restore only this run proven deduction; no blind credit') + await adjust('RECHARGE',current,'restore') + const restored=await readWallet('verify restored zero balance') + for(const field of ['actualBalance','availableBalance','frozenBalance','creditLimit','creditUsed','creditFrozen','availableCredit']) assertEqual(Number(restored[field]),Number(baseline[field]),`restored ${field}`) + assertEqual(restored.version,baseline.version+2,'one deduction and one restoring credit') + const finalLedger=await readLedger('verify exact two fixture ledger entries') + assertEqual(finalLedger.length,2,'no extra wallet movements') + assertTrue(finalLedger.every(row=>row.referenceNo===referenceNo) && finalLedger.filter(row=>row.sourceType==='MANUAL_DEDUCTION'&&Number(row.amount)===-0.01).length===1 + && finalLedger.filter(row=>row.sourceType==='MANUAL_RECHARGE'&&Number(row.amount)===0.01).length===1,'exact balanced fixture movements') + proof.balance.restoration='RESTORED_WITH_AUDIT' + } + } + } +} + +async function orderSubmissionChannelGateModule() { + const module='order-channel-gate', base='/api/platform/v1/fba-pricing/carrier-rates/price-books' + const customerId=String(report.resources.tenantId) + assertTrue(/^[1-9]\d*$/.test(customerId), 'customer-specific price book must never use platform scope zero') + assertTrue(report.steps.some(step=>step.module==='registration' && step.name==='enterprise self-registration' + && step.passed===true && String(step.evidence?.tenantId)===customerId), 'only current-run registered customer is eligible') + assertEqual(report.orderSubmissionGates?.balance?.restoration,'RESTORED_WITH_AUDIT','negative funds must already be restored') + const {masterData,payload}=orderAcceptanceFixture + const proof=report.orderSubmissionGates.channel={status:'PREPARING',customerId,channelCode:payload.channelCode,versions:[]} + await apiStep(module,'verify current customer before private pricing writes',{ + path:'/api/merchant/v1/iam/tenant/profile',token:merchantToken, + },({json})=>{requireSuccess(json);assertEqual(String(json.data?.id),customerId,'current customer');assertEqual(json.data.status,'ACTIVE','eligible customer')},()=>({customerId})) + const list=async(scope,label)=>{ + const response=await apiStep(module,label,{path:`${base}?customerId=${scope}`,token:platformToken},({json})=>{ + requireSuccess(json);assertTrue(Array.isArray(json.data),'price version list required') + assertTrue(json.data.every(version=>String(version.customerId)===String(scope)),'exact price-book customer scope') + },({json})=>({customerId:scope,versionCount:json.data.length,versionsSha256:orderDigest(json.data), + activeVersionIds:json.data.filter(version=>version.status==='ACTIVE').map(version=>version.id)})) + return response.json.data + } + const publicBefore=await list('0','freeze public price-book versions without changing them') + assertEqual((await list(customerId,'require no previous customer-specific price books')).length,0,'do not replace any existing customer private version') + const active=publicBefore.filter(version=>version.status==='ACTIVE' && String(version.id)===String(masterData.channel.priceBookVersionId)) + assertEqual(active.length,1,'selected route must originate from one active public source') + let reference + await apiStep(module,'read authoritative active price source and mappings',{ + path:`${base}/${active[0].id}`,token:platformToken, + },({json})=>{ + requireSuccess(json);reference=json.data + assertEqual(String(reference?.version?.customerId),'0','read-only source is public') + assertEqual(String(reference.version.id),String(active[0].id),'exact active source') + assertEqual(reference.version.status,'ACTIVE','valid reusable source') + assertTrue(Array.isArray(reference.channels)&&reference.channels.length>1,'source must retain channels after removing target sheet') + },({json})=>({versionId:json.data.version.id,sourceSha256:json.data.version.sourceSha256})) + let sourceBytes + await apiStep(module,'reuse existing valid XLSX source for private A',{ + path:`${base}/${reference.version.id}/source`,token:platformToken,responseType:'bytes', + },({status,bytes})=>{ + assertEqual(status,200,'source download HTTP');sourceBytes=bytes + assertEqual(createHash('sha256').update(bytes).digest('hex'),reference.version.sourceSha256,'source SHA-256 matches published version') + },({bytes})=>({sizeBytes:bytes.length,sha256:createHash('sha256').update(bytes).digest('hex')})) + const replacement=channelGateWorkbook(sourceBytes,reference,payload.channelCode) + proof.sourceVersionId=reference.version.id;proof.sourceSheetName=replacement.sourceSheetName;proof.removedChannelCodes=replacement.removedCodes + const codesA=reference.channels.map(channel=>channel.channelCode).sort() + const codesB=codesA.filter(code=>!replacement.removedCodes.includes(code)) + const channelList=async(versionId,containsTarget,label)=>{ + const response=await apiStep(module,label,{ + path:`/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${masterData.receiving.id}`,token:merchantToken, + },({json})=>{ + requireSuccess(json);assertTrue(Array.isArray(json.data),'client channel list must be a successful array') + assertEqual(json.data.some(channel=>channel.channelCode===payload.channelCode),containsTarget,'target channel visibility') + assertTrue(json.data.every(channel=>String(channel.priceBookVersionId)===String(versionId)),'client must read private version, never fall back to public') + },({json})=>({customerId,versionId,channelCodes:json.data.map(channel=>channel.channelCode)})) + return response.json.data + } + const publish=async(bytes,label,expectedCodes)=>{ + const sha256=createHash('sha256').update(bytes).digest('hex'), form=new FormData() + form.append('file',new Blob([bytes],{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}),`gate-${marker}-${label}.xlsx`) + let parsed + await apiStep(module,`upload customer-only version ${label}`,{ + method:'POST',path:`${base}?customerId=${customerId}`,token:platformToken,body:form, + },({json})=>{ + requireSuccess(json);parsed=json.data + assertEqual(String(parsed?.version?.customerId),customerId,'uploaded version exclusively belongs to new customer') + assertPresent(parsed.version.id,'uploaded version identity');assertEqual(parsed.version.status,'PENDING_CONFIRMATION','upload stays pending') + assertEqual(parsed.version.sourceSha256,sha256,'uploaded source digest') + assertOrderUnchanged(expectedCodes,[...parsed.channels.map(channel=>channel.channelCode)].sort()) + },({json})=>({customerId,versionId:json.data?.version?.id,sourceSha256:sha256})) + const entry={label,id:String(parsed.version.id),customerId,sourceSha256:sha256,status:'PENDING_CONFIRMATION'} + proof.versions.push(entry) + const mappings=channelGateMappings(parsed,reference) + await apiStep(module,`recheck exact ownership immediately before publishing ${label}`,{ + path:`${base}/${entry.id}`,token:platformToken, + },({json})=>{ + requireSuccess(json);assertEqual(String(json.data?.version?.customerId),customerId,'publication ownership') + assertEqual(String(json.data.version.id),entry.id,'publication version');assertEqual(json.data.version.sourceSha256,sha256,'publication source') + assertEqual(json.data.version.status,'PENDING_CONFIRMATION','publication precondition') + },()=>({customerId,versionId:entry.id})) + await apiStep(module,`publish private ${label} with source-verified warehouse mappings`,{ + method:'POST',path:`${base}/${entry.id}/publish`,token:platformToken,body:mappings, + },({json})=>{ + requireSuccess(json);assertEqual(String(json.data?.version?.customerId),customerId,'published scope') + assertEqual(String(json.data.version.id),entry.id,'published identity');assertEqual(json.data.version.status,'ACTIVE','published status') + assertEqual(Number(json.data.version.blockingErrorCount),0,'no ignored publication blockers') + assertOrderUnchanged(expectedCodes,[...json.data.channels.map(channel=>channel.channelCode)].sort()) + assertTrue(Array.isArray(json.data.rates)&&json.data.rates.length>0,'remaining real rates') + },()=>({customerId,versionId:entry.id,mappings})) + entry.status='ACTIVE' + return entry.id + } + try { + const a=await publish(sourceBytes,'A',codesA) + proof.clientChannelsA=await channelList(a,true,'client sees target channel in private A') + const gatePayload={...structuredClone(payload),customerRemark:`CHANNEL-GATE-${marker}`} + const created=await apiStep(module,'create fresh draft while target channel is available in A',{ + method:'POST',path:'/api/merchant/v1/orders/first-leg',token:merchantToken,body:gatePayload, + headers:{'Idempotency-Key':newIdempotencyKey('channel-gate-create')}, + },({json})=>{requireSuccess(json);assertPresent(json.data,'channel-gate draft')},({json})=>({orderId:json.data})) + const orderId=created.json.data;proof.orderId=orderId;report.resources.orderIds.push(orderId) + const before=await ownedOrderSnapshot(module,'freeze original A draft version and full facts',orderId) + assertEqual(before.order.status,'DRAFT','unfulfilled channel gate fixture') + assertEqual(before.order.channelCode,payload.channelCode,'original draft channel') + const fulfillment=await submissionGateFulfillmentSnapshot(before.order,'before private channel removal') + const commands=Object.fromEntries(['merchant','platform'].map(role=>[role,orderMutationCommand(before,{},newIdempotencyKey(`removed-channel-${role}`))])) + proof.commands=commands + const b=await publish(replacement.bytes,'B',codesB) + proof.clientChannelsB=await channelList(b,false,'client target channel is removed in private B') + const own=await list(customerId,'verify A superseded only within this customer and B active') + assertTrue(own.length===2 && own.some(version=>String(version.id)===a&&version.status==='SUPERSEDED') + && own.some(version=>String(version.id)===b&&version.status==='ACTIVE'),'exact customer A to B replacement') + for (const entry of proof.versions) { + entry.statusAtPublication = entry.status + entry.status = own.find(version => String(version.id) === entry.id).status + } + for(const role of ['merchant','platform']) { + await apiStep(module,`${role} original draft rejects now-unavailable channel`,{ + method:'POST',path:`/api/${role}/v1/orders/first-leg/${orderId}/submit${role==='platform'?`?tenantId=${customerId}`:''}`, + token:role==='merchant'?merchantToken:platformToken,...commands[role], + },({json,status})=>{ + assertEqual(status,200,'submit HTTP');assertEqual(String(json?.code),'A0409','unavailable channel conflict') + assertEqual(json?.message,'所选渠道已下线,请重新选择','channel-specific rejection, not version or qualification conflict') + },()=>({customerId,orderId,expectedVersion:commands[role].body.expectedVersion,channelCode:payload.channelCode})) + await ownedOrderSnapshot(module,`${role} channel rejection leaves original order unchanged`,orderId,before) + assertOrderUnchanged(fulfillment,await submissionGateFulfillmentSnapshot(before.order,`${role} channel rejection`)) + } + proof.status='SUBMISSIONS_REJECTED' + } finally { + assertOrderUnchanged(publicBefore,await list('0','verify every public price-book version unchanged')) + proof.publicVersionsUnchanged=true + } + proof.status='REJECTIONS_VERIFIED' + report.orderSubmissionGates.remaining=report.orderSubmissionGates.remaining.filter(item=>!item.startsWith('Channel-offline requires')) +} + +async function importOrderSet(name, expectedIds) { + const ids = [], seen = new Set() + let total = null + for (let page = 1; ; page++) { + await apiStep('order-excel-import', `${name}: complete order page ${page}`, { + path: `/api/merchant/v1/orders/first-leg?page=${page}&size=200`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(Array.isArray(json.data?.records), 'order page records required') + assertEqual(Number(json.data.page), page, 'order page identity') + const count = Number(json.data.total) + assertTrue(Number.isSafeInteger(count) && count >= 0, 'valid order total') + if (total === null) total = count + assertEqual(count, total, 'stable order page total') + for (const row of json.data.records) { + assertPresent(row.orderId, 'listed order ID') + const id = String(row.orderId) + assertTrue(!seen.has(id), 'no duplicate order across pages'); seen.add(id); ids.push(id) + } + assertTrue(ids.length <= total, 'order pages do not exceed total') + if (page * 200 >= total) { + assertEqual(ids.length, total, 'complete order set') + if (expectedIds) assertOrderUnchanged([...expectedIds].sort(), [...ids].sort()) + } else assertEqual(json.data.records.length, 200, 'full intermediate order page') + }, () => ({ page, total, collected: ids.length, orderIdsSha256: orderDigest([...ids].sort()) })) + if (page * 200 >= total) return ids + } +} + +async function orderExcelImportModule() { + const module = 'order-excel-import', base = '/api/merchant/v1/orders/first-leg/batch-imports' + const { masterData, payload } = orderAcceptanceFixture + let template + await apiStep(module, 'download and inspect actual Chinese customer template', { + path: `${base}/template`, token: merchantToken, responseType: 'bytes', + }, ({ status, bytes, headers }) => { + assertEqual(status, 200, 'template HTTP status') + assertTrue((headers.get('content-type') ?? '').includes('spreadsheetml'), 'XLSX content type') + template = orderZipRead(bytes) + const header = orderSheetRows(template).find(row => row.number === 2) + assertEqual(header?.cells?.length, 31, 'current 31-column template') + assertEqual(header.cells[0].text, '运输方式 *', 'Chinese first header') + assertEqual(header.cells[30].text, '客户备注', 'Chinese final header') + assertTrue(template.get('xl/workbook.xml').includes('选项数据'), 'server-generated option sheet') + }, ({ bytes }) => ({ sizeBytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') })) + const options = new Set(orderSheetRows(template, 'xl/worksheets/sheet2.xml').flatMap(row => row.cells.map(cell => cell.text))) + const businessLabel = (name, code) => !name || name.toLowerCase() === code.toLowerCase() ? code : `${name}|${code}` + const receiving = businessLabel(masterData.receiving.warehouseName, masterData.receiving.warehouseCode) + const destination = businessLabel(masterData.destination.warehouseName, masterData.destination.warehouseCode) + const channel = businessLabel(masterData.channel.channelName, masterData.channel.channelCode) + for (const value of [receiving, destination, channel]) assertTrue(options.has(value), `authoritative template option ${value}`) + const makeRow = suffix => [ + '海运', '', '自行快递', receiving, channel, '亚马逊FBA', destination, '', '', '', + `Excel-${suffix}`, '纸箱', '2', '20.5', '0.024', '塑料', '否', '否', `SKU-${suffix}`, '123456789012', + `SHIP-${suffix}`, `REF-${suffix}`, '买单报关', payload.pricingTaxMode === 'TAX_INCLUDED' ? '包税' : '不包税', + '散货拼箱', '否', `SHIP-${suffix}`, `REF-${suffix}`, '普通头程', '', `${marker}-${suffix}`, + ] + const upload = async (name, mode, key, bytes, validate, filename = 'orders.xlsx') => { + const form = new FormData() + form.append('file', new Blob([bytes], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), filename) + return apiStep(module, name, { + method: 'POST', path: `${base}?mode=${mode}`, token: merchantToken, headers: { 'Idempotency-Key': key }, body: form, + }, validate, ({ json }) => ({ batchNo: json?.data?.batchNo, mode, result: json?.data?.result, sourceFileId: json?.data?.sourceFileId, + errorReportFileId: json?.data?.errorReportFileId, requestBytesSha256: createHash('sha256').update(bytes).digest('hex') })) + } + const rejection = code => ({ json, status }) => { + assertEqual(status, 200, 'HTTP status'); assertEqual(String(json?.code), code, 'rejection business code') + } + const success = (mode, rows) => ({ json }) => { requireSuccess(json); assertImportResult(json.data, mode, rows) } + const verifyDraft = async (id, row, name) => { + await apiStep(module, name, { path: `/api/merchant/v1/orders/first-leg/${id}`, token: merchantToken }, ({ json }) => { + requireSuccess(json); assertOrderDetail(json.data, id) + assertEqual(json.data.order.status, 'DRAFT', 'import creates draft only') + assertEqual(String(json.data.order.tenantId), String(report.resources.tenantId), 'authenticated customer scope') + assertEqual(json.data.order.customerRemark, row[30], 'exact source remark') + assertEqual(json.data.order.referenceId, row[27], 'exact source reference') + assertEqual(String(json.data.order.receivingWarehouseId), String(masterData.receiving.id), 'authoritative receiving warehouse') + assertEqual(String(json.data.order.destinationWarehouseId), String(masterData.destination.id), 'authoritative destination') + assertEqual(json.data.order.channelCode, masterData.channel.channelCode, 'authoritative channel') + assertEqual(json.data.cargoes.length, 1, 'one Excel row creates one order cargo') + assertEqual(json.data.cargoes[0].skuNo, row[18], 'source SKU') + assertEqual(Number(json.data.cargoes[0].packageCount), 2, 'source package count') + assertTrue(!json.data.statusHistory.some(item => item.toStatus === 'SUBMITTED'), 'import never submits') + }, ({ json }) => ({ orderId: String(id), status: json.data?.order?.status, snapshotSha256: orderDigest(json.data) })) + } + const initial = await importOrderSet('before import matrix') + const rows = importRollbackRows(makeRow('rollback-first'), makeRow('rollback-second')) + const key = newIdempotencyKey('excel-transaction') + const seedBytes = orderWorkbookRows(template, rows.seed) + const seed = await upload('PARTIAL preserves only valid second row', 'PARTIAL', key, seedBytes, success('PARTIAL', [false, true])) + const retainedId = String(seed.json.data.result.items[1].orderId) + report.resources.orderIds.push(retainedId) + const seeded = [...initial, retainedId] + await importOrderSet('PARTIAL exactly one new order', seeded) + await verifyDraft(retainedId, rows.seed[1], 'PARTIAL source fields and customer match') + const retained = await ownedOrderSnapshot(module, 'seed second row full snapshot', retainedId) + const replay = await upload('PARTIAL same key bytes returns same order', 'PARTIAL', key, seedBytes, response => { + success('PARTIAL', [false, true])(response) + assertEqual(String(response.json.data.result.items[1].orderId), retainedId, 'replayed order identity') + }) + await importOrderSet('PARTIAL replay adds no orders', seeded) + await ownedOrderSnapshot(module, 'PARTIAL replay preserves full existing order', retainedId, retained) + const errorData = replay.json.data + await apiStep(module, 'download actual row error workbook', { + path: `/api/merchant/v1/files/order-batch-imports/${encodeURIComponent(errorData.batchNo)}/error-reports/${errorData.errorReportFileId}`, + token: merchantToken, responseType: 'bytes', + }, ({ status, bytes }) => { + assertEqual(status, 200, 'error workbook download') + const rows = orderSheetRows(orderZipRead(bytes)) + assertEqual(rows.length, 2, 'header and exactly one failed row') + assertEqual(Number(rows[1].cells[0].text), 3, 'actual Excel error row') + assertEqual(rows[1].cells[1].text, 'A0422', 'error report code') + assertEqual(rows[1].cells[2].text, errorData.result.items[0].errorMessage, 'error report exact reason') + }, ({ bytes }) => ({ sizeBytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') })) + await upload('ATOMIC parse rejection creates no orders', 'ATOMIC', newIdempotencyKey('excel-parse'), seedBytes, rejection('A0422')) + await importOrderSet('ATOMIC parse failure has zero additions', seeded) + // Same tenant/operator/key and physical row IDs; only row 4 customerRemark changes. + // Row 3 passes validation and writes before row 4 CREATE_DRAFT fingerprint conflicts. + await upload('ATOMIC later row idempotency conflict rolls back earlier row', 'ATOMIC', key, + orderWorkbookRows(template, rows.conflict), rejection('A0409')) + await importOrderSet('ATOMIC mid-write conflict leaves no first-row order', seeded) + await ownedOrderSnapshot(module, 'ATOMIC conflict preserves seeded order and history', retainedId, retained) + await upload('ATOMIC retry with original second-row hash succeeds', 'ATOMIC', key, + orderWorkbookRows(template, rows.retry), response => { + success('ATOMIC', [true, true])(response) + assertEqual(String(response.json.data.result.items[1].orderId), retainedId, 'second row reuses original ID') + assertTrue(String(response.json.data.result.items[0].orderId) !== retainedId, 'new first row is distinct') + }).then(async response => { + const firstId = String(response.json.data.result.items[0].orderId) + report.resources.orderIds.push(firstId); seeded.push(firstId) + await importOrderSet('ATOMIC retry adds exactly first row', seeded) + await verifyDraft(firstId, rows.retry[0], 'ATOMIC first row source facts match') + }) + await ownedOrderSnapshot(module, 'successful ATOMIC replay preserves second row', retainedId, retained) + const invalidCases = [ + ['wrong extension', seedBytes, 'orders.csv'], ['corrupt xlsx', Buffer.from('not-an-xlsx'), 'orders.xlsx'], + ['over five MiB', Buffer.alloc(5 * 1024 * 1024 + 1), 'orders.xlsx'], + ['51 effective rows', orderWorkbookRows(template, Array.from({ length: 51 }, (_, index) => makeRow(`limit-${index}`))), 'orders.xlsx'], + ] + const missingHeader = orderZipRead(orderWorkbookRows(template, [makeRow('header')])) + missingHeader.set('xl/worksheets/sheet1.xml', missingHeader.get('xl/worksheets/sheet1.xml').replace(/]*\br="2"[^>]*>[\s\S]*?<\/row>/, '')) + invalidCases.push(['missing Chinese headers', orderZipWrite(missingHeader), 'orders.xlsx']) + for (const [name, bytes, filename] of invalidCases) { + await upload(`reject ${name}`, 'ATOMIC', newIdempotencyKey('excel-invalid'), bytes, response => { + // Multipart rejects the oversized file before the import service validates its contents. + rejection(name === 'over five MiB' ? 'A0400' : 'A0422')(response) + if (name === 'over five MiB') assertTrue(/上传文件.*(?:超过|大小)/.test(response.json.message), 'explicit upload size rejection') + }, filename) + await importOrderSet(`${name}: order set unchanged`, seeded) + } + const forged = makeRow('forged-catalog'); forged[3] = `FORGED-${marker}` + const tampered = new Map(template) + tampered.set('xl/worksheets/sheet2.xml', tampered.get('xl/worksheets/sheet2.xml').replace('', `${orderXmlEncode(forged[3])}`)) + await upload('forged hidden option is rejected against authoritative catalog', 'ATOMIC', newIdempotencyKey('excel-forged'), + orderWorkbookRows(tampered, [forged]), rejection('A0422')) + await importOrderSet('forged option adds no orders', seeded) + report.orderImportLimitations = [ + 'ATOMIC assertions concern Order rows and their public facts; remote source uploads are not rolled back.', + 'Template selected options are checked; exhaustive warehouse pagination and cross-customer private-address template comparison remain open.', + 'This HTTP matrix does not prove browser import interaction or irreversible order fulfillment cancellation.', + ] +} + async function apiStep(module, name, request, validate, evidenceBuilder = defaultEvidence) { const startedAt = new Date().toISOString() const started = Date.now() diff --git a/scripts/client-workbench-test-env.mjs b/scripts/client-workbench-test-env.mjs new file mode 100644 index 000000000..349452c5a --- /dev/null +++ b/scripts/client-workbench-test-env.mjs @@ -0,0 +1,154 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { resolve, dirname } from 'node:path' +import { pathToFileURL, fileURLToPath } from 'node:url' +import { createHash } from 'node:crypto' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const endpoint = '/merchant/v1/orders/workbench' +class VerificationError extends Error {} +function requireFact(value, message) { if (!value) throw new VerificationError(message) } +const ids = rows => rows.map(row => String(row.orderId)) +const equalIds = (a, b) => JSON.stringify(ids(a).sort()) === JSON.stringify(ids(b).sort()) +const digest = rows => createHash('sha256').update(JSON.stringify(ids(rows).sort())).digest('hex') + +export function executionOptions(args, execArgs, env) { + if (!args.length || args.length === 1 && args[0] === '--plan') return { execute: false } + requireFact(args.length === 3 && args[0] === '--execute' && args[1] === '--environment=TEST' + && /^--run-id=[A-Za-z0-9_-]{1,60}$/.test(args[2]), 'Explicit TEST execution and a new run-id are required') + const files = execArgs.filter(arg => arg.startsWith('--env-file=')) + requireFact(files.length === 1 && execArgs.length === 1 && resolve(files[0].slice(11)).replaceAll('\\', '/') + .endsWith('/.codex/env/chongchong-global/test-acceptance.env'), 'Use the explicitly authorized test-acceptance.env') + requireFact(String(env.CHONGCHONG_ENV).toUpperCase() === 'TEST', 'TEST profile required') + for (const key of ['CHONGCHONG_MERCHANT_API_BASE_URL', 'CHONGCHONG_MERCHANT_USERNAME', 'CHONGCHONG_MERCHANT_PASSWORD']) + requireFact(Boolean(env[key]), `Missing ${key}`) + const base = new URL(env.CHONGCHONG_MERCHANT_API_BASE_URL) + requireFact(['http:', 'https:'].includes(base.protocol) && !base.username && !base.password + && base.pathname === '/' && !base.search && !base.hash, 'Expected the configured TEST Gateway origin') + return { execute: true, runId: args[2].slice(9), base: base.origin + '/api' } +} + +export function validatePage(page, number, size) { + const total = Number(page?.total), pages = Number(page?.pages) + requireFact(Number.isSafeInteger(total) && total >= 0 && Number(page.page) === number + && Number(page.size) === size && pages === Math.ceil(total / size), 'Invalid page metadata') + requireFact(Array.isArray(page.records) && page.records.length === Math.min(size, Math.max(0, total - (number - 1) * size)), 'Truncated page') + requireFact(page.records.every(row => row.orderId != null) && new Set(ids(page.records)).size === page.records.length, 'Missing or duplicate order identity') + return page +} + +/** GET-only verifier. Caller owns authentication and durable, sanitized request evidence. */ +export async function verifyWorkbench(read, customerId) { + const checks = [] + const check = (name, ok) => { requireFact(ok, name); checks.push({ name, passed: true }) } + async function all(path, workbench = false, params = {}) { + let rows = [], expected = null, first + for (let number = 1; ; number++) { + const query = new URLSearchParams({ ...params, page: String(number), size: '200' }) + const response = await read(`${path}?${query}`) + const page = validatePage(workbench ? response.page : response, number, 200) + if (number === 1) { expected = Number(page.total); first = response } + requireFact(Number(page.total) === expected, 'Dataset changed while paging; rerun in a stable window') + requireFact(page.records.every(row => String(row.tenantId) === String(customerId)), 'Customer scope mismatch') + rows.push(...page.records) + if (number >= Number(page.pages)) break + requireFact(number < 1000, 'Pagination safety bound exceeded') + } + requireFact(rows.length === expected && new Set(ids(rows)).size === expected, 'Duplicate or missing orders across pages') + return { rows, first } + } + const workbench = await all(endpoint, true) + check('sample actually exceeds the former 200-order limit', workbench.rows.length > 200) + const formal = await all('/merchant/v1/orders') + check('all formal orders match the independent unified query', equalIds(workbench.rows.filter(r => r.mainStatus !== 'DRAFT'), formal.rows)) + const queues = workbench.first.queues + for (const name of ['all', 'fee', 'customs', 'draft']) { + check(`queue ${name} has a valid count`, Number.isSafeInteger(Number(queues?.[name])) && Number(queues[name]) >= 0) + } + check('all queue count matches complete rows', Number(queues.all) === workbench.rows.length) + check('fee count matches authoritative confirmation flags', Number(queues.fee) === workbench.rows.filter(r => r.customerFeeConfirmationRequired === true).length) + check('draft count matches draft rows', Number(queues.draft) === workbench.rows.filter(r => r.mainStatus === 'DRAFT').length) + for (const queue of ['fee', 'customs', 'draft']) { + const selected = await all(endpoint, true, { queue }) + check(`${queue} complete page count`, selected.rows.length === Number(queues[queue])) + const own = new Set(ids(workbench.rows)) + check(`${queue} contains only listed customer orders`, ids(selected.rows).every(id => own.has(id))) + if (queue === 'fee') check('fee queue exact membership', equalIds(selected.rows, workbench.rows.filter(r => r.customerFeeConfirmationRequired === true))) + if (queue === 'draft') check('draft queue exact membership', equalIds(selected.rows, workbench.rows.filter(r => r.mainStatus === 'DRAFT'))) + } + for (const [page, size] of [[21, 10], [2, 50]]) { + const result = await read(`${endpoint}?page=${page}&size=${size}`) + validatePage(result.page, page, size) + check(`page ${page} size ${size} preserves exact ordered slice`, JSON.stringify(ids(result.page.records)) + === JSON.stringify(ids(workbench.rows.slice((page - 1) * size, page * size)))) + } + const channels = workbench.first.channelNames + check('channel options include every listed channel', Array.isArray(channels) + && workbench.rows.every(r => !r.channelName || channels.includes(r.channelName))) + const channel = workbench.rows.find(r => r.channelName)?.channelName + requireFact(channel, 'A channel-filter sample is required') + const filtered = await all(endpoint, true, { channelName: channel }) + check('channel filter applies before counting and paging', equalIds(filtered.rows, workbench.rows.filter(r => r.channelName === channel))) + const spoofed = await read(`${endpoint}?page=1&size=200&tenantId=0&customerId=0`) + check('spoofed customer parameters cannot broaden scope', equalIds(spoofed.page.records, workbench.first.page.records) + && Number(spoofed.page.total) === workbench.rows.length) + const refreshed = await read(`${endpoint}?page=1&size=200`) + check('dataset stayed stable through verification', Number(refreshed.page.total) === workbench.rows.length + && JSON.stringify(ids(refreshed.page.records)) === JSON.stringify(ids(workbench.first.page.records))) + return { checks, total: workbench.rows.length, formalTotal: formal.rows.length, + membershipSha256: digest(workbench.rows), queues, + limitations: ['customs queue business membership requires separate customs evidence', 'GET evidence does not replace browser or second-customer authorization tests'] } +} + +export async function execute(args = process.argv.slice(2), execArgs = process.execArgv, env = process.env) { + const options = executionOptions(args, execArgs, env) + if (!options.execute) return { plan: true, businessWrites: false, checks: ['complete pagination', 'independent formal-order set', 'queue counts', 'page 21', 'channel filtering', 'customer parameter isolation'], required: 'Deploy Firstleg and Order workbench APIs first; use a stable customer dataset with more than 200 rows' } + const directory = resolve(root, 'reports', `client-workbench-${options.runId}`) + await mkdir(directory) // Exclusive run: existing evidence is never replaced by a rerun. + const report = { startedAt: new Date().toISOString(), readOnlyBusiness: true, success: false, requests: [], sessionClosed: false } + const save = () => writeFile(resolve(directory, 'report.json'), JSON.stringify(report, null, 2)) + let token = '', refresh = '' + async function request(method, path, body) { + requireFact(method === 'GET' || method === 'POST' && ['/auth/login', '/auth/logout'].includes(path), 'Business writes prohibited') + const query = new URLSearchParams(path.split('?')[1] || '') + const entry = { method, path: path.split('?')[0], outcome: 'PENDING', + page: query.get('page') || undefined, size: query.get('size') || undefined, queue: query.get('queue') || undefined, + queryKeys: [...query.keys()], querySha256: createHash('sha256').update(query.toString()).digest('hex') } + report.requests.push(entry); await save() + try { + const response = await fetch(options.base + path, { method, redirect: 'error', signal: AbortSignal.timeout(30000), + headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + body: body ? JSON.stringify(body) : undefined }) + const payload = await response.json() + Object.assign(entry, { outcome: 'RECEIVED', http: response.status, code: payload.code, requestId: payload.requestId, traceId: payload.traceId }) + await save() + const safeCode = /^[A-Z0-9]{1,16}$/.test(String(payload.code)) ? String(payload.code) : 'INVALID_CODE' + requireFact(response.ok && String(payload.code) === '200', `API rejected ${entry.path}: HTTP ${response.status}, code ${safeCode}`) + return payload.data + } catch (error) { if (entry.outcome === 'PENDING') entry.outcome = 'UNKNOWN'; await save(); throw error } + } + try { + const login = await request('POST', '/auth/login', { loginId: env.CHONGCHONG_MERCHANT_USERNAME, password: env.CHONGCHONG_MERCHANT_PASSWORD }) + token = login?.accessToken || ''; refresh = login?.refreshToken || '' + requireFact(token && refresh, 'Incomplete authentication response') + const profile = await request('GET', '/merchant/v1/iam/tenant/profile') + requireFact(profile?.id != null, 'Missing authenticated customer profile') + Object.assign(report, await verifyWorkbench(path => request('GET', path), profile.id)) + report.success = true + } catch (error) { + report.success = false + // Fixed verifier/HTTP classifications only; never persist an arbitrary response body or credential. + report.failure = error instanceof VerificationError ? error.message : 'Transport or response format failure' + } finally { + if (token) { + try { await request('POST', '/auth/logout', { refreshToken: refresh }); report.sessionClosed = true } + catch { report.success = false; report.logoutFailure = true } + } + report.success = report.success && report.sessionClosed + report.completedAt = new Date().toISOString(); await save() + } + return { success: report.success, sessionClosed: report.sessionClosed, report: resolve(directory, 'report.json') } +} +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + execute().then(result => { console.log(JSON.stringify(result)); if (result.success === false) process.exitCode = 1 }) + .catch(() => { console.error('Workbench verification did not start; check explicit TEST profile and new run-id.'); process.exitCode = 1 }) +} diff --git a/scripts/client-workbench-test-env.test.mjs b/scripts/client-workbench-test-env.test.mjs new file mode 100644 index 000000000..4240a7bdd --- /dev/null +++ b/scripts/client-workbench-test-env.test.mjs @@ -0,0 +1,78 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { executionOptions, validatePage, verifyWorkbench } from './client-workbench-test-env.mjs' + +const env = { CHONGCHONG_ENV: 'TEST', CHONGCHONG_MERCHANT_API_BASE_URL: 'https://test.invalid', CHONGCHONG_MERCHANT_USERNAME: 'fixture', CHONGCHONG_MERCHANT_PASSWORD: 'fixture' } +const args = ['--execute', '--environment=TEST', '--run-id=fixture'] +const execArgs = ['--env-file=C:/Users/BY-OY/.codex/env/chongchong-global/test-acceptance.env'] +test('plan has no credential or environment dependency', () => { + assert.deepEqual(executionOptions([], [], {}), { execute: false }) + assert.deepEqual(executionOptions(['--plan'], [], {}), { execute: false }) +}) +test('execution requires explicit TEST profile, safe run id and Gateway origin', () => { + assert.equal(executionOptions(args, execArgs, env).runId, 'fixture') + assert.equal(executionOptions(args, execArgs, env).base, 'https://test.invalid/api') + for (const [a, x, e] of [ + [args, [], env], [args, execArgs, { ...env, CHONGCHONG_ENV: 'PROD' }], + [args, [execArgs[0].replace('test-acceptance', 'prod-acceptance')], env], + [['--execute', '--environment=TEST', '--run-id=../escape'], execArgs, env], + [[...args, '--resume'], execArgs, env], + [args, execArgs, { ...env, CHONGCHONG_MERCHANT_API_BASE_URL: 'https://test.invalid/other' }], + ]) assert.throws(() => executionOptions(a, x, e)) +}) +function fixture() { + const rows = Array.from({ length: 215 }, (_, i) => ({ orderId: String(1000 + i), tenantId: '11', + mainStatus: i < 3 ? 'DRAFT' : 'LOADED', customerFeeConfirmationRequired: i >= 10 && i < 12, + channelName: i % 2 ? 'SEA' : 'AIR' })) + const calls = [] + const queues = list => ({ all: list.length, fee: list.filter(r => r.customerFeeConfirmationRequired).length, + customs: list.filter(r => r.orderId === '1012').length, draft: list.filter(r => r.mainStatus === 'DRAFT').length }) + function read(path) { + calls.push(path) + const url = new URL(path, 'https://test.invalid'), q = url.searchParams + let selected = rows.filter(r => !q.get('channelName') || r.channelName === q.get('channelName')) + const counts = queues(selected) + if (url.pathname === '/merchant/v1/orders') selected = selected.filter(r => r.mainStatus !== 'DRAFT') + if (q.get('queue') === 'fee') selected = selected.filter(r => r.customerFeeConfirmationRequired) + if (q.get('queue') === 'customs') selected = selected.filter(r => r.orderId === '1012') + if (q.get('queue') === 'draft') selected = selected.filter(r => r.mainStatus === 'DRAFT') + const number = Number(q.get('page')), size = Number(q.get('size')) + const page = { page: number, size, total: selected.length, pages: Math.ceil(selected.length / size), records: selected.slice((number - 1) * size, number * size).map(r => ({ ...r })) } + return url.pathname.endsWith('/workbench') ? { page, queues: counts, channelNames: ['AIR', 'SEA'] } : page + } + return { read, calls, rows } +} +test('complete traversal proves beyond-200 data, independent formal membership, queues and filters', async () => { + const h = fixture(), proof = await verifyWorkbench(h.read, '11') + assert.equal(proof.total, 215); assert.equal(proof.formalTotal, 212) + assert.ok(proof.checks.length >= 19); assert.equal(proof.membershipSha256.length, 64) + assert.ok(h.calls.includes('/merchant/v1/orders/workbench?page=21&size=10')) + assert.equal(proof.limitations.length, 2) +}) +for (const [name, mutate, expected] of [ + ['first-200 truncation', (r, path) => { if (path.includes('workbench?') && path.includes('page=1&size=200')) { r.page.total = 200; r.page.pages = 1 } }, /former 200/], + ['wrong page metadata', r => { (r.page ?? r).size = 10 }, /metadata/], + ['customer leakage', r => { (r.page ?? r).records[0].tenantId = '22' }, /Customer scope/], + ['duplicate identity', r => { const p = r.page ?? r; if (p.records.length > 1) p.records[1] = p.records[0] }, /duplicate/], + ['partial response', r => { (r.page ?? r).records.pop() }, /Truncated/], + ['wrong fee count', r => { if (r.queues) r.queues.fee = 99 }, /fee count/], + ['wrong draft count', r => { if (r.queues) r.queues.draft = 0 }, /draft count/], + ['missing channel option', r => { if (r.queues) r.channelNames = ['SEA'] }, /channel options/], + ['wrong page 21 slice', (r, path) => { if (path.includes('page=21&size=10')) r.page.records.reverse() }, /ordered slice/], +]) test(`refuses ${name}`, async () => { + const h = fixture() + await assert.rejects(verifyWorkbench(path => { const response = h.read(path); mutate(response, path); return response }, '11'), expected) +}) +test('changed independent formal set is a failure, not partial success', async () => { + const h = fixture() + await assert.rejects(verifyWorkbench(path => { + const result = h.read(path) + if (!path.includes('/workbench') && path.includes('page=1&') && result.records.length) result.records[0].orderId = 'foreign-formal' + return result + }, '11'), /independent unified/) +}) +test('empty out-of-range pages validate while impossible totals and missing ids fail', () => { + assert.equal(validatePage({ page: 3, size: 10, total: 20, pages: 2, records: [] }, 3, 10).total, 20) + assert.throws(() => validatePage({ page: 1, size: 10, total: -1, pages: 0, records: [] }, 1, 10)) + assert.throws(() => validatePage({ page: 1, size: 10, total: 1, pages: 1, records: [{}] }, 1, 10)) +}) diff --git a/scripts/client-xlsx-address-probe.py b/scripts/client-xlsx-address-probe.py new file mode 100644 index 000000000..4a5622508 --- /dev/null +++ b/scripts/client-xlsx-address-probe.py @@ -0,0 +1,265 @@ +"""Two bounded negative imports; preserve evidence and never retry unknown writes.""" +import hashlib +import io +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.request +import uuid +import zipfile +from datetime import datetime, timezone +from pathlib import Path +from xml.sax.saxutils import escape +from openpyxl import load_workbook + + +def require(ok, message): + if not ok: + raise AssertionError(message) + + +def options(content, title): + book = load_workbook(io.BytesIO(content)) + try: + sheet = book['订单导入'] + headers = {str(c.value).removesuffix(' *'): c.column for c in sheet[2] if c.value} + require(len(headers) == 31 and title in headers, 'Unexpected template headers') + col = headers[title] + validations = [v for v in sheet.data_validations.dataValidation + if any(r.min_col <= col <= r.max_col and r.min_row <= 3 <= r.max_row for r in v.sqref.ranges)] + if not validations: + return [] + require(len(validations) == 1, 'Ambiguous option range') + defined = book.defined_names[validations[0].formula1] + return [c.value for name, span in defined.destinations for row in book[name][span] for c in row] + finally: + book.close() + + +def workbook(content, row): + require(len(row) == 31, 'Expected 31 cells') + output = io.BytesIO() + with zipfile.ZipFile(io.BytesIO(content)) as source, zipfile.ZipFile(output, 'w', zipfile.ZIP_DEFLATED) as target: + for item in source.infolist(): + data = source.read(item.filename) + if item.filename == 'xl/worksheets/sheet1.xml': + xml = data.decode() + headers = re.findall(r']*\br="[12]"[^>]*>[\s\S]*?', xml) + require(len(headers) == 2, 'Both original header rows required') + cells = [] + for index, value in enumerate(row): + col = chr(65 + index) if index < 26 else 'A' + chr(65 + index - 26) + cells.append(f'{escape(str(value))}') + xml, count = re.subn(r']*>[\s\S]*?', + '' + ''.join(headers) + '' + ''.join(cells) + '', xml) + require(count == 1, 'One data sheet required') + data = xml.encode() + target.writestr(item, data) + return output.getvalue() + + +def assert_rejection(body, title): + require(str(body.get('code')) == '200', 'Import failed before row-level validation') + data = body.get('data') or {} + result = data.get('result') or {} + require(data.get('mode') == 'PARTIAL' and data.get('sourceFileId') and data.get('errorReportFileId'), 'Missing import file evidence') + require(result.get('total') == 1 and result.get('succeeded') == 0 and result.get('failed') == 1, 'Unexpected creation result') + items = result.get('items') or [] + require(len(items) == 1, 'Expected exactly one error row') + row = items[0] + require(row.get('success') is False and row.get('clientRowId') == 'excel-row-3' + and all(row.get(k) is None for k in ['orderId', 'orderNo', 'inboundCode']) + and row.get('errorCode') == 'A0422' + and row.get('errorMessage') == title + '不存在、已停用或不属于当前客户', 'Not the precise customer-address rejection') + + +SNAPSHOT_TABLES = ['ord_order', 'ord_order_cargo', 'ord_order_party', 'ord_order_internal_note', + 'ord_order_status_history', 'ord_idempotency_record', 'ord_outbox_event'] + + +def snapshot_sql(columns, identities): + require(len(identities) == 2 and len(set(identities)) == 2 + and all(re.fullmatch(r'[1-9][0-9]{0,19}', x) for x in identities), 'Invalid customer scope') + queries = ['SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;', + 'START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;'] + for table in SNAPSHOT_TABLES: + names = columns.get(table, []) + require(names and len(names) == len(set(names)) and 'id' in names and 'tenant_id' in names + and all(re.fullmatch(r'[a-z][a-z0-9_]*', n) for n in names), 'Incomplete snapshot schema') + fields = ','.join('CAST(`' + n + '` AS CHAR)' for n in names) + where = 'tenant_id IN (' + ','.join(identities) + ')' + queries.append(f"SELECT JSON_OBJECT('table','{table}','hash',SHA2(CAST(JSON_ARRAY({fields}) AS CHAR),256)) FROM `{table}` WHERE {where} ORDER BY id;") + queries.append(f"SELECT JSON_OBJECT('table','{table}','count',COUNT(*)) FROM `{table}` WHERE {where};") + return '\n'.join(queries + ['COMMIT;']) + + +def snapshot_summary(rows): + hashes = {t: [] for t in SNAPSHOT_TABLES} + counts = {} + for row in rows: + table = row.get('table') + require(table in hashes, 'Unexpected snapshot table') + if 'count' in row: + require(table not in counts and isinstance(row['count'], int) and row['count'] >= 0, 'Invalid snapshot count') + counts[table] = row['count'] + else: + require(re.fullmatch(r'[0-9a-f]{64}', row.get('hash', '')), 'Invalid row digest') + hashes[table].append(row['hash']) + require(len(counts) == len(hashes) and all(len(hashes[t]) == counts[t] for t in hashes), 'Truncated snapshot') + return {t: {'count': counts[t], 'sha256': hashlib.sha256('\n'.join(hashes[t]).encode()).hexdigest()} + for t in SNAPSHOT_TABLES} + + +def database_snapshot(config, identities): + def query(sql): + # SQL is sent via stdin; credentials are inherited only by this child process. + result = subprocess.run(['mysql', '--protocol=TCP', '-h', config['host'], '-P', config['port'], + '-u', config['username'], '--batch', '--raw', '--skip-column-names', + '--default-character-set=utf8mb4', '--database=baiyue_order'], + input=sql, text=True, encoding='utf-8', capture_output=True, timeout=30, + env={**os.environ, 'MYSQL_PWD': config['password']}, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0) + require(result.returncode == 0, 'Read-only TEST database query failed') + return [json.loads(line) for line in result.stdout.splitlines() if line.strip()] + selected = ','.join("'" + t + "'" for t in SNAPSHOT_TABLES) + metadata = query("SELECT JSON_OBJECT('table',TABLE_NAME,'column',COLUMN_NAME) FROM information_schema.COLUMNS " + "WHERE TABLE_SCHEMA='baiyue_order' AND TABLE_NAME IN (" + selected + ") ORDER BY TABLE_NAME,ORDINAL_POSITION;") + columns = {} + for row in metadata: + columns.setdefault(row['table'], []).append(row['column']) + return snapshot_summary(query(snapshot_sql(columns, identities))) + + +def main(config): + require(config.get('environment') == 'TEST' and re.fullmatch(r'[A-Za-z0-9_-]{1,60}', config.get('runId', '')), 'Explicit TEST run required') + directory = Path(__file__).resolve().parent.parent / 'reports' / ('client-xlsx-address-' + config['runId']) + directory.mkdir() + report = {'startedAt': datetime.now(timezone.utc).isoformat(), 'success': False, 'requests': [], 'checks': [], 'sessionsClosed': []} + sessions = {} + + def save(): + (directory / 'report.json').write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8') + + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + opener = urllib.request.build_opener(NoRedirect) + + def request(name, method, path, body=None, binary=False, key=None): + require(method == 'GET' or path in ['/auth/login', '/auth/logout', '/merchant/v1/orders/first-leg/batch-imports?mode=PARTIAL'], 'Unexpected write') + entry = {'session': name, 'method': method, 'path': path, 'outcome': 'PENDING'} + headers = {} + if sessions.get(name, {}).get('accessToken'): + headers['Authorization'] = 'Bearer ' + sessions[name]['accessToken'] + if isinstance(body, bytes): + boundary = 'probe-' + uuid.uuid4().hex + entry['workbookSha256'] = hashlib.sha256(body).hexdigest() + body = (f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="address-probe.xlsx"\r\nContent-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\r\n\r\n'.encode() + + body + f'\r\n--{boundary}--\r\n'.encode()) + headers['Content-Type'] = 'multipart/form-data; boundary=' + boundary + headers['Idempotency-Key'] = key + entry['key'] = key + elif body is not None: + body = json.dumps(body).encode() + headers['Content-Type'] = 'application/json' + report['requests'].append(entry) + save() + try: + response = opener.open(urllib.request.Request(config['base'] + path, body, headers, method=method), timeout=30) + with response: + data = response.read() + entry.update(outcome='RECEIVED', http=response.status, requestId=response.headers.get('x-request-id')) + if binary: + require('spreadsheetml' in response.headers.get('content-type', ''), 'Expected workbook content type') + save() + return data + result = json.loads(data) + entry['code'] = result.get('code') + if key: + imported = result.get('data') or {} + rows = (imported.get('result') or {}).get('items') or [] + entry.update(batchNo=imported.get('batchNo'), sourceFileId=imported.get('sourceFileId'), + errorReportFileId=imported.get('errorReportFileId'), + returnedOrderIds=[r['orderId'] for r in rows if r.get('orderId') is not None], + rowErrorCodes=[r.get('errorCode') for r in rows]) + save() + return result + except Exception: + entry['outcome'] = 'UNKNOWN_STOP' if entry['outcome'] == 'PENDING' else 'RECEIVED_INVALID' + save() + raise + + def success(name, method, path, body=None): + result = request(name, method, path, body) + require(str(result.get('code')) == '200', 'Setup/cleanup failed') + return result['data'] + + try: + identities, templates = [], {} + for name in ['source', 'control']: + sessions[name] = success(name, 'POST', '/auth/login', config[name]) + require(sessions[name].get('accessToken') and sessions[name].get('refreshToken'), 'Incomplete session') + profile = success(name, 'GET', '/merchant/v1/iam/tenant/profile') + require(profile.get('id') is not None, 'Missing customer identity') + identities.append(str(profile['id'])) + templates[name] = request(name, 'GET', '/merchant/v1/orders/first-leg/batch-imports/template', binary=True) + require(identities[0] != identities[1], 'Distinct customers required') + report['customerIdentitySha256'] = [hashlib.sha256(x.encode()).hexdigest() for x in identities] + source, target = templates['source'], templates['control'] + common = {} + for title in ['国内收货仓', '运输渠道', 'FBA目的仓']: + shared = [x for x in options(source, title) if x in options(target, title)] + require(shared, 'No shared valid master-data option') + common[title] = shared[0] + if config.get('database'): + report['databaseBefore'] = database_snapshot(config['database'], identities) + save() + for title in ['取货地址', '私人目的仓地址']: + foreign = [x for x in options(source, title) if x not in options(target, title)] + require(foreign, 'No distinct foreign address option') + row = ['海运', '', '自行快递', common['国内收货仓'], common['运输渠道'], '亚马逊FBA', common['FBA目的仓'], '', '', '', + '地址归属测试', '纸箱', '1', '1', '0.001', '塑料', '否', '否', 'ADDRESS-PROBE', '', '', '', + '买单报关', '包税', '散货拼箱', '否', '', '', '普通头程', '', config['runId']] + if title == '取货地址': + row[2], row[9], row[29] = '平台上门提货', '2026-10-10 10:00:00', foreign[0] + else: + row[5], row[6], row[8] = '私人地址', '', foreign[0] + # Use the actual source template, including its foreign hidden option directory. + payload = workbook(source, row) + result = request('control', 'POST', '/merchant/v1/orders/first-leg/batch-imports?mode=PARTIAL', payload, + key='XADDR-' + config['runId'] + '-' + str(len(report['checks']))) + assert_rejection(result, title) + if config.get('database'): + after = database_snapshot(config['database'], identities) + report.setdefault('databaseAfterEachImport', []).append({'field': title, 'tables': after}) + save() + require(after == report['databaseBefore'], 'Order-domain database changed after rejected XLSX import') + report['checks'].append({'field': title, 'passed': True, 'foreignOptionSha256': hashlib.sha256(foreign[0].encode()).hexdigest()}) + save() + report['success'] = True + report['databaseVerified'] = bool(config.get('database')) + report['limitation'] = ('Complete rows in seven Order tables for both customers, including logically deleted rows; ' + 'file/audit/auth writes are outside this unchanged-order-domain assertion; no browser claim' + if config.get('database') else 'XLSX parser rejects foreign customer template choices; source/error files retained; no SQL zero-drift or browser claim') + except Exception as error: + report['failure'] = str(error) if isinstance(error, AssertionError) else 'Transport/setup/format failure; no retry' + finally: + for name, session in sessions.items(): + if session.get('accessToken'): + try: + success(name, 'POST', '/auth/logout', {'refreshToken': session['refreshToken']}) + report['sessionsClosed'].append(name) + except Exception: + report['success'] = False + report['success'] = report['success'] and len(report['sessionsClosed']) == 2 + report['completedAt'] = datetime.now(timezone.utc).isoformat() + save() + print(json.dumps({'success': report['success'], 'checks': len(report['checks'])})) + return report['success'] + + +if __name__ == '__main__': + sys.exit(0 if main(json.load(sys.stdin)) else 1) diff --git a/scripts/client-xlsx-address-probe.test.py b/scripts/client-xlsx-address-probe.test.py new file mode 100644 index 000000000..58009517a --- /dev/null +++ b/scripts/client-xlsx-address-probe.test.py @@ -0,0 +1,85 @@ +import copy +import importlib.util +import io +from pathlib import Path +import unittest +import zipfile + +spec = importlib.util.spec_from_file_location('probe', Path(__file__).with_name('client-xlsx-address-probe.py')) +probe = importlib.util.module_from_spec(spec) +spec.loader.exec_module(probe) + + +class AddressProbeTests(unittest.TestCase): + def test_snapshot_requires_complete_tables_and_row_counts(self): + rows = [{'table': t, 'count': 0} for t in probe.SNAPSHOT_TABLES] + before = probe.snapshot_summary(rows) + for bad in [rows[:-1], rows + [rows[0]], rows + [{'table': probe.SNAPSHOT_TABLES[0], 'hash': 'a' * 64}]]: + with self.assertRaises(AssertionError): + probe.snapshot_summary(bad) + changed = copy.deepcopy(rows) + changed[0]['count'] = 1 + changed.append({'table': probe.SNAPSHOT_TABLES[0], 'hash': 'a' * 64}) + after = probe.snapshot_summary(changed) + self.assertNotEqual(before, after) + changed[-1]['hash'] = 'b' * 64 + self.assertNotEqual(after, probe.snapshot_summary(changed)) + + def test_snapshot_sql_is_read_only_complete_and_customer_scoped(self): + columns = {t: ['id', 'tenant_id', 'deleted', 'version'] for t in probe.SNAPSHOT_TABLES} + sql = probe.snapshot_sql(columns, ['123', '456']) + self.assertIn('START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY', sql) + self.assertEqual(sql.count('tenant_id IN (123,456)'), 14) + self.assertNotIn('deleted=0', sql) + self.assertIn('CAST(`version` AS CHAR)', sql) + for ids in [['123', '123'], ['123 OR 1=1', '456'], ['123']]: + with self.assertRaises(AssertionError): + probe.snapshot_sql(columns, ids) + columns['ord_order'] = ['id', 'unsafe`field'] + with self.assertRaises(AssertionError): + probe.snapshot_sql(columns, ['123', '456']) + + def response(self): + return {'code': '200', 'data': {'mode': 'PARTIAL', 'sourceFileId': 's', 'errorReportFileId': 'e', + 'result': {'total': 1, 'succeeded': 0, 'failed': 1, 'items': [ + {'success': False, 'clientRowId': 'excel-row-3', 'errorCode': 'A0422', + 'errorMessage': '取货地址不存在、已停用或不属于当前客户'}]}}} + + def test_precise_address_rejection(self): + probe.assert_rejection(self.response(), '取货地址') + for change in [{'success': True}, {'orderId': 'created'}, {'errorCode': 'A0404'}, + {'clientRowId': 'excel-row-4'}, {'errorMessage': '国内收货仓不能为空'}]: + changed = copy.deepcopy(self.response()) + changed['data']['result']['items'][0].update(change) + with self.assertRaises(AssertionError): + probe.assert_rejection(changed, '取货地址') + + def test_not_setup_or_partial_success(self): + for part, change in [('root', {'code': 'A0422'}), ('data', {'errorReportFileId': None}), + ('result', {'succeeded': 1}), ('result', {'total': 2})]: + changed = self.response() + value = changed if part == 'root' else changed['data'] if part == 'data' else changed['data']['result'] + value.update(change) + with self.assertRaises(AssertionError): + probe.assert_rejection(changed, '取货地址') + + def test_workbook_preserves_hidden_parts_and_replaces_only_data(self): + raw = io.BytesIO() + sheet = '' + with zipfile.ZipFile(raw, 'w') as z: + z.writestr('xl/worksheets/sheet1.xml', sheet) + z.writestr('xl/worksheets/sheet2.xml', 'source-options') + z.writestr('xl/workbook.xml', 'source-names') + output = probe.workbook(raw.getvalue(), ['<&'] + [''] * 29 + ['marker']) + with zipfile.ZipFile(io.BytesIO(output)) as z: + self.assertEqual(z.read('xl/worksheets/sheet2.xml'), b'source-options') + self.assertEqual(z.read('xl/workbook.xml'), b'source-names') + xml = z.read('xl/worksheets/sheet1.xml').decode() + self.assertIn('r="AE3"', xml) + self.assertIn('<&', xml) + self.assertEqual(xml.count(''), 1) + self.assertIn('', xml) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/client-xlsx-address-test-env.mjs b/scripts/client-xlsx-address-test-env.mjs new file mode 100644 index 000000000..b218d1fbe --- /dev/null +++ b/scripts/client-xlsx-address-test-env.mjs @@ -0,0 +1,44 @@ +import { readFile } from 'node:fs/promises' +import { spawn } from 'node:child_process' +import { parseEnv } from 'node:util' +import { resolve, dirname } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { executionOptions } from './client-workbench-test-env.mjs' +import { customerProfilePath } from './client-import-template-test-env.mjs' + +export async function execute(args = process.argv.slice(2), execArgs = process.execArgv, env = process.env) { + if (!args.length || args.length === 1 && args[0] === '--plan') return { plan: true, + writes: 'Two single-row XLSX PARTIAL imports must reject foreign customer addresses; source/error files may be retained; no retry or deletion' } + if (![5, 6].includes(args.length) || !args[4].startsWith('--python=')) throw Error('Explicit TEST, customer and bundled Python required') + const options = executionOptions(args.slice(0, 3), execArgs, env) + const credentials = parseEnv(await readFile(customerProfilePath(args[3], execArgs[0].slice(11)), 'utf8')) + if (!credentials.MERCHANT_TEST_USERNAME || !credentials.MERCHANT_TEST_PASSWORD) throw Error('Missing control credentials') + const python = resolve(args[4].slice(9)) + if (!python.replaceAll('\\', '/').endsWith('/codex-primary-runtime/dependencies/python/python.exe')) throw Error('Use bundled Python') + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') + const config = { environment: 'TEST', base: options.base, runId: options.runId, + source: { loginId: env.CHONGCHONG_MERCHANT_USERNAME, password: env.CHONGCHONG_MERCHANT_PASSWORD }, + control: { loginId: credentials.MERCHANT_TEST_USERNAME, password: credentials.MERCHANT_TEST_PASSWORD } } + if (args[5]) { + const profile = databaseProfilePath(args[5], execArgs[0].slice(11)) + const database = parseEnv(await readFile(profile, 'utf8')) + if (database.CHONGCHONG_ENV?.toUpperCase() !== 'TEST') throw Error('TEST database required') + for (const key of ['HOST', 'PORT', 'USERNAME', 'PASSWORD']) + if (!database['CHONGCHONG_DB_' + key]) throw Error('Incomplete TEST database configuration') + config.database = Object.fromEntries(['HOST', 'PORT', 'USERNAME', 'PASSWORD'].map(key => [key.toLowerCase(), database['CHONGCHONG_DB_' + key]])) + } + const child = spawn(python, ['-B', resolve(root, 'scripts/client-xlsx-address-probe.py')], + { cwd: root, windowsHide: true, stdio: ['pipe', 'inherit', 'inherit'] }) + child.stdin.end(JSON.stringify(config)) + const code = await new Promise((accept, reject) => { child.on('error', reject); child.on('close', accept) }) + return { success: code === 0, report: resolve(root, 'reports', `client-xlsx-address-${options.runId}`, 'report.json') } +} +export function databaseProfilePath(argument, environmentFile) { + if (!argument.startsWith('--database-profile=')) throw Error('Unknown argument') + const profile = resolve(argument.slice(19)) + if (profile !== resolve(dirname(environmentFile), 'test-database.env')) throw Error('Use explicit test-database.env beside TEST acceptance profile') + return profile +} +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) + execute().then(r => { console.log(JSON.stringify(r)); if (r.success === false) process.exitCode = 1 }) + .catch(() => { console.error('XLSX probe did not start; check explicit TEST inputs and unused run ID.'); process.exitCode = 1 }) diff --git a/scripts/client-xlsx-address-test-env.test.mjs b/scripts/client-xlsx-address-test-env.test.mjs new file mode 100644 index 000000000..6daa22d9e --- /dev/null +++ b/scripts/client-xlsx-address-test-env.test.mjs @@ -0,0 +1,13 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { execute, databaseProfilePath } from './client-xlsx-address-test-env.mjs' +test('XLSX rejection plan performs no network or credential reads', async () => { + assert.equal((await execute(['--plan'], [], {})).plan, true) + await assert.rejects(execute(['--execute'], [], {})) +}) +test('database evidence accepts only the named TEST sibling profile', () => { + const base = 'C:/Users/test/.codex/env/chongchong-global/test-acceptance.env' + assert.match(databaseProfilePath('--database-profile=C:/Users/test/.codex/env/chongchong-global/test-database.env', base), /test-database.env$/) + for (const filename of ['prod-database.env', 'dev-database.env', '../other/test-database.env']) + assert.throws(() => databaseProfilePath('--database-profile=C:/Users/test/.codex/env/chongchong-global/' + filename, base)) +}) diff --git a/scripts/container-logging-contract.test.mjs b/scripts/container-logging-contract.test.mjs index 3fa84cdc5..4c2d75a61 100644 --- a/scripts/container-logging-contract.test.mjs +++ b/scripts/container-logging-contract.test.mjs @@ -5,12 +5,10 @@ import { test } from 'node:test'; const applicationConfigs = [ 'auth/src/main/resources/application.yml', 'audit-service/src/main/resources/application.yml', - 'event-bridge-service/src/main/resources/application.yml', 'file/src/main/resources/application.yml', 'finance/src/main/resources/application.yml', 'firstleg/src/main/resources/application.yml', 'gateway/src/main/resources/application.yml', - 'gateway-overseas/src/main/resources/application.yml', 'iam/src/main/resources/application.yml', 'integration-service/src/main/resources/application.yml', 'lastleg-service/src/main/resources/application.yml', @@ -18,14 +16,11 @@ const applicationConfigs = [ 'print/src/main/resources/application.yml', 'warehouse/src/main/resources/application.yml', 'warehouse-overseas-service/src/main/resources/application.yml', - 'config/nacos/cn/baiyue-common.yml', - 'config/nacos/local/baiyue-common.yml', - 'config/nacos/overseas/baiyue-common.yml', + 'config/nacos/test/baiyue-common.yml', ]; const composeFiles = [ 'deploy/docker-compose.yml', - 'deploy/docker-compose.overseas.yml', ]; const infrastructureComposeFiles = [ diff --git a/scripts/controller-pipeline-contract.test.mjs b/scripts/controller-pipeline-contract.test.mjs index 7680e9048..56f806fe3 100644 --- a/scripts/controller-pipeline-contract.test.mjs +++ b/scripts/controller-pipeline-contract.test.mjs @@ -52,44 +52,33 @@ test('coordinator cannot initialize test databases', () => { assert.doesNotMatch(pipeline, /测试数据库初始化/) }) -test('coordinator defaults event bridge deployments to both regions', () => { - assert.match(pipeline, /choice\(name: 'EVENT_BRIDGE_REGION', choices: \['BOTH', 'CN', 'OVERSEAS'\]/) - assert.match(pipeline, /\['BOTH', 'SELECT', 'CN', 'OVERSEAS'\]\.contains\(bridgeRegion\)/) - assert.match(pipeline, /兼容旧 Jenkins 参数 SELECT:本次按 BOTH/) - assert.doesNotMatch(pipeline, /事件桥 BOTH 仅支持开发或测试环境/) - assert.match(pipeline, /String downstreamImageTag = deployBothBridges[\s\S]*?\? "\$\{env\.IMAGE_TAG\}-both"/) - assert.match(pipeline, /String downstreamBridgeRegion = deployBothBridges[\s\S]*?\? 'CN'/) - assert.match(pipeline, /service == 'event-bridge-service' \? params\.EVENT_BRIDGE_REGION : 'CN'/) - assert.match(pipeline, /string\(name: 'EVENT_BRIDGE_REGION', value: downstreamBridgeRegion\)/) +test('coordinator dispatches exactly the current TEST service map without retired regional units', async () => { + const serviceMap = await readFile(new URL('../deploy/service-map.yml', import.meta.url), 'utf8') + const expected = [...serviceMap.matchAll(/^ ([a-z][a-z0-9-]+):$/gm)].map(match => match[1]).sort() + const declaration = pipeline.match(/def serviceModules = \[([\s\S]*?)\]/)?.[1] || '' + const actual = [...declaration.matchAll(/'([^']+)'/g)].map(match => match[1]).sort() + assert.equal(expected.length, 13) + assert.deepEqual(actual, expected) + assert.doesNotMatch(pipeline, /EVENT_BRIDGE_REGION|gateway-overseas|event-bridge-service/) }) -test('production deploy uses DEPLOY as the single explicit release switch', () => { +test('TEST and gray jobs use fixed independent environment mappings and explicit DEPLOY', () => { assert.match(pipeline, /booleanParam\(name: 'DEPLOY'/) - assert.match(pipeline, /test "\$\(git rev-parse HEAD\)" = "\$\(git rev-parse origin\/main\)"/) - assert.match(pipeline, /env\.RESOLVED_ENVIRONMENT == 'production' && params\.SKIP_TESTS/) + assert.match(pipeline, /case 'backend-java':[\s\S]*?return 'test'/) + assert.match(pipeline, /case 'backend-java-prod':[\s\S]*?return 'production'/) + assert.match(pipeline, /case 'backend-java-prod':[\s\S]*?return 'baiyue-firstleg-prod-'/) + assert.doesNotMatch(pipeline, /return 'development'|backend-java-dev/) + assert.match(pipeline, /booleanParam\(name: 'DEPLOY', value: params\.DEPLOY\)/) assert.doesNotMatch(pipeline, /input message:/) assert.doesNotMatch(pipeline, /\u786e\u8ba4\u90e8\u7f72/) }) -test('production deploy reports one aggregated result to WeCom', () => { - const sourceCommitIndex = pipeline.indexOf("env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD'") - const productionGuardIndex = pipeline.indexOf("if (env.RESOLVED_ENVIRONMENT == 'production')") - - assert.match(pipeline, /credentialsId: 'wecom-prod-webhook'/) - assert.match(pipeline, /node\('java-builder'\) \{[\s\S]*?WECOM_WEBHOOK/) - assert.match(pipeline, /env\.JOB_BASE_NAME == 'backend-java-prod' && params\.DEPLOY/) - assert.ok(sourceCommitIndex >= 0, 'source commit must be recorded for notifications') - assert.ok(sourceCommitIndex < productionGuardIndex, - 'source commit must be recorded before production validation can fail') +test('TEST coordinator records downstream identity and fails the aggregate on any unsuccessful service', () => { + assert.match(pipeline, /env\.SOURCE_COMMIT = sh\(script: 'git rev-parse HEAD'/) assert.match(pipeline, /def downstreamBuildUrls = \[:\]/) assert.match(pipeline, /downstreamBuildUrls\[service\] = downstreamBuild\.absoluteUrl/) assert.match(pipeline, /downstreamBuildNumbers\[service\] = downstreamBuild\.number/) - assert.match(pipeline, /unstable \{[\s\S]*?'\u26a0\ufe0f \u751f\u4ea7\u90e8\u7f72\u90e8\u5206\u5931\u8d25'/) - assert.match(pipeline, /\u7ed3\u679c\uff1a\u6210\u529f \$\{successCount\} \/ \u5931\u8d25 \$\{failedServices\.size\(\)\}/) - assert.match(pipeline, /\u5931\u8d25\u670d\u52a1\uff1a\$\{failedLinks/) - assert.match(pipeline, /failure \{[\s\S]*?'\u274c \u751f\u4ea7\u53d1\u5e03\u5931\u8d25'/) - assert.match(pipeline, /aborted \{[\s\S]*?'\u23f9 \u751f\u4ea7\u90e8\u7f72\u5df2\u53d6\u6d88'/) - assert.match(pipeline, /> \u8017\u65f6\uff1a\$\{duration\}/) - assert.match(pipeline, /\u4f01\u4e1a\u5fae\u4fe1\u901a\u77e5\u5931\u8d25\uff0c\u4e0d\u5f71\u54cd\u539f\u6784\u5efa\u7ed3\u679c/) - assert.doesNotMatch(servicePipeline, /wecom-prod-webhook|sendProductionWeComNotification/) + assert.match(pipeline, /downstreamResults\.findAll \{ service, result -> result != 'SUCCESS' \}/) + assert.match(pipeline, /if \(failed\) \{[\s\S]*?unstable\(/) + assert.doesNotMatch(pipeline + servicePipeline, /wecom-prod-webhook|sendProductionWeComNotification/) }) diff --git a/scripts/create-pending-review-orders-contract.test.mjs b/scripts/create-pending-review-orders-contract.test.mjs index 764b20d91..c6545c7d2 100644 --- a/scripts/create-pending-review-orders-contract.test.mjs +++ b/scripts/create-pending-review-orders-contract.test.mjs @@ -2,7 +2,16 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' -import { PRODUCT_CATALOG, buildOrderPayload, diversifyRoutes, parseArguments, randomOrderOptions, selectSameChannelRoutes, validateCount } from './create-pending-review-orders.mjs' +import { PRODUCT_CATALOG, buildOrderPayload, diversifyRoutes, parseArguments, randomOrderOptions, selectSameChannelRoutes, validateCount, pendingSubmitCommand } from './create-pending-review-orders.mjs' + +test('submission binds original detail identity version and immutable key',()=>{ + const detail={order:{id:'42',version:7}}, command=pendingSubmitCommand(detail,'42','original') + detail.order.version=99 + assert.deepEqual(command,{body:{expectedVersion:7},headers:{'Idempotency-Key':'original'}}) + assert.ok(Object.isFrozen(command.body)) + for(const version of [undefined,null,'7',-1])assert.throws(()=>pendingSubmitCommand({order:{id:'42',version}},'42','key')) + assert.throws(()=>pendingSubmitCommand(detail,'other','key')) +}) const source = readFileSync(new URL('./create-pending-review-orders.mjs', import.meta.url), 'utf8') diff --git a/scripts/create-pending-review-orders.mjs b/scripts/create-pending-review-orders.mjs index 9dc46dad4..742a04e12 100644 --- a/scripts/create-pending-review-orders.mjs +++ b/scripts/create-pending-review-orders.mjs @@ -31,6 +31,13 @@ export const PRODUCT_CATALOG = [ const DEFAULT_COUNT = 50 +export function pendingSubmitCommand(detail, orderId, idempotencyKey) { + if (String(detail?.order?.id) !== String(orderId) || !Number.isSafeInteger(detail?.order?.version) + || detail.order.version < 0 || !idempotencyKey) throw new Error('Missing original order identity/version/key for submit') + return Object.freeze({ body: Object.freeze({ expectedVersion: detail.order.version }), + headers: Object.freeze({ 'Idempotency-Key': idempotencyKey }) }) +} + export function parseArguments(values) { const options = {} const booleanOptions = new Set(['help', 'same-channel']) @@ -428,10 +435,13 @@ export async function main(argv = process.argv.slice(2)) { const orderId = String(requireSuccess(createResponse, `Create order ${index}`) ?? '') if (!orderId) throw new Error(`Create order ${index} did not return orderId`) + const submitDetail = requireSuccess(await request(gateway, timeoutMs, 'GET', + `/api/merchant/v1/orders/first-leg/${encodeURIComponent(orderId)}`, { token }), `Freeze submit version ${index}`) + const submitCommand = pendingSubmitCommand(submitDetail, orderId, `${marker}-submit-${index}`.slice(0, 128)) const submitResponse = await request(gateway, timeoutMs, 'POST', `/api/merchant/v1/orders/first-leg/${encodeURIComponent(orderId)}/submit`, { token, - headers: { 'Idempotency-Key': `${marker}-submit-${index}`.slice(0, 128) }, + ...submitCommand, }) requireSuccess(submitResponse, `Submit order ${index}`) @@ -451,6 +461,7 @@ export async function main(argv = process.argv.slice(2)) { } const result = { index, + submitCommand, orderId, orderNo, clientStatus: detail.order.status, diff --git a/scripts/database-schema-alignment-migrations-contract.test.mjs b/scripts/database-schema-alignment-migrations-contract.test.mjs index 5af5926f3..e5cb45c1d 100644 --- a/scripts/database-schema-alignment-migrations-contract.test.mjs +++ b/scripts/database-schema-alignment-migrations-contract.test.mjs @@ -110,24 +110,25 @@ test('constraint tightening is guarded before any alter table', async () => { } }) -test('数据库迁移计划包含本批四个 schema 的正式目标版本', async () => { +test('数据库迁移计划包含授权版本和事件可靠投递的正式目标版本', async () => { const sqlRoot = path.join(repositoryRoot, 'deploy', 'sql') const scopes = await loadScopes(path.join(sqlRoot, 'migration-scopes.json')) const plan = await buildPlan({ sqlRoot, scopes, regions: ['CN', 'OVERSEAS'], - services: ['iam', 'firstleg', 'lastleg-service', 'warehouse-overseas-service'], + services: ['iam', 'firstleg', 'finance', 'lastleg-service', 'warehouse-overseas-service'], commit: null, }) const targets = Object.fromEntries(plan.regions.flatMap((region) => region.schemas.map((schema) => [schema.schema, schema]))) assert.equal(targets.baiyue_iam.target, '3') - assert.equal(targets.baiyue_firstleg.target, '4') - assert.equal(targets.baiyue_lastleg.target, '5') + assert.equal(targets.baiyue_firstleg.target, '6') + assert.equal(targets.baiyue_finance.target, '2') + assert.equal(targets.baiyue_lastleg.target, '7') assert.equal(targets.baiyue_lastlag_warehouse.target, '2') assert.deepEqual(targets.baiyue_firstleg.migrations.slice(-3).map((migration) => migration.path), [ - 'migrations/cn/baiyue_firstleg/V2__align_measurement_and_fee_schema.sql', - 'migrations/cn/baiyue_firstleg/V3__add_customs_receipt_declaration_schema.sql', 'migrations/cn/baiyue_firstleg/V4__add_sailing_calendar_schema.sql', + 'migrations/cn/baiyue_firstleg/V5__event_delivery_retry_audit.sql', + 'migrations/cn/baiyue_firstleg/V6__event_delivery_attempt.sql', ]) }) diff --git a/scripts/database-schema-diff.mjs b/scripts/database-schema-diff.mjs index 243b8185a..efb92509f 100644 --- a/scripts/database-schema-diff.mjs +++ b/scripts/database-schema-diff.mjs @@ -57,6 +57,13 @@ function sqlStrings(values) { return values.map((value) => `'${value.replaceAll("'", "''")}'`).join(',') } +export function parseMetadataRows(output) { + return output.trim().split(/\r?\n/).filter(Boolean).map((line) => { + if (!/^(?:[0-9a-f]{2})+$/i.test(line)) throw new Error('数据库元数据输出不是有效 HEX JSON') + return JSON.parse(Buffer.from(line, 'hex').toString('utf8')) + }) +} + function runMysql(config, sql) { const executable = process.env.MYSQL_BIN || 'mysql' const result = spawnSync(executable, [ @@ -70,14 +77,14 @@ function runMysql(config, sql) { }) if (result.error) throw new Error(`${config.label} mysql 客户端执行失败:${result.error.message}`) if (result.status !== 0) throw new Error(`${config.label} 元数据查询失败:${(result.stderr || result.stdout).trim()}`) - return result.stdout.trim().split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line)) + return parseMetadataRows(result.stdout) } function snapshot(config, schemas) { const selected = sqlStrings(schemas) - const schemaRows = runMysql(config, `SELECT JSON_OBJECT('schema', SCHEMA_NAME) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME IN (${selected}) ORDER BY SCHEMA_NAME`) - const tableRows = runMysql(config, `SELECT JSON_OBJECT('schema', TABLE_SCHEMA, 'table', TABLE_NAME, 'type', TABLE_TYPE, 'engine', COALESCE(ENGINE, ''), 'collation', COALESCE(TABLE_COLLATION, '')) FROM information_schema.TABLES WHERE TABLE_SCHEMA IN (${selected}) ORDER BY TABLE_SCHEMA, TABLE_NAME`) - const columnRows = runMysql(config, `SELECT JSON_OBJECT('schema', TABLE_SCHEMA, 'table', TABLE_NAME, 'column', COLUMN_NAME, 'ordinal', ORDINAL_POSITION, 'type', COLUMN_TYPE, 'nullable', IS_NULLABLE, 'defaultValue', COLUMN_DEFAULT, 'extra', EXTRA, 'charset', CHARACTER_SET_NAME, 'collation', COLLATION_NAME, 'generation', GENERATION_EXPRESSION) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA IN (${selected}) ORDER BY TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION`) + const schemaRows = runMysql(config, `SELECT HEX(JSON_OBJECT('schema', SCHEMA_NAME)) FROM information_schema.SCHEMATA WHERE SCHEMA_NAME IN (${selected}) ORDER BY SCHEMA_NAME`) + const tableRows = runMysql(config, `SELECT HEX(JSON_OBJECT('schema', TABLE_SCHEMA, 'table', TABLE_NAME, 'type', TABLE_TYPE, 'engine', COALESCE(ENGINE, ''), 'collation', COALESCE(TABLE_COLLATION, ''))) FROM information_schema.TABLES WHERE TABLE_SCHEMA IN (${selected}) ORDER BY TABLE_SCHEMA, TABLE_NAME`) + const columnRows = runMysql(config, `SELECT HEX(JSON_OBJECT('schema', TABLE_SCHEMA, 'table', TABLE_NAME, 'column', COLUMN_NAME, 'ordinal', ORDINAL_POSITION, 'type', COLUMN_TYPE, 'nullable', IS_NULLABLE, 'defaultValue', COLUMN_DEFAULT, 'extra', EXTRA, 'charset', CHARACTER_SET_NAME, 'collation', COLLATION_NAME, 'generation', GENERATION_EXPRESSION)) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA IN (${selected}) ORDER BY TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION`) return { schemas: schemaRows, tables: tableRows, columns: columnRows } } diff --git a/scripts/database-schema-diff.test.mjs b/scripts/database-schema-diff.test.mjs index 91cd6c281..0aa8c0527 100644 --- a/scripts/database-schema-diff.test.mjs +++ b/scripts/database-schema-diff.test.mjs @@ -1,7 +1,20 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { compareSnapshots } from './database-schema-diff.mjs' +import { compareSnapshots, parseMetadataRows } from './database-schema-diff.mjs' + +test('metadata transport preserves Unicode, escaped control characters and expression defaults', () => { + const rows = [{ schema: 'sample', table: 'orders', defaultValue: '国内\r\n海外\t"quote"\\path', generation: "concat('x', '\\n')" }, { defaultValue: null }] + const output = rows.map(row => Buffer.from(JSON.stringify(row), 'utf8').toString('hex')).join('\r\n') + '\r\n' + assert.deepEqual(parseMetadataRows(output), rows) + assert.deepEqual(parseMetadataRows(''), []) +}) + +test('metadata transport rejects invalid or truncated records', () => { + assert.throws(() => parseMetadataRows('abc'), /HEX JSON/) + assert.throws(() => parseMetadataRows('not-json'), /HEX JSON/) + assert.throws(() => parseMetadataRows(Buffer.from('{"schema":').toString('hex')), SyntaxError) +}) test('database schema diff ignores column order but catches contract changes', () => { const left = { diff --git a/scripts/deploy-built-service-rollback.test.mjs b/scripts/deploy-built-service-rollback.test.mjs index e794a23d3..f4f82baae 100644 --- a/scripts/deploy-built-service-rollback.test.mjs +++ b/scripts/deploy-built-service-rollback.test.mjs @@ -45,7 +45,7 @@ case "$command" in printf '%s\\n' starting > "$MOCK_HEALTH_STATE" printf '%s\\n' restarting > "$MOCK_RUNTIME_STATE" else - printf '%s\\n' healthy > "$MOCK_HEALTH_STATE" + printf '%s\\n' "\${MOCK_HEALTH_SUCCESS:-healthy}" > "$MOCK_HEALTH_STATE" printf '%s\\n' running > "$MOCK_RUNTIME_STATE" fi ;; @@ -69,7 +69,7 @@ case "$command" in esac ` -async function runDeployment({ imageTag, failingTag, failingStartTag, restartingTag }) { +async function runDeployment({ imageTag, failingTag, failingStartTag, restartingTag, allowRollback = true, environment = 'test', service = 'audit-service', missingHealth = false, topicPrefix = `${environment}-`, remote = false }) { const temporaryDirectory = await mkdtemp(join(tmpdir(), 'firstleg-rollback-')) const mockBin = join(temporaryDirectory, 'bin') const dockerPath = join(mockBin, 'docker') @@ -82,18 +82,37 @@ async function runDeployment({ imageTag, failingTag, failingStartTag, restarting await mkdir(mockBin) await writeFile(dockerPath, mockDocker, 'utf8') await chmod(dockerPath, 0o755) - await writeFile(envFile, 'PLACEHOLDER=true\n', 'utf8') + if (remote) { + await writeFile(join(mockBin, 'ssh'), `#!/usr/bin/env sh +set -eu +for argument in "$@"; do command="$argument"; done +printf '%s\\n' "$command" >> "$MOCK_SSH_LOG" +exec sh -c "$command" +`) + await writeFile(join(mockBin, 'scp'), `#!/usr/bin/env sh +set -eu +while [ "$#" -gt 2 ]; do shift; done +cp "$1" "\${2#*:}" +`) + await chmod(join(mockBin, 'ssh'), 0o755) + await chmod(join(mockBin, 'scp'), 0o755) + await writeFile(join(temporaryDirectory, 'key'), 'fixture') + await writeFile(join(temporaryDirectory, 'known-hosts'), 'fixture') + } + + await writeFile(envFile, ['NACOS_SERVER_ADDR=nacos:8848','NACOS_NAMESPACE=test-id','NACOS_USERNAME=fixture','NACOS_PASSWORD=fixture','REDIS_HOST=redis','REDIS_PORT=6379','REDIS_DATABASE=0','REDIS_TOKEN_KEY_PREFIX=baiyue:auth','INTERNAL_SERVICE_TOKEN=fixture','JWT_RSA_JWK_SET=fixture','JWT_CURRENT_KID=fixture','ROCKETMQ_NAME_SERVER=namesrv:9876',`MQ_TOPIC_PREFIX=${topicPrefix}`,'AUDIT_DB_URL=jdbc:mysql://mysql/audit','AUDIT_DB_USERNAME=fixture','AUDIT_DB_PASSWORD=fixture',''].join('\n'), 'utf8') await writeFile(logFile, '', 'utf8') await writeFile(currentImageFile, 'registry.local/group/firstleg/audit-service:oldgood\n', 'utf8') await writeFile(healthStateFile, 'healthy\n', 'utf8') await writeFile(runtimeStateFile, 'running\n', 'utf8') const result = spawnSync(shell, [ + '-c', 'PATH="$MOCK_BIN:$PATH"; export PATH; exec sh "$@"', 'fixture', deployScript.replaceAll('\\', '/'), - 'development', + environment, 'registry.local/group/firstleg', imageTag, - 'audit-service', + service, envFile.replaceAll('\\', '/') ], { cwd: repositoryRoot, @@ -101,11 +120,18 @@ async function runDeployment({ imageTag, failingTag, failingStartTag, restarting env: { ...process.env, PATH: `${mockBin}${delimiter}${process.env.PATH ?? ''}`, - TARGET_IS_LOCAL: 'true', + MOCK_BIN: mockBin.replaceAll('\\', '/').replace(/^([A-Za-z]):/, (_, drive) => `/${drive.toLowerCase()}`), + TARGET_IS_LOCAL: remote ? 'false' : 'true', + TARGET_SSH_TARGET: 'fixture@fixture', + TARGET_SSH_KEY: join(temporaryDirectory, 'key').replaceAll('\\', '/'), + TARGET_SSH_KNOWN_HOSTS: join(temporaryDirectory, 'known-hosts').replaceAll('\\', '/'), + MOCK_SSH_LOG: join(temporaryDirectory, 'ssh.log').replaceAll('\\', '/'), + ALLOW_SAME_CONTRACT_ROLLBACK: String(allowRollback), REGISTRY_USER: 'ci-user', REGISTRY_PASSWORD: 'ci-password', DEPLOY_HEALTH_TIMEOUT_SECONDS: '2', DEPLOY_HEALTH_POLL_SECONDS: '1', + MOCK_HEALTH_SUCCESS: missingHealth ? 'no-health:running' : 'healthy', MOCK_LOG: logFile, MOCK_CURRENT_IMAGE: currentImageFile, MOCK_HEALTH_STATE: healthStateFile, @@ -118,8 +144,9 @@ async function runDeployment({ imageTag, failingTag, failingStartTag, restarting const dockerLog = await readFile(logFile, 'utf8') const currentImage = (await readFile(currentImageFile, 'utf8')).trim() + const sshLog = remote ? await readFile(join(temporaryDirectory, 'ssh.log'), 'utf8').catch(() => '') : '' await rm(temporaryDirectory, { recursive: true, force: true }) - return { ...result, dockerLog, currentImage } + return { ...result, dockerLog, currentImage, sshLog } } test('successful deployment keeps the new healthy image', async (context) => { @@ -173,3 +200,61 @@ test('restart loops fail fast and restore the previous image', async (context) = assert.match(output, /自动回滚成功: registry\.local\/group\/firstleg\/audit-service:oldgood/) assert.equal(result.currentImage, 'registry.local/group/firstleg/audit-service:oldgood') }) + +test('architecture switch failure does not roll back one old service', async () => { + const result = await runDeployment({imageTag:'newbad', failingTag:'newbad', allowRollback:false}) + assert.equal(result.status,1) + assert.doesNotMatch(result.dockerLog,/pull .*:oldgood/) + assert.match(result.stderr,/Automatic rollback disabled/) +}) +test('unsupported environments and retired services fail before Docker', async () => { + for(const options of [{environment:'development'},{service:'event-bridge-service'},{service:'gateway-overseas'}]){ + const result=await runDeployment({imageTag:'newgood',...options}) + assert.equal(result.status,2) + assert.equal(result.dockerLog,'') + } +}) + +test('running without HEALTHCHECK cannot be counted as deployed',async()=>{ + const result=await runDeployment({imageTag:'newgood',missingHealth:true,allowRollback:false}) + assert.equal(result.status,1) + assert.match(result.stderr,/Missing HEALTHCHECK/) +}) +test('SQL initializer rejects absent authorization before any Docker call',()=>{ + const result=spawnSync(shell,[resolve(repositoryRoot,'deploy/scripts/initialize-test-databases.sh').replaceAll('\\','/')],{encoding:'utf8',env:{...process.env,ALLOW_TEST_SCHEMA_INITIALIZATION:'false'}}) + assert.equal(result.status,2) + assert.match(result.stderr,/Explicit TEST SQL authorization required/) +}) + + +test('gray deploy preserves health checks and uses an isolated Compose project', async () => { + const result = await runDeployment({ imageTag: 'graygood', environment: 'production', allowRollback: false }) + assert.equal(result.status, 0, result.stderr) + assert.match(result.dockerLog, /--project-name firstleg-production/) + assert.doesNotMatch(result.dockerLog, /--project-name firstleg-test/) +}) + +test('gray deployment rejects TEST message scope before any Docker operation', async () => { + const result = await runDeployment({ imageTag: 'graygood', environment: 'production', topicPrefix: 'test-' }) + assert.equal(result.status, 2) + assert.equal(result.dockerLog, '') + assert.match(result.stderr, /MQ_TOPIC_PREFIX must be production-/) +}) + + +test('SSH gray delivery executes the same health gate and cleans its unique temporary bundle', async () => { + const result = await runDeployment({ imageTag: 'remotegray', environment: 'production', allowRollback: false, remote: true }) + assert.equal(result.status, 0, result.stderr) + assert.equal(result.currentImage, 'registry.local/group/firstleg/audit-service:remotegray') + assert.match(result.dockerLog, /--project-name firstleg-production/) + assert.match(result.sshLog, /rm -rf -- '\/tmp\/firstleg-production-release\.[A-Za-z0-9]+'/) + assert.doesNotMatch(result.sshLog, /ci-password/) +}) + +test('SSH gray health failure remains failed and still removes the uploaded credential bundle', async () => { + const result = await runDeployment({ imageTag: 'remotebad', failingTag: 'remotebad', environment: 'production', allowRollback: false, remote: true }) + assert.equal(result.status, 1, result.stderr) + assert.match(result.stderr, /Automatic rollback disabled/) + assert.match(result.sshLog, /rm -rf -- '\/tmp\/firstleg-production-release\.[A-Za-z0-9]+'/) + assert.doesNotMatch(result.sshLog, /ci-password/) +}) diff --git a/scripts/event-delivery-fault-proxy.py b/scripts/event-delivery-fault-proxy.py new file mode 100644 index 000000000..b853ee601 --- /dev/null +++ b/scripts/event-delivery-fault-proxy.py @@ -0,0 +1,333 @@ +"""Explicitly selected test-event ACK loss. Does not create or replay events. + +Run with an explicit consumer URL, event type and exactly one event selector. +EVENT_FAULT_CONTROL_TOKEN protects the separate loopback control listener. +Default PASS; authenticated POST /mode {"mode":"FAULT"} enables ACK loss. +The parent operator owns producer configuration, DEAD/retry and restoration. +""" + +import argparse +import hashlib +import hmac +import json +import os +import re +import signal +import threading +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +MAX_BODY = 8 * 1024 * 1024 +EVENT_ID = re.compile(r"(?:[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}|[0-9A-HJKMNP-TV-Z]{26})\Z") +HASH = re.compile(r"sha256:[0-9a-fA-F]{64}\Z") +HOP_HEADERS = {"connection", "keep-alive", "proxy-authenticate", "proxy-authorization", + "te", "trailer", "transfer-encoding", "upgrade", "host", "content-length"} + + +def encoded(value): + return json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + + +class State: + def __init__(self, event_type, event_id, loading_plan_id, log_path=None, container_booking_id=None, delivery_id=None, + fee_source_id=None, source_version=None): + self.event_type = event_type + self.selector_id = event_id + self.plan_id = loading_plan_id + self.booking_id = container_booking_id + self.delivery_id = delivery_id + self.fee_source_id = fee_source_id + self.source_version = source_version + self.log_path = log_path + self.lock = threading.Lock() + self.mode = "PASS" + self.event_id = None + self.payload_hash = None + self.envelope_hash = None + self.attempt = 0 + self.records = [] + + def select(self, body): + try: + event = json.loads(body) + except (ValueError, UnicodeError): + return None, False, None, 0, self.mode + if not isinstance(event, dict): + return None, False, None, 0, self.mode + event_id, payload_hash = event.get("eventId"), event.get("payloadHash") + # Only valid bounded identifiers may enter evidence, never arbitrary payload strings. + if not isinstance(event_id, str) or not EVENT_ID.fullmatch(event_id): + return None, False, None, 0, self.mode + if not isinstance(payload_hash, str) or not HASH.fullmatch(payload_hash): + with self.lock: + if self.event_id == event_id: + self.attempt += 1 + return {"eventId": event_id, "payloadHash": self.payload_hash}, True, \ + "TARGET_EVENT_HASH_CONFLICT", self.attempt, self.mode + return None, False, None, 0, self.mode + identity = {"eventId": event_id, "payloadHash": payload_hash.lower()} + payload = event.get("payload") + if self.selector_id: + selected = event_id == self.selector_id + elif self.booking_id: + selected = isinstance(payload, dict) and str(payload.get("sourceContainerBookingId")) == self.booking_id + elif self.delivery_id: + selected = isinstance(payload, dict) and str(payload.get("deliveryOrderId")) == self.delivery_id + elif self.fee_source_id: + selected = (isinstance(payload, dict) and str(payload.get("sourceFeeId")) == self.fee_source_id + and str(payload.get("sourceVersion")) == self.source_version and payload.get("action") == "VOIDED") + else: + selected = isinstance(payload, dict) and str(payload.get("loadingPlanId")) == self.plan_id + qualifies = event.get("eventType") == self.event_type and selected + envelope_hash = hashlib.sha256(json.dumps(event, sort_keys=True, ensure_ascii=False, + separators=(",", ":")).encode("utf-8")).hexdigest() + with self.lock: + # Reusing the pinned ID with another hash/content/type is rejected even if a + # changed selector field would otherwise make the request look unrelated. + if self.event_id == event_id and (self.payload_hash != payload_hash.lower() + or self.envelope_hash != envelope_hash): + self.attempt += 1 + return identity, True, "TARGET_EVENT_HASH_CONFLICT", self.attempt, self.mode + if not qualifies or (self.event_id is not None and self.event_id != event_id): + return identity, False, None, 0, self.mode + if self.event_id is None: + self.event_id, self.payload_hash, self.envelope_hash = event_id, payload_hash.lower(), envelope_hash + self.attempt += 1 + return identity, True, None, self.attempt, self.mode + + def record(self, identity, matched, attempt, outcome, error_code): + record = {"time": datetime.now(timezone.utc).isoformat(), + "eventId": identity.get("eventId") if identity else None, + "payloadHash": identity.get("payloadHash") if identity else None, + "outcome": outcome, "attempt": attempt, "matched": matched, "errorCode": error_code} + with self.lock: + # Store only target evidence; unrelated delivery payloads/identities are not retained. + if matched: + self.records.append(record) + if self.log_path: + with open(self.log_path, "a", encoding="utf-8") as log: + log.write(json.dumps(record, separators=(",", ":")) + "\n") + + def snapshot(self): + with self.lock: + return {"mode": self.mode, "eventId": self.event_id, "payloadHash": self.payload_hash, + "attempt": self.attempt, "records": list(self.records)} + + +class QuietHandler(BaseHTTPRequestHandler): + def log_message(self, _format, *_args): + pass + + def reply(self, status, body, content_type="application/json", headers=None): + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + for name, value in (headers or {}).items(): + if name.lower() not in HOP_HEADERS | {"content-type"}: + self.send_header(name, value) + self.end_headers() + self.wfile.write(body) + + def safe_error(self, status, code): + self.reply(status, encoded({"errorCode": code})) + + def read_body(self): + try: + length = int(self.headers.get("Content-Length", "-1")) + except ValueError: + length = -1 + if not 0 <= length <= MAX_BODY: + self.safe_error(413, "INVALID_BODY_LENGTH") + return None + body = self.rfile.read(length) + if len(body) != length: + self.safe_error(400, "INCOMPLETE_BODY") + return None + return body + + +def data_handler(state, upstream, timeout): + parts = urllib.parse.urlsplit(upstream) + endpoint = urllib.parse.urlunsplit(("", "", parts.path or "/", parts.query, "")) + # Both consumers share FINANCE_SERVICE_URL. Preserve wallet delivery while + # targeting one fee event; never select wallet bodies for fault injection. + wallet_endpoint = "/internal/finance/v1/wallet-command-events" + wallet_passthrough = (state.fee_source_id is not None + and endpoint == "/internal/finance/v1/business-fee-source-events") + + class DataHandler(QuietHandler): + def do_POST(self): + passthrough = wallet_passthrough and self.path == wallet_endpoint + if self.path != endpoint and not passthrough: + self.safe_error(404, "UNEXPECTED_EVENT_ENDPOINT") + return + body = self.read_body() + if body is None: + return + identity, matched, error_code, attempt, mode = (None, False, None, 0, "PASS") if passthrough else state.select(body) + if error_code: + state.record(identity, matched, attempt, None, error_code) + self.safe_error(409, error_code) + return + # Forward the exact body and end-to-end headers. Never persist auth headers. + connection_headers = set(self.headers.get("Connection", "").lower().replace(" ", "").split(",")) + headers = {name: value for name, value in self.headers.items() + if name.lower() not in HOP_HEADERS | connection_headers} + destination = urllib.parse.urlunsplit((parts.scheme, parts.netloc, wallet_endpoint, "", "")) if passthrough else upstream + request = urllib.request.Request(destination, data=body, headers=headers, method="POST") + try: + # Redirects are forbidden: a redirect could expose a service identity token. + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *_args, **_kwargs): + return None + opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()) + try: + consumer = opener.open(request, timeout=timeout) + except urllib.error.HTTPError as error: + consumer = error + with consumer: + status = consumer.code + result = consumer.read(MAX_BODY + 1) + result_headers = dict(consumer.headers.items()) + except (OSError, urllib.error.URLError): + state.record(identity, matched, attempt, None, "UPSTREAM_TRANSPORT_ERROR") + self.safe_error(502, "UPSTREAM_TRANSPORT_ERROR") + return + if len(result) > MAX_BODY: + state.record(identity, matched, attempt, None, "UPSTREAM_BODY_TOO_LARGE") + self.safe_error(502, "UPSTREAM_BODY_TOO_LARGE") + return + outcome = None + if matched: + try: + wrapper = json.loads(result) + ack = wrapper.get("data") + valid = status == 200 and str(wrapper.get("code")) == "200" and isinstance(ack, dict) \ + and ack.get("eventId") == identity["eventId"] \ + and str(ack.get("payloadHash", "")).lower() == identity["payloadHash"] \ + and ack.get("outcome") in {"APPLIED", "DUPLICATE"} + if valid: + outcome = ack["outcome"] + except (ValueError, UnicodeError, AttributeError): + valid = False + if not valid: + state.record(identity, matched, attempt, None, "UPSTREAM_ACK_INVALID") + self.safe_error(502, "UPSTREAM_ACK_INVALID") + return + if mode == "FAULT": + state.record(identity, matched, attempt, outcome, "ACK_LOSS_INJECTED") + self.safe_error(503, "ACK_LOSS_INJECTED") + return + state.record(identity, matched, attempt, outcome, None) + self.reply(status, result, result_headers.get("Content-Type", "application/json"), result_headers) + + return DataHandler + + +def control_handler(state, control_token): + class ControlHandler(QuietHandler): + def authorized(self): + received = self.headers.get("X-Event-Fault-Control-Token", "") + if not hmac.compare_digest(received.encode(), control_token.encode()): + self.safe_error(403, "CONTROL_FORBIDDEN") + return False + return True + + def do_GET(self): + if not self.authorized(): + return + if self.path != "/status": + self.safe_error(404, "UNKNOWN_CONTROL_ENDPOINT") + return + self.reply(200, encoded(state.snapshot())) + + def do_POST(self): + if not self.authorized(): + return + if self.path != "/mode": + self.safe_error(404, "UNKNOWN_CONTROL_ENDPOINT") + return + body = self.read_body() + if body is None: + return + try: + value = json.loads(body) + mode = value["mode"] + if mode not in {"PASS", "FAULT"}: + raise ValueError() + except (ValueError, KeyError, TypeError, UnicodeError): + self.safe_error(400, "INVALID_MODE") + return + with state.lock: + state.mode = mode + self.reply(200, encoded(state.snapshot())) + + return ControlHandler + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--upstream", required=True) + parser.add_argument("--event-type", required=True) + selector = parser.add_mutually_exclusive_group(required=True) + selector.add_argument("--event-id") + selector.add_argument("--loading-plan-id") + selector.add_argument("--container-booking-id") + selector.add_argument("--delivery-id") + selector.add_argument("--fee-source-id") + parser.add_argument("--source-version") + parser.add_argument("--bind", default="127.0.0.1") + parser.add_argument("--port", type=int, default=0) + parser.add_argument("--control-port", type=int, default=0) + parser.add_argument("--timeout", type=float, default=20) + parser.add_argument("--log") + args = parser.parse_args() + parts = urllib.parse.urlsplit(args.upstream) + token = os.environ.get("EVENT_FAULT_CONTROL_TOKEN", "") + if parts.scheme not in {"http", "https"} or not parts.hostname or parts.username or parts.password or parts.fragment: + parser.error("upstream must be an explicit HTTP consumer endpoint without credentials or fragment") + if len(token) < 24: + parser.error("EVENT_FAULT_CONTROL_TOKEN must have at least 24 characters") + if args.event_id and not EVENT_ID.fullmatch(args.event_id): + parser.error("event-id must be a UUID or ULID") + if args.loading_plan_id and not re.fullmatch(r"[1-9][0-9]{0,19}", args.loading_plan_id): + parser.error("loading-plan-id must be a positive decimal identifier") + if args.container_booking_id and not re.fullmatch(r"[1-9][0-9]{0,19}", args.container_booking_id): + parser.error("container-booking-id must be a positive decimal identifier") + if args.delivery_id and not re.fullmatch(r"[1-9][0-9]{0,19}", args.delivery_id): + parser.error("delivery-id must be a positive decimal identifier") + if bool(args.fee_source_id) != bool(args.source_version): + parser.error("fee-source-id and source-version are required together") + if args.fee_source_id and (not re.fullmatch(r"[1-9][0-9]{0,19}", args.fee_source_id) + or not re.fullmatch(r"[1-9][0-9]{0,9}", args.source_version) + or int(args.source_version) > 2147483647 + or args.event_type != "BusinessFeeSourceChanged"): + parser.error("fee void requires valid source identity, version and BusinessFeeSourceChanged") + if not re.fullmatch(r"[A-Za-z][A-Za-z0-9]{0,79}", args.event_type) or not 0 < args.timeout <= 120: + parser.error("invalid event type or timeout") + state = State(args.event_type, args.event_id, args.loading_plan_id, args.log, args.container_booking_id, args.delivery_id, + args.fee_source_id, args.source_version) + data = ThreadingHTTPServer((args.bind, args.port), data_handler(state, args.upstream, args.timeout)) + control = ThreadingHTTPServer(("127.0.0.1", args.control_port), control_handler(state, token)) + for server in (data, control): + server.daemon_threads = True + stop = threading.Event() + for number in (signal.SIGINT, signal.SIGTERM): + signal.signal(number, lambda *_args: stop.set()) + threads = [threading.Thread(target=server.serve_forever, daemon=True) for server in (data, control)] + for thread in threads: + thread.start() + print(json.dumps({"ready": True, "dataPort": data.server_port, "controlPort": control.server_port}), flush=True) + stop.wait() + for server in (data, control): + server.shutdown() + server.server_close() + for thread in threads: + thread.join() + + +if __name__ == "__main__": + main() diff --git a/scripts/event-delivery-fault-proxy.test.mjs b/scripts/event-delivery-fault-proxy.test.mjs new file mode 100644 index 000000000..0d3480724 --- /dev/null +++ b/scripts/event-delivery-fault-proxy.test.mjs @@ -0,0 +1,312 @@ +import assert from 'node:assert/strict' +import { spawn, spawnSync } from 'node:child_process' +import { createServer } from 'node:http' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, dirname, basename, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import test from 'node:test' + +const script = fileURLToPath(new URL('./event-delivery-fault-proxy.py', import.meta.url)) +const targetId = 'e6259b1a-e112-4233-83e1-ae0071db3c52' +const otherId = '1b344fb7-d5d9-4324-b2d1-51bd0ab3af55' +const samePlanOtherId = '05dab8d1-10fd-4c9e-b242-54f4c6ff76a4' +const hash = `sha256:${'a'.repeat(64)}` +const secret = 'test-service-identity-secret-do-not-log' +const controlToken = 'test-control-secret-with-more-than-24-characters' +const event = (id = targetId, planId = '2100000000000000001') => ({ eventId: id, + eventType: 'LoadingCompleted', payloadHash: hash, + payload: { loadingPlanId: planId, syntheticPrivateField: secret } }) +const close = server => new Promise(resolve => server.close(resolve)) + +async function fixture(selector = ['--loading-plan-id', '2100000000000000001'], eventType = 'LoadingCompleted', endpoint = '/internal/events') { + const calls = [] + const applied = new Set() + let invalidAck = false + const consumer = createServer(async (request, response) => { + const chunks = [] + for await (const chunk of request) chunks.push(chunk) + const raw = Buffer.concat(chunks).toString() + const envelope = JSON.parse(raw) + calls.push({ raw, authorization: request.headers.authorization, eventId: envelope.eventId, path: request.url }) + const outcome = applied.has(envelope.eventId) ? 'DUPLICATE' : 'APPLIED' + applied.add(envelope.eventId) + const body = JSON.stringify({ code: '200', data: { eventId: invalidAck ? otherId : envelope.eventId, + payloadHash: envelope.payloadHash, outcome } }) + response.writeHead(200, { 'Content-Type': 'application/json', 'X-Consumer-Evidence': 'actual-upstream' }) + response.end(body) + }) + await new Promise(resolve => consumer.listen(0, '127.0.0.1', resolve)) + const directory = await mkdtemp(join(tmpdir(), 'event-fault-offline-')) + const log = join(directory, 'evidence.jsonl') + const child = spawn(process.env.PYTHON || 'python', [script, '--upstream', + `http://127.0.0.1:${consumer.address().port}${endpoint}`, '--event-type', eventType, + ...selector, '--log', log], { env: { ...process.env, EVENT_FAULT_CONTROL_TOKEN: controlToken }, + stdio: ['ignore', 'pipe', 'pipe'] }) + let output = ''; let errors = '' + child.stdout.on('data', chunk => { output += chunk }) + child.stderr.on('data', chunk => { errors += chunk }) + const ready = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { child.kill(); reject(new Error('Proxy readiness timeout')) }, 8000) + const onData = () => { + const line = output.split('\n').find(item => item.trim()) + if (!line) return + try { const value = JSON.parse(line); clearTimeout(timer); child.stdout.off('data', onData); resolve(value) } + catch { clearTimeout(timer); reject(new Error('Invalid proxy readiness JSON')) } + } + child.stdout.on('data', onData) + child.once('error', error => { clearTimeout(timer); reject(error) }) + child.once('exit', code => { if (!output) { clearTimeout(timer); reject(new Error(`Proxy exit ${code}`)) } }) + }) + const dataUrl = `http://127.0.0.1:${ready.dataPort}${endpoint}` + const controlUrl = `http://127.0.0.1:${ready.controlPort}` + return { calls, applied, log, invalidAck: value => { invalidAck = value }, + send: async (envelope, path = endpoint) => { + const sent = JSON.stringify(envelope) + const response = await fetch(new URL(path, dataUrl), { method: 'POST', body: sent, + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${secret}` } }) + return { status: response.status, raw: await response.text(), headers: response.headers, sent } + }, + control: (path, body, token = controlToken) => fetch(`${controlUrl}${path}`, { + method: body ? 'POST' : 'GET', headers: { 'X-Event-Fault-Control-Token': token, + 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined, + }), + output: () => `${output}\n${errors}`, + stop: async () => { + if (child.exitCode === null) { + const exited = new Promise(resolve => child.once('exit', resolve)) + child.kill(); await exited + } + await close(consumer) + assert.equal(dirname(resolve(directory)), resolve(tmpdir())) + assert.ok(basename(directory).startsWith('event-fault-offline-')) + await rm(directory, { recursive: true }) + }, + } +} + +test('fee fault preserves wallet endpoint bytes and auth without selecting its body', async t => { + const proxy = await fixture(['--fee-source-id', '2100000000000000001', '--source-version', '1'], + 'BusinessFeeSourceChanged', '/internal/finance/v1/business-fee-source-events') + t.after(() => proxy.stop()) + await proxy.control('/mode', { mode: 'FAULT' }) + const envelope = { ...event(), eventType: 'BusinessFeeSourceChanged', + payload: { sourceFeeId: '2100000000000000001', sourceVersion: 1, action: 'VOIDED' } } + const walletPath = '/internal/finance/v1/wallet-command-events' + const wallet = await proxy.send(envelope, walletPath) + assert.equal(wallet.status, 200) + assert.equal(proxy.calls[0].path, walletPath) + assert.equal(proxy.calls[0].raw, wallet.sent) + assert.equal(proxy.calls[0].authorization, `Bearer ${secret}`) + assert.equal((await (await proxy.control('/status')).json()).attempt, 0) + assert.equal((await proxy.send(envelope, '/internal/finance/v1/unknown')).status, 404) + assert.equal(proxy.calls.length, 1) + assert.equal((await proxy.send(envelope)).status, 503) + assert.equal((await (await proxy.control('/status')).json()).attempt, 1) +}) + +test('ACK-loss proxy only targets one event and forwards committed upstream results before dropping ACK', async t => { + const proxy = await fixture() + t.after(() => proxy.stop()) + const unauthorized = await proxy.control('/mode', { mode: 'FAULT' }, 'wrong-control-token') + assert.equal(unauthorized.status, 403) + let status = await (await proxy.control('/status')).json() + assert.equal(status.mode, 'PASS') + assert.equal((await proxy.control('/mode', { mode: 'FAULT' })).status, 200) + + await t.test('real consumer is reached and durable APPLIED then DUPLICATE both lose ACK', async () => { + const first = await proxy.send(event()) + assert.equal(first.status, 503) + assert.equal(JSON.parse(first.raw).errorCode, 'ACK_LOSS_INJECTED') + assert.ok(proxy.applied.has(targetId)) + const duplicate = await proxy.send(event()) + assert.equal(duplicate.status, 503) + assert.equal(proxy.calls.length, 2) + assert.equal(proxy.calls[0].raw, first.sent) + assert.equal(proxy.calls[0].authorization, `Bearer ${secret}`) + status = await (await proxy.control('/status')).json() + assert.deepEqual(status.records.map(item => item.outcome), ['APPLIED', 'DUPLICATE']) + assert.deepEqual(status.records.map(item => item.attempt), [1, 2]) + }) + + await t.test('other plans, other types and another same-plan ID are unaffected', async () => { + for (const envelope of [event(otherId, '2100000000000000002'), + { ...event(otherId), eventType: 'CarrierScheduleNodeUpserted' }, event(samePlanOtherId)]) { + const result = await proxy.send(envelope) + assert.equal(result.status, 200) + assert.equal(JSON.parse(result.raw).data.eventId, envelope.eventId) + assert.equal(result.headers.get('x-consumer-evidence'), 'actual-upstream') + } + status = await (await proxy.control('/status')).json() + assert.equal(status.attempt, 2) + assert.equal(status.records.length, 2) + }) + + await t.test('pinned identity rejects hash, hidden payload and malformed-hash changes before upstream', async () => { + const before = proxy.calls.length + for (const envelope of [{ ...event(), payloadHash: `sha256:${'b'.repeat(64)}` }, + { ...event(), payload: { loadingPlanId: '2100000000000000002', changed: secret } }, + { ...event(), payloadHash: secret }]) { + const result = await proxy.send(envelope) + assert.equal(result.status, 409) + assert.equal(JSON.parse(result.raw).errorCode, 'TARGET_EVENT_HASH_CONFLICT') + } + assert.equal(proxy.calls.length, before) + }) + + await t.test('PASS forwards matching duplicate ACK and original upstream response bytes and headers', async () => { + assert.equal((await proxy.control('/mode', { mode: 'PASS' })).status, 200) + const result = await proxy.send(event()) + assert.equal(result.status, 200) + assert.equal(result.raw, JSON.stringify({ code: '200', data: { eventId: targetId, payloadHash: hash, outcome: 'DUPLICATE' } })) + assert.equal(result.headers.get('x-consumer-evidence'), 'actual-upstream') + status = await (await proxy.control('/status')).json() + assert.equal(status.records.at(-1).outcome, 'DUPLICATE') + assert.equal(status.records.at(-1).errorCode, null) + }) + + await t.test('incorrect upstream ACK is never replaced with plausible success evidence', async () => { + proxy.invalidAck(true) + const result = await proxy.send(event()) + assert.equal(result.status, 502) + assert.equal(JSON.parse(result.raw).errorCode, 'UPSTREAM_ACK_INVALID') + }) + + await t.test('state, JSONL evidence and subprocess output never expose private payload or identity secrets', async () => { + const records = (await readFile(proxy.log, 'utf8')).trim().split('\n').map(line => JSON.parse(line)) + for (const record of records) { + assert.deepEqual(Object.keys(record).sort(), + ['time', 'eventId', 'payloadHash', 'outcome', 'attempt', 'matched', 'errorCode'].sort()) + assert.ok(record.eventId === targetId) + } + const evidence = `${JSON.stringify(await (await proxy.control('/status')).json())}\n${await readFile(proxy.log, 'utf8')}\n${proxy.output()}` + assert.ok(!evidence.includes(secret)) + assert.ok(!evidence.includes(controlToken)) + assert.ok(!evidence.includes('syntheticPrivateField')) + assert.ok(!evidence.includes('authorization')) + }) +}) + +test('explicit event-ID selector requires exact event type and forwards unrelated IDs unchanged', async t => { + const proxy = await fixture(['--event-id', targetId]) + t.after(() => proxy.stop()) + await proxy.control('/mode', { mode: 'FAULT' }) + const other = await proxy.send(event(otherId)) + assert.equal(other.status, 200) + const wrongType = await proxy.send({ ...event(), eventType: 'OverseasDeliveryCompleted' }) + assert.equal(wrongType.status, 200) + const target = await proxy.send(event()) + assert.equal(target.status, 503) + const status = await (await proxy.control('/status')).json() + assert.equal(status.attempt, 1) + assert.equal(status.eventId, targetId) +}) + +test('unloading selector pins only the exact source booking and preserves reverse-event ACK recovery', async t => { + const bookingId = '2100000000000000066' + const proxy = await fixture(['--container-booking-id', bookingId], 'OverseasContainerUnloaded') + t.after(() => proxy.stop()) + await proxy.control('/mode', { mode: 'FAULT' }) + const unloaded = { ...event(), eventType: 'OverseasContainerUnloaded', + payload: { sourceContainerBookingId: bookingId, containerNo: 'TEST-CONTAINER' } } + for (const unrelated of [ + { ...unloaded, eventId: otherId, payload: { sourceContainerBookingId: '2100000000000000067' } }, + { ...unloaded, eventId: otherId, payload: { loadingPlanId: bookingId } }, + { ...unloaded, eventId: otherId, eventType: 'OverseasDeliveryCompleted' }, + ]) assert.equal((await proxy.send(unrelated)).status, 200) + assert.equal((await proxy.send(unloaded)).status, 503) + assert.equal((await proxy.send(unloaded)).status, 503) + assert.equal((await proxy.send({ ...unloaded, eventId: samePlanOtherId })).status, 200) + const before = proxy.calls.length + assert.equal((await proxy.send({ ...unloaded, payload: { sourceContainerBookingId: '2100000000000000067' } })).status, 409) + assert.equal(proxy.calls.length, before) + await proxy.control('/mode', { mode: 'PASS' }) + const recovered = await proxy.send(unloaded) + assert.equal(recovered.status, 200) + assert.equal(JSON.parse(recovered.raw).data.outcome, 'DUPLICATE') + const status = await (await proxy.control('/status')).json() + assert.equal(status.eventId, targetId) + assert.deepEqual(status.records.filter(row => row.errorCode !== 'TARGET_EVENT_HASH_CONFLICT').map(row => row.outcome), + ['APPLIED', 'DUPLICATE', 'DUPLICATE']) +}) + +test('booking selector rejects malformed identities and mixed selection before starting listeners', () => { + for (const args of [['--container-booking-id', '0'], ['--container-booking-id', '../66'], + ['--container-booking-id', '66', '--loading-plan-id', '77'], ['--container-booking-id', '66', '--event-id', targetId]]) { + const result = spawnSync(process.env.PYTHON || 'python', [script, '--upstream', 'http://127.0.0.1:1/internal/events', + '--event-type', 'OverseasContainerUnloaded', ...args], { encoding: 'utf8', timeout: 5000, + env: { ...process.env, EVENT_FAULT_CONTROL_TOKEN: controlToken } }) + assert.equal(result.status, 2) + assert.ok(!result.stdout.includes('"ready"')) + } +}) + +test('delivery selector isolates the first exact delivery event and preserves its ACK-loss recovery', async t => { + const deliveryId = '2100000000000000088' + const proxy = await fixture(['--delivery-id', deliveryId], 'OverseasDeliveryCompleted') + t.after(() => proxy.stop()) + await proxy.control('/mode', { mode: 'FAULT' }) + const signed = { ...event(), eventType: 'OverseasDeliveryCompleted', + payload: { deliveryOrderId: deliveryId, sourceInboundId: '2100000000000000099' } } + for (const unrelated of [ + { ...signed, eventId: otherId, payload: { deliveryOrderId: '2100000000000000089' } }, + { ...signed, eventId: otherId, payload: { deliveryId } }, + { ...signed, eventId: otherId, payload: { loadingPlanId: deliveryId } }, + { ...signed, eventId: otherId, eventType: 'OverseasContainerUnloaded' }, + ]) assert.equal((await proxy.send(unrelated)).status, 200) + assert.equal((await proxy.send(signed)).status, 503) + assert.equal((await proxy.send(signed)).status, 503) + assert.equal((await proxy.send({ ...signed, eventId: samePlanOtherId })).status, 200) + const calls = proxy.calls.length + assert.equal((await proxy.send({ ...signed, payload: { deliveryOrderId: '2100000000000000089' } })).status, 409) + assert.equal(proxy.calls.length, calls) + await proxy.control('/mode', { mode: 'PASS' }) + const recovered = await proxy.send(signed) + assert.equal(recovered.status, 200) + assert.equal(JSON.parse(recovered.raw).data.outcome, 'DUPLICATE') + const status = await (await proxy.control('/status')).json() + assert.equal(status.eventId, targetId) + assert.deepEqual(status.records.filter(row => row.errorCode !== 'TARGET_EVENT_HASH_CONFLICT').map(row => row.outcome), ['APPLIED', 'DUPLICATE', 'DUPLICATE']) + assert.ok(!(await readFile(proxy.log, 'utf8')).includes('sourceInboundId')) +}) + +test('delivery selection rejects malformed IDs or a second selector before opening a listener', () => { + for (const args of [['--delivery-id', '0'], ['--delivery-id', '../88'], ['--delivery-id', '88', '--event-id', targetId], + ['--delivery-id', '88', '--container-booking-id', '66'], ['--delivery-id', '88', '--loading-plan-id', '77']]) { + const result = spawnSync(process.env.PYTHON || 'python', [script, '--upstream', 'http://127.0.0.1:1/internal/events', + '--event-type', 'OverseasDeliveryCompleted', ...args], { encoding: 'utf8', timeout: 5000, + env: { ...process.env, EVENT_FAULT_CONTROL_TOKEN: controlToken } }) + assert.equal(result.status, 2) + assert.ok(!result.stdout.includes('"ready"')) + } +}) + +test('fee void selector leaves publication, other sources and versions unchanged while recovering exact VOIDED ACK', async t => { + const sourceFeeId='2100000000000000011' + const proxy=await fixture(['--fee-source-id',sourceFeeId,'--source-version','2'],'BusinessFeeSourceChanged') + t.after(()=>proxy.stop()) + await proxy.control('/mode',{mode:'FAULT'}) + const voided={...event(),eventType:'BusinessFeeSourceChanged',payload:{sourceFeeId,sourceVersion:2,action:'VOIDED',totalAmount:30}} + for(const payload of [{...voided.payload,action:'PUBLISHED'},{...voided.payload,sourceVersion:1},{...voided.payload,sourceFeeId:'2100000000000000012'}, + {...voided.payload,action:'VOID'}]) assert.equal((await proxy.send({...voided,eventId:otherId,payload})).status,200) + assert.equal((await proxy.send(voided)).status,503) + assert.equal((await proxy.send(voided)).status,503) + assert.equal((await proxy.send({...voided,eventId:samePlanOtherId})).status,200) + const before=proxy.calls.length + assert.equal((await proxy.send({...voided,payload:{...voided.payload,sourceVersion:3}})).status,409) + assert.equal(proxy.calls.length,before) + await proxy.control('/mode',{mode:'PASS'}) + assert.equal(JSON.parse((await proxy.send(voided)).raw).data.outcome,'DUPLICATE') + const state=await (await proxy.control('/status')).json() + assert.deepEqual(state.records.filter(r=>r.errorCode!=='TARGET_EVENT_HASH_CONFLICT').map(r=>r.outcome),['APPLIED','DUPLICATE','DUPLICATE']) +}) + +test('fee void selector requires a bounded version paired with only its own source selector',()=>{ + for(const args of [['--fee-source-id','11'],['--event-id',targetId,'--source-version','1'],['--fee-source-id','0','--source-version','1'], + ['--fee-source-id','11','--source-version','0'],['--fee-source-id','11','--source-version','2147483648'], + ['--fee-source-id','../11','--source-version','1'],['--fee-source-id','11','--source-version','1','--delivery-id','88']]) { + const result=spawnSync(process.env.PYTHON||'python',[script,'--upstream','http://127.0.0.1:1/internal/events','--event-type','BusinessFeeSourceChanged',...args], + {encoding:'utf8',timeout:5000,env:{...process.env,EVENT_FAULT_CONTROL_TOKEN:controlToken}}) + assert.equal(result.status,2);assert.ok(!result.stdout.includes('"ready"')) + } +}) diff --git a/scripts/event-outbox-operations.mjs b/scripts/event-outbox-operations.mjs new file mode 100644 index 000000000..2c64ea0ab --- /dev/null +++ b/scripts/event-outbox-operations.mjs @@ -0,0 +1,50 @@ +import { parseArgs } from 'node:util' +import { pathToFileURL } from 'node:url' + +export function buildRequest(argv, env) { + const { values: args } = parseArgs({ args: argv, options: { + help: { type: 'boolean' }, service: { type: 'string' }, base: { type: 'string' }, + entry: { type: 'string' }, retry: { type: 'boolean' }, 'request-id': { type: 'string' }, + operator: { type: 'string' }, reason: { type: 'string' }, hash: { type: 'string' }, + } }) + if (args.help) return null + if (env.CHONGCHONG_ENV !== 'test') throw new Error('CHONGCHONG_ENV must be test') + if (!['firstleg', 'lastleg', 'finance'].includes(args.service)) throw new Error('Unknown producer service') + if (!/^[A-Za-z0-9-]{1,64}$/.test(args.entry ?? '')) throw new Error('Invalid entry ID') + const base = new URL(args.base) + if (!['http:', 'https:'].includes(base.protocol) || base.username || base.password || base.search || base.hash || base.pathname !== '/') { + throw new Error('Base must be an explicit service origin without credentials or path') + } + const token = env.EVENT_OPERATIONS_TOKEN + if (!token || Buffer.byteLength(token) < 32) throw new Error('EVENT_OPERATIONS_TOKEN must contain at least 32 bytes') + const url = new URL(`/internal/${args.service}/event-operations/${args.entry}${args.retry ? '/retry' : ''}`, base) + const options = { method: args.retry ? 'POST' : 'GET', redirect: 'error', + headers: { 'X-Event-Operations-Token': token, 'Content-Type': 'application/json' }, + signal: AbortSignal.timeout(15000) } + if (args.retry) { + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(args['request-id'] ?? '')) throw new Error('A stable request UUID is required') + for (const [name, limit] of [['operator', 128], ['reason', 500]]) { + if (!args[name]?.trim() || args[name].length > limit || /[\x00-\x1f\x7f-\x9f]/.test(args[name])) throw new Error(`Invalid ${name}`) + } + if (!/^sha256:[0-9a-f]{64}$/i.test(args.hash ?? '')) throw new Error('Expected payload hash is required') + options.body = JSON.stringify({ requestId: args['request-id'], operator: args.operator, reason: args.reason, expectedPayloadHash: args.hash }) + } + return { url, options } +} + +async function main() { + const request = buildRequest(process.argv.slice(2), process.env) + if (!request) { + console.log('Inspect: --service firstleg|lastleg|finance --base --entry \nRetry: add --retry --request-id --operator --reason --hash \nEnvironment: CHONGCHONG_ENV=test, EVENT_OPERATIONS_TOKEN. No automatic retry or redirects. Retry writes Outbox state and audit atomically.') + return + } + const response = await fetch(request.url, request.options) + if (!response.ok) throw new Error(`Operations endpoint returned HTTP ${response.status}`) + const result = await response.json() + if (result?.code !== '200') throw new Error(`Operations rejected: code=${String(result?.code ?? 'missing')}`) + console.log(JSON.stringify(result.data, null, 2)) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch(error => { console.error(error.message); process.exitCode = 1 }) +} diff --git a/scripts/event-outbox-operations.test.mjs b/scripts/event-outbox-operations.test.mjs new file mode 100644 index 000000000..20a1cadba --- /dev/null +++ b/scripts/event-outbox-operations.test.mjs @@ -0,0 +1,23 @@ +import { buildRequest } from './event-outbox-operations.mjs' +import test from 'node:test' +import assert from 'node:assert/strict' +const env = { CHONGCHONG_ENV: 'test', EVENT_OPERATIONS_TOKEN: 't'.repeat(40) } +const args = ['--service', 'finance', '--base', 'http://127.0.0.1:9200', '--entry', 'event-123'] +test('inspection stays on explicit internal producer origin without redirects', () => { + const request = buildRequest(args, env) + assert.equal(request.url.href, 'http://127.0.0.1:9200/internal/finance/event-operations/event-123') + assert.equal(request.options.method, 'GET') + assert.equal(request.options.redirect, 'error') + assert.equal(request.options.body, undefined) +}) +test('retry requires stable identity, reason and expected payload hash', () => { + assert.throws(() => buildRequest([...args, '--retry'], env), /UUID/) + const request = buildRequest([...args, '--retry', '--request-id', '11111111-1111-4111-8111-111111111111', '--operator', 'operator-7', '--reason', 'dependency restored', '--hash', 'sha256:' + 'a'.repeat(64)], env) + assert.equal(request.options.method, 'POST') + assert.equal(JSON.parse(request.options.body).operator, 'operator-7') +}) +test('wrong environment, weak credential and unknown service fail before HTTP', () => { + assert.throws(() => buildRequest(args, { ...env, CHONGCHONG_ENV: 'prod' }), /must be test/) + assert.throws(() => buildRequest(args, { ...env, EVENT_OPERATIONS_TOKEN: '' }), /TOKEN/) + assert.throws(() => buildRequest(['--service', 'bridge'], env), /Unknown/) +}) diff --git a/scripts/f32-pda-hold-race.mjs b/scripts/f32-pda-hold-race.mjs new file mode 100644 index 000000000..6b1d330a8 --- /dev/null +++ b/scripts/f32-pda-hold-race.mjs @@ -0,0 +1,229 @@ +// Explicit TEST inventory race. Imports perform no network or environment reads. +import fs from 'node:fs' +import { createHash } from 'node:crypto' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { buildEvidenceRemoteScript } from './fba-core-regression-evidence.mjs' +import { warehouseClaim } from './admin-test-env-modules/unified-pda-session-revocation.mjs' + +const demand=(condition,message)=>{if(!condition)throw Error(message)} +const digest=value=>createHash('sha256').update(JSON.stringify(value)).digest('hex') +const pause=ms=>new Promise(done=>setTimeout(done,ms)) +const literal=value=>`CONVERT(0x${Buffer.from(value).toString('hex')} USING utf8mb4) COLLATE utf8mb4_bin` +const tx=['SET NAMES utf8mb4 COLLATE utf8mb4_bin;','SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;','START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;'] +const parse=raw=>JSON.parse(raw.replace(/([:\[,]\s*)(-?\d{16,})(?=\s*[,}\]])/g,'$1"$2"')) +export function validateRacePlan(p){ + const fields=['purpose','marker','customerId','orderId','warehouseId','balanceId','positionId','pdaMembershipId','dispatchItemId','dispatchNo','tripNo','expectedImage','expectedPackages','pickPackages','pickQuantity'] + demand(p&&Object.keys(p).length===fields.length&&Object.keys(p).every(key=>fields.includes(key)),'INVALID_RACE_SCHEMA') + demand(p?.purpose==='F32_OWNED_PDA_HOLD_RACE'&&/^[A-Za-z0-9_-]{1,64}$/.test(p.marker||''),'INVALID_RACE_PLAN') + for(const key of ['customerId','orderId','warehouseId','balanceId','positionId','pdaMembershipId','dispatchItemId']) + demand(typeof p[key]==='string'&&/^[1-9][0-9]{0,19}$/.test(p[key]),'INVALID_RACE_IDENTIFIER') + demand(/^[A-Za-z0-9_-]{1,64}$/.test(p.dispatchNo||'')&&/^[A-Za-z0-9_-]{1,64}$/.test(p.tripNo||''),'INVALID_RACE_BUSINESS_NUMBER') + demand(/^sha256:[a-f0-9]{64}$/.test(p.expectedImage||'')&&p.expectedPackages===4&&p.pickPackages===1 + &&Number.isSafeInteger(p.pickQuantity)&&p.pickQuantity>0,'INVALID_RACE_BASELINE') + return p +} +const b64=value=>'['+Buffer.from(value).toString('base64').match(/.{1,4000}/g).map(x=>`'${x}'`).join(',')+"].join('')" +export function buildOrderLockLeaseScript(plan,phase,agent){ + const p=validateRacePlan(plan) + demand(['HOLD_FIRST','PICK_FIRST'].includes(phase)&&/^[A-Za-z0-9_.-]{1,128}$/.test(agent||''),'INVALID_LEASE_SCOPE') + const tag=`F32_${p.marker}_${phase}` + // The only write-capable transaction contains a locking SELECT and ROLLBACK. + // A fixed server-side 12-second lease releases the row even if the observer disconnects. + const sql=`SET SESSION innodb_lock_wait_timeout=5;START TRANSACTION;SELECT JSON_OBJECT('rowId',CAST(id AS CHAR),'customerId',customer_id,'connectionId',CONNECTION_ID()) FROM ll_order WHERE id=${p.orderId} AND customer_id=${literal(p.customerId)} AND overseas_warehouse_id=${literal(p.warehouseId)} FOR UPDATE;SELECT JSON_OBJECT('marker','${tag}','leaseElapsed',SLEEP(12));ROLLBACK;SELECT JSON_OBJECT('released',JSON_EXTRACT('true','$'));` + const python=`import json,os,subprocess,urllib.parse,re +try: + c=json.loads(subprocess.check_output(['docker','inspect','firstleg-test-lastleg'],text=True,stderr=subprocess.DEVNULL))[0] + e=dict(x.split('=',1) for x in c['Config']['Env']) + if c['Name']!='/firstleg-test-lastleg' or c['Config'].get('Labels',{}).get('com.docker.compose.project')!='firstleg-test' or e.get('DEPLOY_ENV')!='test' or c['Image']!='${p.expectedImage}' or c['State'].get('Health',{}).get('Status')!='healthy':raise RuntimeError('TEST_RUNTIME_CHANGED') + u=urllib.parse.urlsplit(e['LASTLEG_DB_URL'].removeprefix('jdbc:'));host='127.0.0.1' if u.hostname=='host.docker.internal' else u.hostname + childEnv=dict(os.environ,MYSQL_PWD=e['LASTLEG_DB_PASSWORD']) + q=subprocess.run(['mysql','--host='+host,'--port='+str(u.port or 3306),'--user='+e['LASTLEG_DB_USERNAME'],'--batch','--raw','--skip-column-names',u.path.lstrip('/')],input=${JSON.stringify(sql)},capture_output=True,text=True,env=childEnv,timeout=22) + if q.returncode: + code=re.search(r'ERROR ([0-9]{3,5})',q.stderr);print('F32_LEASE_JSON='+json.dumps({'ok':False,'error':'MYSQL_'+(code.group(1) if code else 'UNKNOWN')})) + else: + rows=[json.loads(x) for x in q.stdout.splitlines() if x.strip()] + if len(rows)!=3 or str(rows[0].get('rowId'))!='${p.orderId}' or str(rows[0].get('customerId'))!='${p.customerId}' or rows[-1].get('released')!=True:raise RuntimeError('LEASE_SOURCE_NOT_PROVED') + print('F32_LEASE_JSON='+json.dumps({'ok':True,'rows':rows,'imageId':c['Image']})) +except Exception as error: + print('F32_LEASE_JSON='+json.dumps({'ok':False,'error':str(error) if re.fullmatch('[A-Z_]{1,64}',str(error)) else 'LEASE_FAILED_OR_TIMED_OUT'})) +` + const groovy=`def p=new ProcessBuilder(['python3','-c',new String(java.util.Base64.decoder.decode(${b64(python)}),'UTF-8')]).redirectErrorStream(true).start();def out=p.inputStream.getText('UTF-8');p.waitFor();print(out)` + return `def c=jenkins.model.Jenkins.instance.getComputer('${agent}');if(c==null||c.channel==null)throw new RuntimeException('TEST agent unavailable');print(hudson.util.RemotingDiagnostics.executeGroovy(new String(java.util.Base64.decoder.decode(${b64(groovy)}),'UTF-8'),c.channel))` +} +export function buildLockEvidenceSql(plan,phase){ + const p=validateRacePlan(plan);demand(['HOLD_FIRST','PICK_FIRST'].includes(phase),'INVALID_LEASE_SCOPE') + const tag=`F32_${p.marker}_${phase}` + return [...tx, + `SELECT JSON_OBJECT('collection','lease','connectionId',t.trx_mysql_thread_id) FROM performance_schema.data_locks l JOIN information_schema.innodb_trx t ON t.trx_id=l.ENGINE_TRANSACTION_ID WHERE l.OBJECT_SCHEMA=DATABASE() AND l.OBJECT_NAME='ll_order' AND l.INDEX_NAME='PRIMARY' AND l.LOCK_STATUS='GRANTED' AND l.LOCK_DATA=${literal(p.orderId)} AND t.trx_query LIKE ${literal('%'+tag+'%')};`, + `SELECT JSON_OBJECT('collection','wait','requestingConnectionId',r.trx_mysql_thread_id,'blockingConnectionId',b.trx_mysql_thread_id,'orderId',l.LOCK_DATA) FROM performance_schema.data_lock_waits w JOIN performance_schema.data_locks l ON l.ENGINE_LOCK_ID=w.REQUESTING_ENGINE_LOCK_ID JOIN information_schema.innodb_trx r ON r.trx_id=w.REQUESTING_ENGINE_TRANSACTION_ID JOIN information_schema.innodb_trx b ON b.trx_id=w.BLOCKING_ENGINE_TRANSACTION_ID WHERE l.OBJECT_SCHEMA=DATABASE() AND l.OBJECT_NAME='ll_order' AND l.INDEX_NAME='PRIMARY' AND l.LOCK_DATA=${literal(p.orderId)};`,'COMMIT;'].join('\n') +} +export function buildStockEvidenceSql(plan){ + const p=validateRacePlan(plan),customer=literal(p.customerId) + return [...tx, + `SELECT JSON_OBJECT('collection','sourceOrder','id',CAST(id AS CHAR),'customerId',customer_id,'warehouseId',overseas_warehouse_id,'picked',COALESCE(picked_package_count,0),'shipped',COALESCE(shipped_package_count,0),'version',version,'detained',detained,'outboundNo',outbound_order_no) FROM ll_order WHERE customer_id=${customer} AND id=${p.orderId};`, + `SELECT JSON_OBJECT('collection','hold','id',CAST(id AS CHAR),'key',idempotency_key,'held',held,'version',order_version) FROM ll_order_hold_history WHERE customer_id=${customer} AND order_id=${p.orderId} ORDER BY id;`, + `SELECT JSON_OBJECT('collection','pick','id',CAST(id AS CHAR),'key',idempotency_key,'packages',packages,'quantity',product_quantity,'positionId',CAST(inventory_position_id AS CHAR)) FROM ll_dispatch_pda_pick WHERE customer_id=${customer} AND dispatch_item_id=${p.dispatchItemId} ORDER BY id;`, + `SELECT JSON_OBJECT('collection','attribution','id',CAST(id AS CHAR),'pickId',CAST(pick_id AS CHAR),'orderId',CAST(order_id AS CHAR),'packages',packages,'quantity',product_quantity) FROM ll_dispatch_pda_pick_order WHERE customer_id=${customer} AND order_id=${p.orderId} ORDER BY id;`, + `SELECT JSON_OBJECT('collection','ledger','id',CAST(id AS CHAR),'key',idempotency_key,'positionId',CAST(position_id AS CHAR),'businessType',business_type,'statusFrom',status_from,'statusTo',status_to,'before',before_packages,'delta',delta_packages,'after',after_packages) FROM ll_inventory_ledger WHERE customer_id=${customer} AND balance_id=${p.balanceId} ORDER BY id;`,'COMMIT;'].join('\n') +} +export function assertContendedRequests(rows,expectedOrderId,firstConnectionId){ + const leases=rows.filter(x=>x.collection==='lease'),waits=rows.filter(x=>x.collection==='wait') + demand(leases.length===1,'LIVE_LEASE_NOT_PROVED') + const ids=[...new Set(waits.map(x=>x.requestingConnectionId))] + demand(Number.isSafeInteger(leases[0].connectionId)&&leases[0].connectionId>0 + &&waits.every(x=>x.orderId===expectedOrderId&&Number.isSafeInteger(x.requestingConnectionId)&&x.requestingConnectionId>0) + &&!ids.includes(leases[0].connectionId),'LOCK_EVIDENCE_SCOPE_INVALID') + demand(ids.length===(firstConnectionId===undefined?1:2)&&waits.some(x=>x.blockingConnectionId===leases[0].connectionId),'EXACT_REQUEST_CONTENTION_NOT_PROVED') + if(firstConnectionId!==undefined)demand(ids.includes(firstConnectionId),'FIRST_REQUEST_NO_LONGER_WAITING') + return {leaseConnectionId:leases[0].connectionId,requestingConnections:ids,waits} +} +function stableSnapshot(x){return digest(x)} + +export function assertRaceInventory(plan,phase,before,after){ + const p=validateRacePlan(plan);demand(['HOLD_FIRST','PICK_FIRST'].includes(phase),'INVALID_RACE_PHASE') + const scanned=phase==='PICK_FIRST'?1:0,b=after.balance,order=after.detail.order + const bucketNames=['availablePackages','holdPackages','allocatedPackages','pickedPackages','receivingPackages'] + const buckets=x=>bucketNames.reduce((n,k)=>{demand(Number.isSafeInteger(Number(x[k]))&&Number(x[k])>=0,'INVALID_STOCK_BUCKET');return n+Number(x[k])},0) + demand(Number(before.balance.availablePackages)===4&&Number(before.balance.totalPackages)===4 + &&before.positions.length===2&&after.positions.length===2 + &&new Set(after.positions.map(x=>x.id)).size===2&&after.positions.every(x=>before.positions.some(y=>y.id===x.id)) + &&Number(b.availablePackages)===(scanned?3:0)&&Number(b.holdPackages)===(scanned?0:4) + &&Number(b.pickedPackages)===0&&Number(b.allocatedPackages)===0&&Number(b.receivingPackages)===0 + &&Number(b.totalPackages)===4-scanned&&buckets(b)===4-scanned + &&after.positions.reduce((n,x)=>n+buckets(x),0)+scanned===4&&order.detained===!scanned,'RACE_INVENTORY_RESULT_INVALID') + const sources=after.sql.filter(x=>x.collection==='sourceOrder') + demand(sources.length===1&&sources[0].id===p.orderId&&sources[0].customerId===p.customerId&&sources[0].warehouseId===p.warehouseId + &&sources[0].picked===scanned&&sources[0].shipped===0&&!sources[0].outboundNo + &&sources[0].version===Number(order.version)&&sources[0].detained===(scanned?0:1),'RACE_SOURCE_ORDER_COUNTERS_INVALID') + const added=collection=>after.sql.filter(x=>x.collection===collection&&!before.sql.some(y=>y.collection===collection&&y.id===x.id)) + const pickKey=`${p.marker}-${phase}-PICK`,newPicks=added('pick'),attributed=added('attribution'),ledger=added('ledger'),holds=added('hold') + demand(newPicks.length===scanned&&newPicks.every(x=>x.key===pickKey&&x.positionId===p.positionId&&x.packages===1&&x.quantity===p.pickQuantity),'RACE_PICK_FACTS_INVALID') + demand(attributed.length===scanned&&attributed.every(x=>x.orderId===p.orderId&&x.packages===1&&x.quantity===p.pickQuantity + &&newPicks.some(y=>y.id===x.pickId)),'RACE_SOURCE_ATTRIBUTION_INVALID') + demand(ledger.every(x=>Number.isSafeInteger(x.delta)&&x.before+x.delta===x.after),'RACE_LEDGER_ARITHMETIC_INVALID') + if(scanned){ + demand(holds.length===0&&ledger.length===3,'PICK_OR_REJECTED_HOLD_DUPLICATED_FACTS') + for(const [type,from,to,delta] of [['ALLOCATE','AVAILABLE','ALLOCATED',1],['PICK','ALLOCATED','PICKED',1],['SHIP','PICKED','SHIPPED',-1]]){ + const rows=ledger.filter(x=>x.businessType===type) + demand(rows.length===1&&rows[0].positionId===p.positionId&&rows[0].statusFrom===from&&rows[0].statusTo===to&&rows[0].delta===delta + &&rows[0].key.startsWith(`DISPATCH:${p.dispatchNo}:${p.dispatchItemId}:${pickKey}:${type}:`),'RACE_SCAN_LEDGER_INVALID') + } + demand(after.positions.every(x=>Number(x.availablePackages)===Number(before.positions.find(y=>y.id===x.id).availablePackages)-(x.id===p.positionId?1:0) + &&Number(x.holdPackages)===0&&Number(x.pickedPackages)===0&&Number(x.allocatedPackages)===0),'RACE_POSITION_DEDUCTION_INVALID') + }else{ + demand(holds.length===1&&holds[0].key===`${p.marker}-${phase}-HOLD`&&holds[0].held===1 + &&holds[0].version===Number(order.version)&&ledger.length===2,'HOLD_OR_REJECTED_PICK_DUPLICATED_FACTS') + demand(ledger.every(x=>x.businessType==='HOLD'&&x.statusFrom==='AVAILABLE'&&x.statusTo==='HOLD' + &&x.delta===Number(before.positions.find(y=>y.id===x.positionId)?.availablePackages)) + &&new Set(ledger.map(x=>x.positionId)).size===2&&ledger.reduce((n,x)=>n+x.delta,0)===4,'RACE_HOLD_LEDGER_INVALID') + } + return{verified:true,scannedOutPackages:scanned,availablePackages:Number(b.availablePackages),heldPackages:Number(b.holdPackages),pickedBucketPackages:0,sourcePickedPackages:sources[0].picked} +} + +async function main(argv){ + const a={};for(let i=0;i{ + const checkpointFd=fs.openSync(checkpoint,'w',0o600) + try{fs.writeFileSync(checkpointFd,JSON.stringify(report,null,2));fs.fsyncSync(checkpointFd)}finally{fs.closeSync(checkpointFd)} + } + const base=env.CHONGCHONG_ADMIN_API_BASE_URL.replace(/\/$/,''),agent='java-test-agent';let admin,pda,activationAttempted=false + const call=async(method,path,body,token=admin?.accessToken)=>{ + const entry={method,path:path.split('?')[0],phase:'INTENT'};report.requests.push(entry);save() + let res;try{res=await fetch(base+path,{method,redirect:'error',signal:AbortSignal.timeout(20000),headers:{Accept:'application/json',...(token?{Authorization:'Bearer '+token}:{}),...(body===undefined?{}:{'Content-Type':'application/json'})},...(body===undefined?{}:{body:JSON.stringify(body)})})}catch{entry.phase='OUTCOME_UNKNOWN';save();throw Error('API_OUTCOME_UNKNOWN_NO_AUTO_RETRY')} + const payload=parse(await res.text());Object.assign(entry,{phase:'ACKNOWLEDGED',http:res.status,code:String(payload.code),requestId:res.headers.get('x-request-id')});save();return{http:res.status,body:payload} + } + const ok=r=>{demand(r.http===200&&String(r.body?.code)==='200','API_SUCCESS_REQUIRED');return r.body.data} + const remote=async(script,prefix)=>{ + const res=await fetch(new URL('scriptText',env.JENKINS_URL.replace(/\/?$/,'/')),{method:'POST',redirect:'error',signal:AbortSignal.timeout(55000),headers:{Authorization:'Basic '+Buffer.from(env.JENKINS_USER+':'+env.JENKINS_API_TOKEN).toString('base64'),'Content-Type':'application/x-www-form-urlencoded'},body:new URLSearchParams({script})}) + demand(res.ok,'JENKINS_HTTP_FAILURE');const lines=(await res.text()).split(/\r?\n/).filter(x=>x.startsWith(prefix+'='));demand(lines.length===1,'REMOTE_RESULT_AMBIGUOUS');const value=JSON.parse(lines[0].slice(prefix.length+1));demand(value.ok===true,/^[A-Z_0-9]+$/.test(value.error||'')?value.error:'REMOTE_FAILED');return value + } + const sql=async query=>(await remote(buildEvidenceRemoteScript('lastleg',query,agent),'FBA_CORE_JSON')).rows + const snapshot=async()=>{ + const detail=ok(await call('GET',`/platform/v1/last-leg/orders/${p.orderId}?customerId=${p.customerId}`)) + const balance=ok(await call('GET',`/platform/v1/last-leg/inventory/balances/${p.balanceId}?customerId=${p.customerId}`)) + const positions=ok(await call('GET',`/platform/v1/last-leg/inventory/balances/${p.balanceId}/positions?customerId=${p.customerId}`)) + const task=ok(await call('GET',`/pda/v1/last-leg/dispatches/${p.dispatchNo}`,undefined,pda.accessToken)) + demand(String(detail.order.id)===p.orderId&&String(detail.order.customerId)===p.customerId&&String(balance.inboundOrderId)===p.orderId + &&String(balance.customerId)===p.customerId&&String(balance.overseasWarehouseId)===p.warehouseId + &&positions.length===2&&positions.every(x=>String(x.customerId)===p.customerId&&String(x.inboundOrderId)===p.orderId) + &&task.tripNo===p.tripNo&&task.dispatchNo===p.dispatchNo,'SNAPSHOT_SCOPE_INVALID') + return{detail,balance,positions,task,sql:await sql(buildStockEvidenceSql(p))} + } + // Explicit headers for immutable command keys; never mint a different key after an unknown result. + const command=async(type,body,key)=>{ + const path=type==='HOLD'?`/platform/v1/last-leg/orders/${p.orderId}/hold?customerId=${p.customerId}`:`/pda/v1/last-leg/dispatches/${p.dispatchNo}/items/${p.dispatchItemId}/pick` + const token=type==='HOLD'?admin.accessToken:pda.accessToken,method=type==='HOLD'?'PUT':'POST' + const entry={method,path:path.split('?')[0],type,key,body,phase:'INTENT'};report.requests.push(entry);save() + let res;try{res=await fetch(base+path,{method,redirect:'error',signal:AbortSignal.timeout(20000),headers:{Authorization:'Bearer '+token,'Content-Type':'application/json',...(type==='HOLD'?{'Idempotency-Key':key}:{})},body:JSON.stringify(body)})}catch{entry.phase='OUTCOME_UNKNOWN';save();throw Error('COMMAND_OUTCOME_UNKNOWN_NO_AUTO_RETRY')} + const payload=parse(await res.text());Object.assign(entry,{phase:'ACKNOWLEDGED',http:res.status,code:String(payload.code),message:payload.message,requestId:res.headers.get('x-request-id')});save();return{http:res.status,body:payload} + } + try{ + admin=ok(await call('POST','/auth/login',{loginId:env.CHONGCHONG_ADMIN_USERNAME,password:env.CHONGCHONG_ADMIN_PASSWORD},'')) + const users=ok(await call('GET',`/platform/v1/iam/users?page=1&size=20&keyword=${encodeURIComponent(env.PDA_USERNAME)}`)) + const user=users.records.find(x=>String(x.membershipId)===p.pdaMembershipId) + demand(user&&!user.protectedAccount&&user.status==='DISABLED'&&String(user.overseasWarehouseId)===p.warehouseId,'OWN_DISABLED_PDA_REQUIRED') + activationAttempted=true;ok(await call('PUT',`/platform/v1/iam/users/${p.pdaMembershipId}/status`,{status:'ACTIVE'})) + pda=ok(await call('POST','/auth/login',{loginId:env.PDA_USERNAME,password:env.PDA_PASSWORD},''));demand(warehouseClaim(pda.accessToken)===p.warehouseId,'PDA_WAREHOUSE_MISMATCH') + for(const phase of ['HOLD_FIRST','PICK_FIRST']){ + const before=await snapshot(),row=before.detail.order,source=before.sql.find(x=>x.collection==='sourceOrder') + demand(row.currentStatus==='INBOUND_COMPLETED'&&row.detained===false&&source?.id===p.orderId&&source.customerId===p.customerId + &&source.warehouseId===p.warehouseId&&source.version===Number(row.version)&&source.detained===0 + &&source.picked===0&&source.shipped===0&&!source.outboundNo&&Number(before.balance.availablePackages)===4 + &&Number(before.balance.holdPackages)===0&&Number(before.balance.pickedPackages)===0&&before.task.canPick===true,'LEGAL_UNPICKED_SOURCE_REQUIRED') + const evidence={phase,before,commands:{},lockSamples:[]};report.phases.push(evidence);save() + const holdKey=`${p.marker}-${phase}-HOLD`,pickKey=`${p.marker}-${phase}-PICK` + const holdBody={held:true,chargeable:false,reason:`${p.marker} ${phase}`,expectedVersion:Number(row.version)} + const pickBody={positionId:p.positionId,packages:1,productQuantity:p.pickQuantity,idempotencyKey:pickKey,remark:`${p.marker} ${phase}`} + evidence.commands={hold:{key:holdKey,body:holdBody},pick:{key:pickKey,body:pickBody}};save() + const pending=[],leasePromise=remote(buildOrderLockLeaseScript(p,phase,agent),'F32_LEASE_JSON').then(x=>({value:x}),error=>({error:error.message})) + const waits=async count=>{ + for(let i=0;i<12;i++){ + const rows=await sql(buildLockEvidenceSql(p,phase));evidence.lockSamples.push(rows);save() + const leases=rows.filter(x=>x.collection==='lease'),connections=[...new Set(rows.filter(x=>x.collection==='wait').map(x=>x.requestingConnectionId))] + if(leases.length===1&&connections.length===count)return rows + if(connections.length>count)throw Error('FOREIGN_SOURCE_CONTENTION');await pause(100) + }throw Error('EXPECTED_MYSQL_LOCK_WAIT_NOT_OBSERVED') + } + try{ + await waits(0) + const firstType=phase==='HOLD_FIRST'?'HOLD':'PICK',secondType=firstType==='HOLD'?'PICK':'HOLD' + const start=type=>command(type,type==='HOLD'?holdBody:pickBody,type==='HOLD'?holdKey:pickKey).then(value=>({type,value}),error=>({type,error:error.message})) + pending.push(start(firstType));const one=assertContendedRequests(await waits(1),p.orderId);evidence.firstWait=one;save() + pending.push(start(secondType));evidence.twoWaiters=assertContendedRequests(await waits(2),p.orderId,one.requestingConnections[0]);save() + }finally{evidence.lease=await leasePromise;evidence.responses=await Promise.all(pending);save()} + demand(!evidence.lease.error&&evidence.lease.value.rows.at(-1).released===true&&evidence.responses.length===2 + &&evidence.lease.value.rows[0].connectionId===evidence.twoWaiters?.leaseConnectionId + &&evidence.lease.value.rows[1].marker===`F32_${p.marker}_${phase}`,'LEASE_OR_BOTH_REQUESTS_UNPROVED') + const win=evidence.responses.find(x=>x.type===(phase==='HOLD_FIRST'?'HOLD':'PICK')),lose=evidence.responses.find(x=>x!==win) + demand(win&&!win.error&&lose&&!lose.error,'RACE_RESPONSE_UNKNOWN');ok(win.value) + demand(lose.value.http===200&&String(lose.value.body.code)==='A0409','RACE_LOSER_NOT_CONFLICT') + const after=evidence.after=await snapshot(),order=after.detail.order + evidence.inventoryProof=assertRaceInventory(p,phase,before,after) + ok(await command(win.type,win.type==='HOLD'?holdBody:pickBody,win.type==='HOLD'?holdKey:pickKey));const replay=evidence.replay=await snapshot() + demand(stableSnapshot(after)===stableSnapshot(replay),'RACE_REPLAY_DRIFT') + if(phase==='HOLD_FIRST'){ + ok(await command('HOLD',{held:false,chargeable:false,reason:`${p.marker} release own first phase`,expectedVersion:Number(order.version)},`${p.marker}-RELEASE`)) + evidence.restored=await snapshot();demand(evidence.restored.detail.order.detained===false&&Number(evidence.restored.balance.availablePackages)===4&&Number(evidence.restored.balance.holdPackages)===0,'OWN_HOLD_RELEASE_FAILED') + } + evidence.verified=true;save() + } + report.success=report.phases.length===2&&report.phases.every(x=>x.verified) + }catch(error){report.success=false;report.error=/^[A-Z_0-9]+$/.test(error.message)?error.message:'F32_RACE_FAILED_INSPECT_CHECKPOINT'} + finally{ + try{if(pda){ok(await call('POST','/auth/logout',{refreshToken:pda.refreshToken},pda.accessToken));report.pdaSessionClosed=true}}catch{report.pdaSessionClosed=false;report.success=false} + try{if(activationAttempted){const user=ok(await call('PUT',`/platform/v1/iam/users/${p.pdaMembershipId}/status`,{status:'DISABLED'}));report.operatorRestored=user.status==='DISABLED';demand(report.operatorRestored,'PDA_DISABLE_NOT_PROVED')}}catch{report.operatorRestored=false;report.success=false} + try{if(admin){ok(await call('POST','/auth/logout',{refreshToken:admin.refreshToken},admin.accessToken));report.adminSessionClosed=true}}catch{report.adminSessionClosed=false;report.success=false} + fs.writeFileSync(fd,JSON.stringify(report,null,2));fs.fsyncSync(fd);fs.closeSync(fd);save() + } + console.log(JSON.stringify({success:report.success,report:resolve(a.report),phases:report.phases.map(x=>({phase:x.phase,verified:!!x.verified})),error:report.error,operatorRestored:report.operatorRestored})) + if(!report.success)process.exitCode=1 +} +if(process.argv[1]&&import.meta.url===pathToFileURL(resolve(process.argv[1])).href)main(process.argv.slice(2)).catch(error=>{console.error(/^[A-Z_0-9]+$/.test(error.message)?error.message:'F32_RACE_ARGUMENT_OR_REPORT_ERROR');process.exitCode=1}) diff --git a/scripts/f32-pda-hold-race.test.mjs b/scripts/f32-pda-hold-race.test.mjs new file mode 100644 index 000000000..6f3413cab --- /dev/null +++ b/scripts/f32-pda-hold-race.test.mjs @@ -0,0 +1,85 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { validateRacePlan,buildOrderLockLeaseScript,buildLockEvidenceSql,buildStockEvidenceSql,assertContendedRequests,assertRaceInventory } from './f32-pda-hold-race.mjs' +import {assertReadOnlySql} from './fba-core-regression-evidence.mjs' +const plan={purpose:'F32_OWNED_PDA_HOLD_RACE',marker:'OFFLINE',customerId:'21',orderId:'22',warehouseId:'23',balanceId:'24',positionId:'25',pdaMembershipId:'26',dispatchItemId:'27',dispatchNo:'DS-OFFLINE',tripNo:'TR-OFFLINE',expectedImage:'sha256:'+'a'.repeat(64),expectedPackages:4,pickPackages:1,pickQuantity:10} +test('explicit owned scope rejects SQL injection, image drift and widened quantities',()=>{ + assert.equal(validateRacePlan(plan),plan) + for(const changed of [{password:'do not persist'},{orderId:'22;DELETE FROM ll_order'},{customerId:21},{positionId:'0'},{expectedImage:'latest'},{marker:'x\';evil'},{expectedPackages:5},{pickPackages:2},{pickQuantity:0}]) + assert.throws(()=>validateRacePlan({...plan,...changed})) + assert.throws(()=>buildOrderLockLeaseScript(plan,'OTHER','agent')) + assert.throws(()=>buildOrderLockLeaseScript(plan,'HOLD_FIRST',"agent');evil")) +}) +test('both evidence queries remain consistent read-only transactions with exact original source filters',()=>{ + for(const phase of ['HOLD_FIRST','PICK_FIRST']){ + const sql=buildLockEvidenceSql(plan,phase) + assert.equal(assertReadOnlySql(sql),sql) + assert.match(sql,/OBJECT_NAME='ll_order'/);assert.match(sql,/INDEX_NAME='PRIMARY'/) + assert.match(sql,/LOCK_DATA=CONVERT\(0x3232/) + } + const sql=buildStockEvidenceSql(plan) + assert.equal(assertReadOnlySql(sql),sql) + assert.match(sql,/dispatch_item_id=27/);assert.match(sql,/order_id=22/);assert.match(sql,/balance_id=24/) +}) +function chunks(source){return Array.from(source.matchAll(/\[('[A-Za-z0-9+/=]+'(?:,'[A-Za-z0-9+/=]+')*)\]\.join\(''\)/g),m=>Array.from(m[1].matchAll(/'([^']+)'/g),x=>x[1]).join(''))} +test('lease has a fixed bounded server sleep, rollback and immutable healthy TEST target; Python compiles offline',()=>{ + const outer=buildOrderLockLeaseScript(plan,'HOLD_FIRST','agent') + const inner=Buffer.from(chunks(outer)[0],'base64').toString('utf8') + const python=Buffer.from(chunks(inner)[0],'base64').toString('utf8') + assert.match(python,/SLEEP\(12\)/);assert.match(python,/ROLLBACK;/);assert.match(python,/timeout=22/) + assert.ok(python.includes("JSON_OBJECT('marker','F32_OFFLINE_HOLD_FIRST'"),'Correlation must survive the MySQL client stripping comments') + assert.match(python,/JSON_EXTRACT\('true','\$'\)/) + assert.match(python,/innodb_lock_wait_timeout=5/);assert.match(python,/FOR UPDATE/) + assert.match(python,/DEPLOY_ENV.*test/);assert.match(python,/MYSQL_PWD=e\['LASTLEG_DB_PASSWORD'\]/) + assert.ok(python.includes(plan.expectedImage));assert.match(python,/Health.*healthy/) + assert.doesNotMatch(python,/INSERT INTO|DELETE FROM|UPDATE ll_order|docker.*(?:stop|restart)/) + const result=spawnSync(process.env.PYTHON||'python',['-c',"import sys;compile(sys.stdin.read(),'','exec')"],{input:python,encoding:'utf8'}) + assert.equal(result.status,0,result.stderr||result.error?.message) +}) +const rows=[{collection:'lease',connectionId:100},{collection:'wait',requestingConnectionId:101,blockingConnectionId:100,orderId:'22'}] +function scanSnapshots(){ + const bucket=available=>({availablePackages:available,holdPackages:0,allocatedPackages:0,pickedPackages:0,receivingPackages:0}) + const before={balance:{...bucket(4),totalPackages:4},positions:[{id:'25',...bucket(3)},{id:'28',...bucket(1)}],detail:{order:{detained:false}},sql:[]} + const after={balance:{...bucket(3),totalPackages:3},positions:[{id:'25',...bucket(2)},{id:'28',...bucket(1)}],detail:{order:{detained:false,version:1}},sql:[ + {collection:'sourceOrder',id:'22',customerId:'21',warehouseId:'23',picked:1,shipped:0,detained:0,version:1}, + {collection:'pick',id:'100',key:'OFFLINE-PICK_FIRST-PICK',positionId:'25',packages:1,quantity:10}, + {collection:'attribution',id:'101',pickId:'100',orderId:'22',packages:1,quantity:10}, + ...[['ALLOCATE','AVAILABLE','ALLOCATED',1],['PICK','ALLOCATED','PICKED',1],['SHIP','PICKED','SHIPPED',-1]].map(([businessType,statusFrom,statusTo,delta],i)=>({collection:'ledger',id:String(102+i),key:`DISPATCH:DS-OFFLINE:27:OFFLINE-PICK_FIRST-PICK:${businessType}:operation`,positionId:'25',businessType,statusFrom,statusTo,delta,before:delta>0?0:1,after:delta>0?1:0})), + ]} + return{before,after} +} +test('successful PDA scan accounts for one consumed box and its three immutable ledger steps, without inventing DTO counters',()=>{ + const {before,after}=scanSnapshots() + assert.doesNotThrow(()=>assertRaceInventory(plan,'PICK_FIRST',before,after)) +}) +test('missing/duplicate scan ledger, wrong source and a second deduction cannot pass the inventory proof',()=>{ + for(const change of [x=>x.sql=x.sql.filter(y=>y.businessType!=='SHIP'),x=>x.sql.push({...x.sql.find(y=>y.businessType==='SHIP'),id:'999'}), + x=>x.sql.find(y=>y.collection==='sourceOrder').picked=2,x=>x.sql.find(y=>y.collection==='attribution').orderId='other', + x=>x.balance.availablePackages=2,x=>x.sql.find(y=>y.businessType==='SHIP').positionId='other',x=>x.sql.find(y=>y.businessType==='SHIP').delta=-2]){ + const {before,after}=scanSnapshots();change(after) + assert.throws(()=>assertRaceInventory(plan,'PICK_FIRST',before,after)) + } +}) +test('hold winner moves the exact two positions, with one history and no pick or attribution facts',()=>{ + const {before}=scanSnapshots() + const after={balance:{...before.balance,availablePackages:0,holdPackages:4},positions:before.positions.map(x=>({...x,availablePackages:0,holdPackages:x.availablePackages})), + detail:{order:{detained:true,version:2}},sql:[{collection:'sourceOrder',id:'22',customerId:'21',warehouseId:'23',picked:0,shipped:0,detained:1,version:2}, + {collection:'hold',id:'200',key:'OFFLINE-HOLD_FIRST-HOLD',held:1,version:2}, + ...before.positions.map((x,i)=>({collection:'ledger',id:String(201+i),key:'hold',positionId:x.id,businessType:'HOLD',statusFrom:'AVAILABLE',statusTo:'HOLD',before:0,delta:x.availablePackages,after:x.availablePackages}))]} + assert.equal(assertRaceInventory(plan,'HOLD_FIRST',before,after).heldPackages,4) + for(const change of [x=>x.sql.pop(),x=>x.sql.push({collection:'pick',id:'300',key:'wrong'}),x=>x.sql.find(x=>x.collection==='hold').version=3,x=>x.sql.find(x=>x.collection==='ledger').positionId='other']){ + const changed=structuredClone(after);change(changed) + assert.throws(()=>assertRaceInventory(plan,'HOLD_FIRST',before,changed)) + } +}) +test('concurrency requires a live original row lease, first observed waiter and two distinct simultaneous waiters',()=>{ + assert.deepEqual(assertContendedRequests(rows,'22').requestingConnections,[101]) + const both=[...rows,{collection:'wait',requestingConnectionId:102,blockingConnectionId:100,orderId:'22'}] + assert.deepEqual(assertContendedRequests(both,'22',101).requestingConnections,[101,102]) + assert.throws(()=>assertContendedRequests(rows,'22',101),/EXACT_REQUEST_CONTENTION/) + assert.throws(()=>assertContendedRequests(both,'22',999),/FIRST_REQUEST_NO_LONGER_WAITING/) + assert.throws(()=>assertContendedRequests(both,'other',101),/LOCK_EVIDENCE_SCOPE/) + assert.throws(()=>assertContendedRequests(rows.slice(1),'22'),/LIVE_LEASE/) + assert.throws(()=>assertContendedRequests([...both,{collection:'wait',requestingConnectionId:103,blockingConnectionId:100,orderId:'22'}],'22',101),/EXACT_REQUEST_CONTENTION/) +}) diff --git a/scripts/fba-core-regression-evidence.mjs b/scripts/fba-core-regression-evidence.mjs new file mode 100644 index 000000000..fc8283591 --- /dev/null +++ b/scripts/fba-core-regression-evidence.mjs @@ -0,0 +1,187 @@ +// Internal explicit TEST evidence queries; no network or environment reads on import. +import { buildFleetInventorySql, fleetInventoryFromRows } from './fleet-inventory-evidence.mjs' +const stages = new Set(['BEFORE_COMPLETE', 'AFTER_COMPLETE', 'AFTER_SAME_KEY_REPLAY', 'AFTER_FRESH_KEY_REJECTION', 'AFTER_EVENT_RECOVERY']) +const numeric = new Set(['availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages', 'inTransitPackages', 'receivingPackages', 'procurementPackages', 'receivedPackages', 'beforePackages', 'deltaPackages', 'afterPackages', 'packageCount']) +const fields = { + sources: 'id customerId overseasWarehouseId sourceContainerBookingId containerNo sourceInboundId inboundCode sourceRegion packageCount', + sessions: 'id sourceContainerBookingId containerNo overseasWarehouseId', + balances: 'id customerId overseasWarehouseId destinationWarehouseId destinationWarehouseCode inboundOrderId inboundOrderNo inboundCode skuCode availablePackages holdPackages allocatedPackages pickedPackages inTransitPackages receivingPackages procurementPackages', + positions: 'id balanceId customerId overseasWarehouseId destinationWarehouseCode palletId palletNo batchNo warehouseLocationId warehouseLocationCode status availablePackages holdPackages allocatedPackages pickedPackages receivingPackages', + stockReceipts: 'id customerId planId taskId allocationId lineId orderId inboundCode skuCode warehousePalletId palletNo warehouseLocationId warehouseLocationCode batchNo receivedPackages inventoryBalanceId inventoryPositionId inventoryOperationKey', + inventoryOperations: 'id customerId idempotencyKey operationType requestHash status', + stockInLedger: 'id customerId balanceId positionId overseasWarehouseId inboundOrderId inboundCode palletId palletNo batchNo skuCode businessType businessOrderNo sourceEventId idempotencyKey statusFrom statusTo beforePackages deltaPackages afterPackages', + receivingOperations: 'id sessionId action idempotencyKey requestHash', +} +const snake = value => value.replace(/[A-Z]/g, letter => '_' + letter.toLowerCase()) +function identifier(value) { + if (typeof value !== 'string' || !/^[1-9][0-9]{0,19}$/.test(value)) throw Error('Invalid receiving evidence identifier') + return value +} +// UTF-8 hex literals avoid quote, backslash and SQL-mode ambiguity. +const literal = value => `(CONVERT(0x${Buffer.from(value, 'utf8').toString('hex')} USING utf8mb4) COLLATE utf8mb4_bin)` +function validateScope(scope) { + for (const name of ['customerId', 'warehouseId', 'bookingId']) identifier(scope?.[name]) + if (!stages.has(scope.stage) || typeof scope.containerNo !== 'string' || !scope.containerNo.trim() + || !Array.isArray(scope.inboundCodes) || !scope.inboundCodes.length + || scope.inboundCodes.some(code => typeof code !== 'string' || !code.trim() || code.length > 128) + || new Set(scope.inboundCodes).size !== scope.inboundCodes.length + || !Array.isArray(scope.sourceInboundIds) || scope.sourceInboundIds.length !== scope.inboundCodes.length + || new Set(scope.sourceInboundIds).size !== scope.sourceInboundIds.length) throw Error('Invalid receiving evidence scope') + scope.sourceInboundIds.forEach(identifier) + for (const code of scope.inboundCodes) if (!Number.isSafeInteger(scope.expectedPackagesByInbound?.[code]) + || scope.expectedPackagesByInbound[code] <= 0) throw Error('Invalid receiving evidence quantity') +} +export function buildReceivingEvidenceSql(scope) { + validateScope(scope) + const owned = alias => `${alias}.customer_id=${literal(scope.customerId)} AND ${alias}.overseas_warehouse_id=${literal(scope.warehouseId)}` + const source = `o.customer_id=${literal(scope.customerId)} AND o.source_container_booking_id=${literal(scope.bookingId)}` + const orderIds = `SELECT o.id FROM ll_order o WHERE ${source}` + const session = `s.source_container_booking_id=${literal(scope.bookingId)} AND s.container_no=${literal(scope.containerNo)} AND s.overseas_warehouse_id=${scope.warehouseId}` + const taskIds = `SELECT t.id FROM ll_inbound_pda_task t JOIN ll_inbound_plan p ON p.id=t.plan_id JOIN ll_order o ON o.id=p.source_order_id WHERE ${source} AND BINARY t.customer_id=BINARY o.customer_id AND BINARY p.customer_id=BINARY o.customer_id` + const query = (name, alias, from, where) => { + const pairs = fields[name].split(' ').map(field => { + const column = `${alias}.${snake(field)}` + return `'${field}',${numeric.has(field) ? column : `CAST(${column} AS CHAR)`}` + }).join(',') + return `SELECT JSON_OBJECT('collection','${name}','row',JSON_OBJECT(${pairs})) FROM ${from} WHERE ${where} ORDER BY ${alias}.id;` + } + return [ + 'SET NAMES utf8mb4 COLLATE utf8mb4_bin;', + 'SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;', + 'START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;', + query('sources', 'o', 'll_order o', source), + query('sessions', 's', 'll_receiving_container_session s', session), + query('balances', 'b', 'll_inventory_balance b', owned('b')), + query('positions', 'p', 'll_inventory_position p', owned('p')), + query('stockReceipts', 'r', 'll_inbound_stock_receipt r', `r.customer_id=${literal(scope.customerId)} AND r.order_id IN (${orderIds})`), + query('inventoryOperations', 'i', 'll_inventory_operation i', `i.customer_id=${literal(scope.customerId)} AND i.operation_type=${literal('STOCK_IN')} AND EXISTS (SELECT 1 FROM ll_inbound_pda_task t WHERE t.id IN (${taskIds}) AND i.idempotency_key LIKE (CONCAT('PDA_STOCK_IN:',t.id,':%') COLLATE utf8mb4_bin))`), + query('stockInLedger', 'l', 'll_inventory_ledger l', `${owned('l')} AND l.business_type='STOCK_IN' AND l.inbound_order_id IN (${orderIds})`), + query('receivingOperations', 'r', 'll_receiving_container_operation r', `r.action='COMPLETE' AND r.session_id IN (SELECT s.id FROM ll_receiving_container_session s WHERE ${session})`), + 'COMMIT;', + ].join('\n') +} +export function receivingEvidenceFromRows(scope, rows) { + validateScope(scope) + const result = Object.fromEntries(Object.keys(fields).map(name => [name, []])) + if (!Array.isArray(rows)) throw Error('Receiving evidence rows missing') + for (const item of rows) { + if (!Object.hasOwn(result, item?.collection) || !item.row) throw Error('Invalid receiving evidence collection') + result[item.collection].push(item.row) + } + const sources = result.sources + if (sources.length !== scope.inboundCodes.length + || new Set(sources.map(row => row.inboundCode)).size !== sources.length + || new Set(sources.map(row => row.sourceInboundId)).size !== sources.length + || sources.some(row => row.customerId !== scope.customerId || row.overseasWarehouseId !== scope.warehouseId + || row.sourceContainerBookingId !== scope.bookingId || row.containerNo !== scope.containerNo + || row.sourceRegion !== 'FIRSTLEG' || !scope.sourceInboundIds.includes(row.sourceInboundId) + || !scope.inboundCodes.includes(row.inboundCode) || row.packageCount !== scope.expectedPackagesByInbound[row.inboundCode]) + || result.sessions.length !== 1 || result.sessions.some(row => row.sourceContainerBookingId !== scope.bookingId + || row.containerNo !== scope.containerNo || row.overseasWarehouseId !== scope.warehouseId)) throw Error('Receiving evidence source/session scope mismatch') + delete result.sources + delete result.sessions + for (const rows of Object.values(result)) rows.sort((a, b) => String(a.id).localeCompare(String(b.id))) + result.palletComposition = result.positions.map(row => ({ ...row })) + return result +} +export function createReceivingEvidenceReader(executeReadOnly) { + if (typeof executeReadOnly !== 'function') throw Error('Receiving evidence transport missing') + return async scope => receivingEvidenceFromRows(scope, await executeReadOnly(buildReceivingEvidenceSql(scope))) +} + +const transactionStart = ['SET NAMES utf8mb4 COLLATE utf8mb4_bin;', + 'SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;', + 'START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;'] +export function assertReadOnlySql(sql) { + if (typeof sql !== 'string' || /--|\/\*|\b(?:INSERT|UPDATE|DELETE|REPLACE|DROP|ALTER|TRUNCATE|CALL|EXECUTE|PREPARE|INTO|OUTFILE|DUMPFILE|LOAD_FILE|SLEEP|BENCHMARK|GET_LOCK)\b/i.test(sql)) + throw Error('Read-only SQL rejected') + const lines = sql.split('\n').filter(Boolean) + if (!transactionStart.every((line,i) => lines[i] === line) || lines.at(-1) !== 'COMMIT;' + || lines.length < 5 || lines.slice(3,-1).some(line => !/^SELECT [^;]+;$/.test(line))) throw Error('Read-only transaction required') + return sql +} +export function buildCompletionEvidenceSql(scope) { + identifier(scope?.customerId); identifier(scope?.rootOrderId) + if (scope.eventType !== 'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED' || !Array.isArray(scope.sourceOrderIds) + || scope.sourceOrderIds.length !== 2 || new Set(scope.sourceOrderIds).size !== 2 + || !scope.sourceOrderIds.includes(scope.rootOrderId)) throw Error('Invalid completion evidence scope') + scope.sourceOrderIds.forEach(identifier) + return [...transactionStart, + `SELECT JSON_OBJECT('orderId',CAST(o.id AS CHAR),'customerId',CAST(o.tenant_id AS CHAR),'rootOrderId',CAST(o.root_order_id AS CHAR),'eventType','FIRST_LEG_ORDER_FULFILLMENT_COMPLETED','count',COUNT(x.id),'publishedCount',COALESCE(SUM(x.publish_status='PUBLISHED'),0),'pendingCount',COALESCE(SUM(x.publish_status='PENDING'),0),'failedCount',COALESCE(SUM(x.publish_status NOT IN ('PENDING','PUBLISHED')),0)) FROM ord_order o LEFT JOIN ord_outbox_event x ON x.aggregate_id=o.id AND x.tenant_id=o.tenant_id AND x.event_type='FIRST_LEG_ORDER_FULFILLMENT_COMPLETED' WHERE o.tenant_id=${scope.customerId} AND o.root_order_id=${scope.rootOrderId} AND o.id IN (${scope.sourceOrderIds.join(',')}) GROUP BY o.id,o.tenant_id,o.root_order_id ORDER BY o.id;`, + 'COMMIT;'].join('\n') +} +export function completionEvidenceFromRows(scope, rows) { + buildCompletionEvidenceSql(scope) + if (!Array.isArray(rows) || rows.length !== 2 || new Set(rows.map(row => row.orderId)).size !== 2 + || rows.some(row => !scope.sourceOrderIds.includes(row.orderId) || row.customerId !== scope.customerId + || row.rootOrderId !== scope.rootOrderId || row.eventType !== scope.eventType + || ['count','publishedCount','pendingCount','failedCount'].some(key => !Number.isSafeInteger(row[key]) || row[key] < 0))) + throw Error('Completion evidence scope or counts mismatch') + return {readOnly:true,customerId:scope.customerId,rootOrderId:scope.rootOrderId,orders:rows} +} +export const EVIDENCE_ENV_KEYS = ['JENKINS_URL','JENKINS_USER','JENKINS_API_TOKEN','FBA_CORE_JENKINS_AGENT'] +export function validateEvidenceConfig(env) { + if (EVIDENCE_ENV_KEYS.some(key => typeof env[key] !== 'string' || !env[key].trim())) throw Error('Missing TEST evidence configuration') + const url = new URL(env.JENKINS_URL) + if (!['http:','https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash + || !/^[A-Za-z0-9_.-]{1,128}$/.test(env.FBA_CORE_JENKINS_AGENT)) throw Error('Invalid TEST evidence configuration') +} +const base64Expression = value => '['+Buffer.from(value).toString('base64').match(/.{1,4000}/g).map(part=>`'${part}'`).join(',')+"].join('')" +// Credentials stay in the executor's memory. No deploy, SQL mutation, or arbitrary SQL entry point. +export function buildEvidenceRemoteScript(service, sql, agent) { + assertReadOnlySql(sql) + if (!['order','firstleg','lastleg'].includes(service) || !/^[A-Za-z0-9_.-]{1,128}$/.test(agent)) throw Error('Invalid TEST evidence target') + const prefix=service.toUpperCase(),container=service==='firstleg'?'firstleg-test-business':`firstleg-test-${service}` + const python = `import json,os,subprocess,urllib.parse,base64,re +try: + c=json.loads(subprocess.check_output(['docker','inspect','${container}'],text=True,stderr=subprocess.DEVNULL))[0] + if c.get('Name')!='/${container}' or c.get('Config',{}).get('Labels',{}).get('com.docker.compose.project')!='firstleg-test': raise RuntimeError('target') + e=dict(v.split('=',1) for v in c['Config']['Env']) + u=urllib.parse.urlsplit(e['${prefix}_DB_URL'].removeprefix('jdbc:')) + if not u.hostname or not u.path.lstrip('/'): raise RuntimeError('database') + childEnv=dict(os.environ,MYSQL_PWD=e['${prefix}_DB_PASSWORD']) + host='127.0.0.1' if u.hostname=='host.docker.internal' else u.hostname + sql=base64.b64decode('${Buffer.from(sql).toString('base64')}').decode('utf-8') + p=subprocess.run(['mysql','--host='+host,'--port='+str(u.port or 3306),'--user='+e['${prefix}_DB_USERNAME'],'--batch','--raw','--skip-column-names',u.path.lstrip('/')],input=sql,capture_output=True,text=True,env=childEnv,timeout=40) + if p.returncode: + code=re.search(r'ERROR ([0-9]{3,5})',p.stderr) + print('FBA_CORE_JSON='+json.dumps({'ok':False,'error':'MYSQL_'+(code.group(1) if code else 'UNKNOWN')})) + else: print('FBA_CORE_JSON='+json.dumps({'ok':True,'rows':[json.loads(line) for line in p.stdout.splitlines() if line.strip()]})) +except Exception: + print('FBA_CORE_JSON='+json.dumps({'ok':False,'error':'REMOTE_QUERY_FAILED'})) +` + const groovy=`def p=new ProcessBuilder(['python3','-c',new String(java.util.Base64.decoder.decode(${base64Expression(python)}),'UTF-8')]).redirectErrorStream(true).start(); def output=p.inputStream.getText('UTF-8'); p.waitFor(); print(output)` + return `def c=jenkins.model.Jenkins.instance.getComputer('${agent}'); if(c==null || c.channel==null) throw new RuntimeException('TEST evidence agent unavailable'); print(hudson.util.RemotingDiagnostics.executeGroovy(new String(java.util.Base64.decoder.decode(${base64Expression(groovy)}),'UTF-8'),c.channel));` +} +export function createTestEvidenceReaders(env, fetcher = globalThis.fetch) { + validateEvidenceConfig(env) + async function execute(service, sql) { + const script=buildEvidenceRemoteScript(service,sql,env.FBA_CORE_JENKINS_AGENT) + let response + try { + response=await fetcher(new URL('scriptText',env.JENKINS_URL.replace(/\/?$/,'/')),{ + method:'POST',headers:{Authorization:'Basic '+Buffer.from(env.JENKINS_USER+':'+env.JENKINS_API_TOKEN).toString('base64'), + 'Content-Type':'application/x-www-form-urlencoded'},body:new URLSearchParams({script}),signal:AbortSignal.timeout(55000)}) + } catch { throw Error('TEST evidence transport failed') } + if (!response.ok) throw Error('TEST evidence HTTP '+response.status) + let raw + try { raw=await response.text() } catch { throw Error('TEST evidence response failed') } + const lines=raw.split(/\r?\n/).filter(line=>line.startsWith('FBA_CORE_JSON=')) + if(lines.length!==1) throw Error('TEST evidence JSON missing or ambiguous') + let result + try { result=JSON.parse(lines[0].slice('FBA_CORE_JSON='.length)) } catch { throw Error('TEST evidence JSON invalid') } + if(result?.ok!==true || !Array.isArray(result.rows)) throw Error(/^MYSQL_[0-9]{3,5}$/.test(result?.error)?result.error:'TEST evidence query failed') + return result.rows + } + return { + readFleetInventory: async scope => fleetInventoryFromRows(scope, await execute('lastleg', buildFleetInventorySql(scope))), + readReceiving: createReceivingEvidenceReader(sql => execute('lastleg',sql)), + readCompletion: async scope => completionEvidenceFromRows(scope,await execute('order',buildCompletionEvidenceSql(scope))), + preflight: async () => { + for(const service of ['order','lastleg']) { + const rows=await execute(service,[...transactionStart,"SELECT JSON_OBJECT('testProbe',1);",'COMMIT;'].join('\n')) + if(rows.length!==1 || rows[0].testProbe!==1)throw Error('TEST evidence preflight failed') + } + }, + } +} diff --git a/scripts/fba-core-regression-evidence.test.mjs b/scripts/fba-core-regression-evidence.test.mjs new file mode 100644 index 000000000..fd6f99385 --- /dev/null +++ b/scripts/fba-core-regression-evidence.test.mjs @@ -0,0 +1,74 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { assertReadOnlySql, buildReceivingEvidenceSql, receivingEvidenceFromRows, buildCompletionEvidenceSql, + completionEvidenceFromRows, buildEvidenceRemoteScript, createTestEvidenceReaders } from './fba-core-regression-evidence.mjs' + +const env=()=>({JENKINS_URL:'https://ci.example.invalid/',JENKINS_USER:'offline-ci',JENKINS_API_TOKEN:'offline-api-secret',FBA_CORE_JENKINS_AGENT:'java-test-agent'}) +const scope=()=>({customerId:'2105209721220861954',warehouseId:'193000000000200003',bookingId:'2105220190013755394', + stage:'BEFORE_COMPLETE',containerNo:'TEST-ONE',inboundCodes:['IN-1'],sourceInboundIds:['2105220155339444226'], + expectedPackagesByInbound:{'IN-1':1}}) +const completion=()=>({customerId:'90',rootOrderId:'100',sourceOrderIds:['100','101'],eventType:'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED'}) +const completionRows=()=>['100','101'].map(orderId=>({customerId:'90',rootOrderId:'100',orderId, + eventType:'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED',count:orderId==='100'?1:0,publishedCount:orderId==='100'?1:0,pendingCount:0,failedCount:0})) + +test('both queries use consistent read-only transactions and exact scope without losing long identifiers',()=>{ + const sql=buildReceivingEvidenceSql(scope()) + assert.equal(buildReceivingEvidenceSql({...scope(),stage:'AFTER_EVENT_RECOVERY'}),sql) + assert.throws(()=>buildReceivingEvidenceSql({...scope(),stage:'UNKNOWN_RECOVERY'})) + assert.equal(assertReadOnlySql(sql),sql) + assert.match(sql,/START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY/) + assert.ok(sql.includes(Buffer.from(scope().customerId).toString('hex'))) + assert.doesNotMatch(sql,/customer_id=2105209721220861954/) + assert.match(sql,/CAST\(b.id AS CHAR\)/) + assert.match(assertReadOnlySql(buildCompletionEvidenceSql(completion())),/LEFT JOIN ord_outbox_event/) +}) +test('read-only guard rejects mutation, multiple statements, lock or unsafe select effects',()=>{ + const sql=buildCompletionEvidenceSql(completion()) + for(const statement of ['DELETE FROM ord_order;','SELECT 1 INTO OUTFILE \'x\';','SELECT SLEEP(1);', + 'SELECT 1; DROP TABLE x;','SET SESSION autocommit=1;'])assert.throws(()=>assertReadOnlySql(sql.replace(/SELECT .+;/,statement))) + assert.throws(()=>assertReadOnlySql('SELECT 1;')) + assert.throws(()=>buildEvidenceRemoteScript('prod',sql,'test')) +}) +test('invalid and injected IDs fail before transport; cancelled child zero evidence cannot disappear',()=>{ + assert.throws(()=>buildReceivingEvidenceSql({...scope(),bookingId:'1 OR 1=1'})) + assert.throws(()=>buildCompletionEvidenceSql({...completion(),sourceOrderIds:['100','100']})) + assert.equal(completionEvidenceFromRows(completion(),completionRows()).orders[1].count,0) + for(const mutate of [r=>r.pop(),r=>r[1].customerId='91',r=>r[1].count=null,r=>r[1].orderId='100']) { + const rows=completionRows();mutate(rows);assert.throws(()=>completionEvidenceFromRows(completion(),rows)) + } +}) +test('receiving rows require exact source and session; empty or foreign scope fails',()=>{ + assert.throws(()=>receivingEvidenceFromRows(scope(),[])) + const s=scope(),rows=[{collection:'sources',row:{id:'1',customerId:s.customerId,overseasWarehouseId:s.warehouseId, + sourceContainerBookingId:s.bookingId,containerNo:s.containerNo,sourceInboundId:s.sourceInboundIds[0],inboundCode:'IN-1',sourceRegion:'FIRSTLEG',packageCount:1}}, + {collection:'sessions',row:{id:'2',sourceContainerBookingId:s.bookingId,containerNo:s.containerNo,overseasWarehouseId:s.warehouseId}}] + assert.deepEqual(receivingEvidenceFromRows(s,rows).balances,[]) + rows[0].row.customerId='99';assert.throws(()=>receivingEvidenceFromRows(s,rows)) +}) +test('transport frames valid empty rows and suppresses raw remote and connection secrets',async()=>{ + let calls=0 + const transport=createTestEvidenceReaders(env(),async()=>{calls++;return {ok:true,text:async()=> 'FBA_CORE_JSON='+JSON.stringify({ok:true,rows:[{testProbe:1}]})}}) + await transport.preflight();assert.equal(calls,2) + for(const output of ['secret-leak','FBA_CORE_JSON={bad','FBA_CORE_JSON={"ok":false,"error":"password=secret-leak"}']) { + const reader=createTestEvidenceReaders(env(),async()=>({ok:true,text:async()=>output})) + await assert.rejects(reader.preflight(),error=>!error.message.includes('secret-leak')) + } + const reader=createTestEvidenceReaders(env(),async()=>{throw Error('offline-api-secret')}) + await assert.rejects(reader.preflight(),/TEST evidence transport failed/) + const script=buildEvidenceRemoteScript('order',buildCompletionEvidenceSql(completion()),'java-test-agent') + assert.equal(script.includes(env().JENKINS_API_TOKEN),false) +}) + +test('Firstleg receiver evidence stays on its exact TEST container and rejects writes', () => { + const sql = buildCompletionEvidenceSql(completion()) + const decode = value => Buffer.from([...value.matchAll(/'([A-Za-z0-9+/=]{40,})'/g)].map(m => m[1]).join(''), 'base64').toString('utf8') + const python = decode(decode(buildEvidenceRemoteScript('firstleg', sql, 'java-test-agent'))) + assert.match(python, /docker','inspect','firstleg-test-business'/) + assert.match(python, /get\('Name'\)!='\/firstleg-test-business'/) + assert.match(python, /com\.docker\.compose\.project'\)!='firstleg-test'/) + assert.match(python, /e\['FIRSTLEG_DB_URL'\]/) + assert.match(python, /e\['FIRSTLEG_DB_USERNAME'\]/) + assert.match(python, /MYSQL_PWD=e\['FIRSTLEG_DB_PASSWORD'\]/) + assert.throws(() => buildEvidenceRemoteScript('firstleg', sql.replace('COMMIT;', 'DELETE FROM fl_inbound_order;\nCOMMIT;'), 'java-test-agent')) + assert.throws(() => buildEvidenceRemoteScript('firstleg-prod', sql, 'java-test-agent')) +}) diff --git a/scripts/fba-core-regression-test-env.mjs b/scripts/fba-core-regression-test-env.mjs new file mode 100644 index 000000000..621416ae0 --- /dev/null +++ b/scripts/fba-core-regression-test-env.mjs @@ -0,0 +1,195 @@ +import { mkdirSync, writeFileSync, openSync, closeSync, unlinkSync } from 'node:fs' +import { resolve, dirname, basename, relative, isAbsolute, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { createHash } from 'node:crypto' +import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs' +import { createTestEvidenceReaders, validateEvidenceConfig, EVIDENCE_ENV_KEYS } from './fba-core-regression-evidence.mjs' + +const repositoryRoot=resolve(dirname(fileURLToPath(import.meta.url)),'..') +export const F40_MODES=Object.freeze(['ROOT_FIRST','CHILD_FIRST','ROOT_SIGNED_REMAINDER_CANCELLED']) +export const F40_TAX_MODES=Object.freeze(['BUY_DECLARATION','TAX_REFUND']) +export const REQUIRED_ENV_KEYS=Object.freeze(['CHONGCHONG_ADMIN_API_BASE_URL','CHONGCHONG_ADMIN_USERNAME', + 'CHONGCHONG_ADMIN_PASSWORD','ADMIN_PDA_USERNAME','ADMIN_PDA_PASSWORD','FBA_CORE_TEST_GATEWAY_ORIGIN',...EVIDENCE_ENV_KEYS]) + +export function parseCoreArguments(argv) { + const values={},flags=new Set() + for(let i=0;i`${mode.toLowerCase()}.json`),'evidence/*.json'], + retention:'Exclusive run directory and evidence files. Interrupted lock requires operator review; no automatic cleanup or resume.', + scopeLimit:config.verifyDeliveryEventRecovery?'Only the selected first delivery event and listed F40 mode; not all reverse events, fee-void recovery, or UI acceptance.' + :'Only the listed F40 modes and receiving/completion evidence; not all F01-F40, UI acceptance or reverse event replay.'} +} +export function validateCoreEnvironment(env,root=repositoryRoot) { + if(basename(root)!=='baiyue-firstleg'||basename(dirname(root))!=='chongchong-global')throw Error('TEST workspace required') + if(REQUIRED_ENV_KEYS.some(key=>typeof env[key]!=='string'||!env[key].trim()))throw Error('Missing required TEST configuration') + let base,expected + try {base=new URL(env.CHONGCHONG_ADMIN_API_BASE_URL);expected=new URL(env.FBA_CORE_TEST_GATEWAY_ORIGIN)}catch{throw Error('Invalid TEST gateway configuration')} + if(!['http:','https:'].includes(base.protocol)||base.username||base.password||base.search||base.hash + ||expected.href!==expected.origin+'/'||base.origin!==expected.origin + ||/(?:^|[.-])(?:dev|prod|production)(?:[.-]|$)/i.test(base.hostname))throw Error('TEST gateway origin mismatch') + if(Boolean(env.CHONGCHONG_MERCHANT_USERNAME)!==Boolean(env.CHONGCHONG_MERCHANT_PASSWORD))throw Error('Incomplete optional merchant credentials') + validateEvidenceConfig(env) + return base.origin +} +export function redactEvidence(value,secrets=[]) { + // Long runs can register the same credential repeatedly. Normalize once per + // report, rather than filtering and reapplying it for every nested string. + const uniqueSecrets=[...new Set(secrets)].filter(secret=>typeof secret==='string'&&secret.length>0) + function redact(item) { + if(Array.isArray(item))return item.map(redact) + if(item&&typeof item==='object')return Object.fromEntries(Object.entries(item).map(([key,child])=>[key, + /^(?:.*password|.*token|authorization|cookie|gateway|jenkinsUrl|privateKey)$/i.test(key)?'[REDACTED]':redact(child)])) + if(typeof item==='string')return uniqueSecrets.reduce((text,secret)=>text.split(secret).join('[REDACTED]'),item) + return item + } + return redact(value) +} +export function modePassed(report) { + const proof=report?.createdResources?.phase5RootFulfillment + return report?.success===true&&report?.summary?.failed===0&&proof?.finalStatus==='VERIFIED' + && !(report.deploymentBlockers||[]).length && !(report.steps||[]).some(step=>step.passed===false) +} +function safeFailure(error) { + const message=typeof error?.message==='string'?error.message:'' + const mysql=/^MYSQL_([0-9]{3,5})$/.exec(message) + const http=/^TEST evidence HTTP ([1-5][0-9]{2})$/.exec(message) + return mysql?{code:'READ_ONLY_MYSQL_FAILED',mysqlError:Number(mysql[1])} + :http?{code:'READ_ONLY_HTTP_FAILED',http:Number(http[1])}:{code:'EXECUTION_FAILED'} +} +function createAcceptanceContext(mode,config,env,gateway) { + const options=validateExecutionOptions({...parseArguments(['--modules','phase5-root-fulfillment','--quiet','true']), + environment:'test',gateway,username:env.CHONGCHONG_ADMIN_USERNAME,password:env.CHONGCHONG_ADMIN_PASSWORD, + pdaUsername:env.ADMIN_PDA_USERNAME,pdaPassword:env.ADMIN_PDA_PASSWORD, + merchantUsername:env.CHONGCHONG_MERCHANT_USERNAME||'',merchantPassword:env.CHONGCHONG_MERCHANT_PASSWORD||'', + markerNamespace:createHash('sha256').update(config.runId+':'+mode).digest('hex').slice(0,12)}) + return new AcceptanceRun(options) +} +export async function runFbaCore(config,{env=process.env,readers,createContext=createAcceptanceContext,outputRoot=repositoryRoot,faultFactory}={}) { + const scopeDescription=taxScope(config.taxMode) + const modes=selectedModes(config) + const gateway=validateCoreEnvironment(env) + if(config.verifyDeliveryEventRecovery&&(env.CHONGCHONG_ENV!=='test'||!config.signatureMode))throw Error('Delivery fault requires explicit TEST and one signature mode') + const output=resolve(outputRoot,config.outputDir),reports=resolve(outputRoot,'reports'),rel=relative(reports,output) + if(!config.execute||config.environment!=='TEST'||!rel||rel.startsWith('..')||isAbsolute(rel))throw Error('Fresh report directory inside reports required') + mkdirSync(reports,{recursive:true}) + const lock=join(reports,'.fba-core-regression.lock') + const lockFd=openSync(lock,'wx') + const secrets=[...REQUIRED_ENV_KEYS,'CHONGCHONG_MERCHANT_USERNAME','CHONGCHONG_MERCHANT_PASSWORD'] + .filter(key=>/PASSWORD|TOKEN|USERNAME|JENKINS_USER/.test(key)).map(key=>env[key]) + const summary={runId:config.runId,environment:'TEST',taxMode:config.taxMode,verifyDeliveryEventRecovery:config.verifyDeliveryEventRecovery===true,startedAt:new Date().toISOString(),success:false,modes:[],scope:scopeDescription} + let initialized=false + const merchantBefore={username:process.env.MERCHANT_TEST_USERNAME,password:process.env.MERCHANT_TEST_PASSWORD} + try { + writeFileSync(lockFd,JSON.stringify({runId:config.runId,pid:process.pid,startedAt:summary.startedAt})) + mkdirSync(output,{recursive:false});initialized=true + mkdirSync(join(output,'evidence')) + const save=(name,value)=>writeFileSync(join(output,name),JSON.stringify(redactEvidence(value,secrets),null,2)+'\n',{flag:'wx'}) + save('manifest.json',executionPlan(config)) + readers ||= createTestEvidenceReaders(env) + await readers.preflight() + for(const mode of modes) { + const context=createContext(mode,config,env,gateway) + let sequence=0 + const evidence=async(kind,scope,read)=>{ + const record={mode,taxMode:config.taxMode,kind,scope,capturedAt:new Date().toISOString(),captured:false} + try {record.evidence=await read(scope);record.captured=true;return record.evidence} + catch (error) {record.error='READ_ONLY_EVIDENCE_FAILED';record.diagnostic=safeFailure(error);throw Error('Read-only evidence failed')} + finally {save(`evidence/${mode.toLowerCase()}-${String(++sequence).padStart(4,'0')}-${kind}.json`,record)} + } + context.context.f40SignatureOrder=mode + context.context.f40TaxMode=config.taxMode + context.context.f40ReadOnlyOutboxEvidence=scope=>evidence('completion',scope,readers.readCompletion) + context.context.phase4ReceivingEvidenceReader=scope=>evidence('receiving',scope,readers.readReceiving) + let fault, faultPreparationAttempted=false + if(config.verifyDeliveryEventRecovery) { + if(typeof readers.readFleetInventory!=='function')throw Error('Complete raw inventory reader required for delivery recovery') + const factory=faultFactory||(await import('./test-event-fault-executor.mjs')).createTestDeliveryFaultExecutor + const executor=factory(env) + fault={...executor,prepare:async scope=>{ + save(`evidence/${mode.toLowerCase()}-delivery-scope.json`,{scope,stage:'PREPARE_INTENT'}) + faultPreparationAttempted=true;return executor.prepare(scope) + }} + context.context.f40DeliveryEventFaultExecutor=fault + context.context.f40ReadOnlyInventoryEvidence=scope=>evidence('inventory',scope,readers.readFleetInventory) + } + let report + try {report=await context.run()} + catch (error) {report={success:false,fatal:'FBA_CORE_MODE_EXCEPTION',diagnostic:safeFailure(error),summary:{failed:1},createdResources:context.createdResources}} + finally { + secrets.push(context.token,context.pdaToken,context.refreshToken,context.context?.merchantQualificationFixture?.password) + if(fault) { + try { + const restoration=await fault.cleanup() + save(`evidence/${mode.toLowerCase()}-delivery-restoration.json`,{attempted:faultPreparationAttempted,restoration:restoration??null}) + if(faultPreparationAttempted&&(restoration?.restored!==true||restoration.healthy!==true||restoration.proxyStopped!==true))throw Error('Delivery restoration unproven') + } catch(error) {report={...report,success:false,deliveryRestorationFailed:true,restorationDiagnostic:safeFailure(error)}} + } + } + if(config.verifyDeliveryEventRecovery) { + const proof=report?.createdResources?.phase5RootFulfillment?.deliveryEventRecovery + if(proof?.status!=='VERIFIED')report={...report,success:false,deliveryRecoveryUnproven:true} + } + report={...report,regressionScope:{taxMode:config.taxMode,signatureOrder:mode,description:scopeDescription}} + const name=`${mode.toLowerCase()}.json`;save(name,report) + const passed=modePassed(report) + summary.modes.push({mode,taxMode:config.taxMode,report:name,passed,summary:report.summary,evidenceFiles:sequence}) + if(!passed)break + } + summary.success=summary.modes.length===modes.length&&summary.modes.every(mode=>mode.passed) + } catch (error) {summary.failure='FBA_CORE_PREFLIGHT_OR_RUN_FAILED';summary.diagnostic=safeFailure(error)} + finally { + summary.finishedAt=new Date().toISOString() + try { + if(initialized)writeFileSync(join(output,'summary.json'),JSON.stringify(redactEvidence(summary,secrets),null,2)+'\n',{flag:'wx'}) + } finally { + closeSync(lockFd);unlinkSync(lock) + for(const [key,value] of [['MERCHANT_TEST_USERNAME',merchantBefore.username],['MERCHANT_TEST_PASSWORD',merchantBefore.password]]) { + if(value===undefined)delete process.env[key];else process.env[key]=value + } + } + } + return summary +} +if(process.argv[1]&&import.meta.url===pathToFileURL(resolve(process.argv[1])).href) { + try { + const config=parseCoreArguments(process.argv.slice(2)) + const result=config.execute?await runFbaCore(config):executionPlan(config) + console.log(JSON.stringify(result,null,2));if(config.execute&&!result.success)process.exitCode=1 + }catch {console.error('FBA core rejected configuration or existing run/lock; no automatic overwrite.');process.exitCode=1} +} diff --git a/scripts/fba-core-regression-test-env.test.mjs b/scripts/fba-core-regression-test-env.test.mjs new file mode 100644 index 000000000..3e8d11926 --- /dev/null +++ b/scripts/fba-core-regression-test-env.test.mjs @@ -0,0 +1,171 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, mkdirSync, writeFileSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { F40_MODES, parseCoreArguments, executionPlan, validateCoreEnvironment, redactEvidence, runFbaCore } from './fba-core-regression-test-env.mjs' + +const env=()=>({CHONGCHONG_ADMIN_API_BASE_URL:'https://test.example.invalid/api',FBA_CORE_TEST_GATEWAY_ORIGIN:'https://test.example.invalid', + CHONGCHONG_ADMIN_USERNAME:'offline-admin',CHONGCHONG_ADMIN_PASSWORD:'offline-password',ADMIN_PDA_USERNAME:'offline-pda', + ADMIN_PDA_PASSWORD:'offline-pda-password',JENKINS_URL:'https://ci.example.invalid/',JENKINS_USER:'offline-ci', + JENKINS_API_TOKEN:'offline-api-secret',FBA_CORE_JENKINS_AGENT:'java-test-agent'}) +const config=()=>parseCoreArguments(['--execute','--environment','TEST','--run-id','offline-check']) +const scope=()=>({customerId:'2105209721220861954',warehouseId:'193000000000200003',bookingId:'2105220190013755394', + stage:'BEFORE_COMPLETE',containerNo:'TEST-ONE',inboundCodes:['IN-1'],sourceInboundIds:['2105220155339444226'], + expectedPackagesByInbound:{'IN-1':1}}) +const completion=()=>({customerId:'90',rootOrderId:'100',sourceOrderIds:['100','101'],eventType:'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED'}) +const root=()=>mkdtempSync(join(tmpdir(),'fba-core-contract-')) +const readers=()=>({preflight:async()=>{},readReceiving:async()=>({balances:[]}),readCompletion:async()=>({orders:[]})}) +const pass=()=>({success:true,summary:{failed:0},steps:[],deploymentBlockers:[],createdResources:{phase5RootFulfillment:{finalStatus:'VERIFIED'}}}) + +test('default plan is offline and exposes exact serial modes, required keys and retained reports',()=>{ + const plan=executionPlan(parseCoreArguments([])) + assert.equal(plan.execution,'PLAN');assert.deepEqual(plan.modes,F40_MODES) + assert.equal(plan.taxMode,'BUY_DECLARATION');assert.match(plan.scope,/BUY_DECLARATION/) + assert.ok(plan.requiredEnvironmentKeys.includes('ADMIN_PDA_PASSWORD')) + for(const args of [['--execute'],['--execute','--environment','PROD','--run-id','x'],['--plan','--execute'],['--mode','ROOT_FIRST']])assert.throws(()=>parseCoreArguments(args)) +}) +test('tax mode is explicit, defaults to BUY and rejects aliases, duplicate and unsupported values',()=>{ + const tax=parseCoreArguments(['--plan','--tax-mode','TAX_REFUND']) + assert.equal(tax.taxMode,'TAX_REFUND') + const plan=executionPlan(tax) + assert.deepEqual(plan.modes,F40_MODES);assert.match(plan.scope,/post-COMPLETED customer document reads\/downloads/) + for(const args of [['--tax-mode','REFUND'],['--tax-mode','tax_refund'],['--tax-mode',''],['--taxMode','TAX_REFUND'], + ['--tax-mode','TAX_REFUND','--tax-mode','BUY_DECLARATION']])assert.throws(()=>parseCoreArguments(args)) +}) + +test('explicit single signature mode runs only that mode and reports the narrowed scope', async()=>{ + const cfg=parseCoreArguments(['--execute','--environment','TEST','--run-id','single-mode','--signature-mode','ROOT_FIRST']) + assert.deepEqual(executionPlan(cfg).modes,['ROOT_FIRST']) + assert.doesNotMatch(executionPlan(cfg).reports.join(','),/child_first/) + const seen=[] + const summary=await runFbaCore(cfg,{env:env(),readers:readers(),outputRoot:root(),createContext:mode=>({context:{},async run(){seen.push(mode);return pass()}})}) + assert.equal(summary.success,true);assert.deepEqual(seen,['ROOT_FIRST']);assert.equal(summary.modes.length,1) + for(const args of [['--signature-mode','ALL'],['--signature-mode','root_first'],['--signature-mode','ROOT_FIRST','--signature-mode','CHILD_FIRST']])assert.throws(()=>parseCoreArguments(args)) +}) +test('both tax modes pass unchanged to all three existing contexts and persist on all report surfaces',async()=>{ + for(const taxMode of ['BUY_DECLARATION','TAX_REFUND']){ + const outputRoot=root(),cfg={...config(),taxMode},seen=[] + const result=await runFbaCore(cfg,{env:env(),outputRoot,readers:readers(),createContext:mode=>({context:{},async run(){ + seen.push({mode,taxMode:this.context.f40TaxMode}) + await this.context.phase4ReceivingEvidenceReader(scope()) + return pass() + }})}) + assert.equal(result.success,true);assert.equal(result.taxMode,taxMode) + assert.deepEqual(seen,F40_MODES.map(mode=>({mode,taxMode}))) + const read=name=>JSON.parse(readFileSync(join(outputRoot,cfg.outputDir,name),'utf8')) + assert.equal(read('manifest.json').taxMode,taxMode) + assert.equal(read('summary.json').taxMode,taxMode) + assert.ok(read('summary.json').modes.every(mode=>mode.taxMode===taxMode)) + for(const mode of F40_MODES){ + const report=read(mode.toLowerCase()+'.json') + assert.equal(report.regressionScope.taxMode,taxMode) + assert.equal(report.regressionScope.signatureOrder,mode) + assert.match(report.regressionScope.description,new RegExp(taxMode)) + assert.equal(read(`evidence/${mode.toLowerCase()}-0001-receiving.json`).taxMode,taxMode) + } + } +}) +test('tax mode exception is retained with exact scope and does not start later fixtures',async()=>{ + const outputRoot=root(),cfg={...config(),taxMode:'TAX_REFUND'},seen=[] + const result=await runFbaCore(cfg,{env:env(),outputRoot,readers:readers(),createContext:mode=>({context:{},createdResources:{},async run(){ + seen.push(mode);throw Error('private failure') + }})}) + assert.deepEqual(seen,['ROOT_FIRST']);assert.equal(result.success,false);assert.equal(result.taxMode,'TAX_REFUND') + const report=JSON.parse(readFileSync(join(outputRoot,cfg.outputDir,'root_first.json'),'utf8')) + assert.equal(report.regressionScope.taxMode,'TAX_REFUND');assert.equal(report.fatal,'FBA_CORE_MODE_EXCEPTION') +}) +test('TEST configuration rejects cross environment origins, embedded credentials and wrong workspace',()=>{ + assert.equal(validateCoreEnvironment(env()),'https://test.example.invalid') + for(const changed of [{FBA_CORE_TEST_GATEWAY_ORIGIN:'https://other.example.invalid'}, + {CHONGCHONG_ADMIN_API_BASE_URL:'https://user:secret@test.example.invalid'}, + {CHONGCHONG_ADMIN_API_BASE_URL:'https://prod.example.invalid',FBA_CORE_TEST_GATEWAY_ORIGIN:'https://prod.example.invalid'}, + {ADMIN_PDA_PASSWORD:''},{FBA_CORE_JENKINS_AGENT:"test');println('bad"}])assert.throws(()=>validateCoreEnvironment({...env(),...changed})) + assert.throws(()=>validateCoreEnvironment(env(),'C:/wrong/chongchong-global-dev/baiyue-firstleg')) +}) +test('serial runner injects both readers and emits all modes without overlap',async()=>{ + const outputRoot=root(),order=[];let active=0 + const summary=await runFbaCore(config(),{env:env(),outputRoot,readers:readers(),createContext:mode=>({context:{},async run(){ + assert.equal(active++,0);order.push(mode) + assert.equal(this.context.f40SignatureOrder,mode) + await this.context.phase4ReceivingEvidenceReader(scope());await this.context.f40ReadOnlyOutboxEvidence(completion()) + active--;return pass() + }})}) + assert.equal(summary.success,true);assert.deepEqual(order,F40_MODES) + const saved=JSON.parse(readFileSync(join(outputRoot,config().outputDir,'summary.json'),'utf8')) + assert.equal(saved.modes.length,3);assert.equal(saved.modes[0].evidenceFiles,2) +}) +test('first failed mode stops later fixtures and keeps report plus evidence',async()=>{ + const outputRoot=root(),calls=[] + const result=await runFbaCore(config(),{env:env(),outputRoot,readers:readers(),createContext:mode=>({context:{},async run(){ + calls.push(mode);await this.context.phase4ReceivingEvidenceReader(scope());return {...pass(),success:false} + }})}) + assert.equal(result.success,false);assert.deepEqual(calls,['ROOT_FIRST']) + assert.ok(existsSync(join(outputRoot,config().outputDir,'root_first.json'))) + assert.ok(existsSync(join(outputRoot,config().outputDir,'evidence/root_first-0001-receiving.json'))) +}) +test('reader exception journals failure, hides secret and prevents next mode',async()=>{ + const outputRoot=root(),r=readers();r.readReceiving=async()=>{throw Error('offline-api-secret')} + const result=await runFbaCore(config(),{env:env(),outputRoot,readers:r,createContext:()=>({context:{},createdResources:{},async run(){ + await this.context.phase4ReceivingEvidenceReader(scope());return pass() + }})}) + assert.equal(result.modes.length,1);assert.equal(result.success,false) + const text=readFileSync(join(outputRoot,config().outputDir,'evidence/root_first-0001-receiving.json'),'utf8') + assert.match(text,/READ_ONLY_EVIDENCE_FAILED/);assert.doesNotMatch(text,/offline-api-secret/) +}) +test('preflight failure creates summary but no HTTP fixture; existing run or active lock cannot overwrite',async()=>{ + const outputRoot=root(),r=readers();let created=0;r.preflight=async()=>{throw Error('private')} + await runFbaCore(config(),{env:env(),outputRoot,readers:r,createContext:()=>{created++}}) + assert.equal(created,0) + const summaryPath=join(outputRoot,config().outputDir,'summary.json'),before=readFileSync(summaryPath,'utf8') + await runFbaCore(config(),{env:env(),outputRoot,readers:readers(),createContext:()=>{created++}}) + assert.equal(readFileSync(summaryPath,'utf8'),before);assert.equal(created,0) + const lock=join(outputRoot,'reports/.fba-core-regression.lock');writeFileSync(lock,'occupied') + await assert.rejects(runFbaCore({...config(),runId:'next',outputDir:'reports/next'},{env:env(),outputRoot,readers:readers()})) + assert.equal(readFileSync(lock,'utf8'),'occupied') +}) +test('redaction scans the secret input once for the complete report tree',()=>{ + let reads=0 + const values=Array.from({length:80},()=> 'synthetic-private-value') + const secrets=new Proxy(values,{get(target,key,receiver){if(/^\d+$/.test(String(key)))reads++;return Reflect.get(target,key,receiver)}}) + const report={steps:Array.from({length:40},()=>({message:'prefix synthetic-private-value suffix',nested:{token:'hidden',public:'kept'}}))} + const result=redactEvidence(report,secrets) + assert.ok(result.steps.every(row=>row.message==='prefix [REDACTED] suffix'&&row.nested.token==='[REDACTED]'&&row.nested.public==='kept')) + assert.equal(reads,values.length,'normalization must not repeat for every evidence string') + assert.equal(report.steps[0].message,'prefix synthetic-private-value suffix','input report is not modified') +}) + +test('reports redact dynamically created account secrets and environment/token values',()=>{ + const value=redactEvidence({password:'x',pdaToken:'y',message:'failure private-secret',nested:{gateway:'url',value:'safe'}},['private-secret']) + assert.equal(value.password,'[REDACTED]');assert.equal(value.pdaToken,'[REDACTED]') + assert.equal(value.message,'failure [REDACTED]');assert.equal(value.nested.value,'safe') +}) + +test('delivery fault requires one explicit mode and verified proof plus restoration, even after caught run failures', async()=>{ + assert.throws(()=>parseCoreArguments(['--plan','--verify-delivery-event-recovery'])) + const cfg=parseCoreArguments(['--execute','--environment','TEST','--run-id','fault-one','--signature-mode','ROOT_FIRST','--verify-delivery-event-recovery']) + assert.equal(executionPlan(cfg).verifyDeliveryEventRecovery,true) + for(const failure of ['none','context','proof','restore']) { + const outputRoot=root();let cleanups=0,prepares=0 + const r={...readers(),readFleetInventory:async scope=>({scope,balances:[],positions:[],ledger:[]})} + const summary=await runFbaCore(cfg,{env:{...env(),CHONGCHONG_ENV:'test'},readers:r,outputRoot, + faultFactory:()=>({prepare:async()=>{prepares++},cleanup:async()=>{ + cleanups++;if(failure==='restore')throw Error('private failure') + return {restored:true,healthy:true,proxyStopped:true} + }}),createContext:()=>({context:{},createdResources:{},async run(){ + await this.context.f40DeliveryEventFaultExecutor.prepare({marker:'F40',customerId:'90',deliveryId:'91'}) + await this.context.f40ReadOnlyInventoryEvidence({marker:'F40',customerId:'90',warehouseId:'92'}) + if(failure==='context')throw Error('private failure') + const report=pass() + if(failure!=='proof')report.createdResources.phase5RootFulfillment.deliveryEventRecovery={status:'VERIFIED'} + return report + }})}) + assert.equal(summary.success,failure==='none') + assert.equal(cleanups,1);assert.equal(prepares,1) + const evidencePath=join(outputRoot,cfg.outputDir,'evidence') + assert.ok(existsSync(join(evidencePath,'root_first-delivery-scope.json'))) + assert.ok(existsSync(join(evidencePath,'root_first-0001-inventory.json'))) + const report=JSON.parse(readFileSync(join(outputRoot,cfg.outputDir,'root_first.json'),'utf8')) + if(failure==='restore')assert.equal(report.deliveryRestorationFailed,true) + } +}) diff --git a/scripts/fba-remaining-nodes-acceptance-pda-operator.mjs b/scripts/fba-remaining-nodes-acceptance-pda-operator.mjs new file mode 100644 index 000000000..b6d675547 --- /dev/null +++ b/scripts/fba-remaining-nodes-acceptance-pda-operator.mjs @@ -0,0 +1,66 @@ +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' + +// Explicit one-time TEST setup. No inventory operations, existing-user changes or role changes. +export async function createF33PdaOperator({ env, credentialFile, reportFile }) { + if (String(env.CHONGCHONG_ENV).toUpperCase() !== 'TEST') throw Error('EXPLICIT_TEST_REQUIRED') + if (fs.existsSync(credentialFile) || fs.existsSync(reportFile)) throw Error('EXISTING_RESULT_RECONCILE_NO_RECREATE') + const warehouseId = '193000000000200003', roleCode = 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' + const report = { startedAt: new Date().toISOString(), warehouseId, roleCode, requests: [], success: false, + existingAccountsChanged: false, rolePermissionsChanged: false, businessOperationsIssued: 0 } + const save = () => { fs.mkdirSync(path.dirname(reportFile), {recursive:true}); fs.writeFileSync(reportFile, JSON.stringify(report,null,2)) } + let admin, pda + const call = async(method, route, body, token=admin?.accessToken) => { + const entry={method,path:route.split('?')[0],phase:'INTENT'};report.requests.push(entry);save() + let response + try { response=await fetch(env.CHONGCHONG_ADMIN_API_BASE_URL.replace(/\/$/,'')+route,{method,redirect:'error',signal:AbortSignal.timeout(20000),headers:{Accept:'application/json',...(token?{Authorization:`Bearer ${token}`} : {}),...(body?{'Content-Type':'application/json'}:{})},...(body?{body:JSON.stringify(body)}:{})}) } + catch {entry.phase='OUTCOME_UNKNOWN';save();throw Error('OUTCOME_UNKNOWN_RECONCILE_NO_RETRY')} + const value=JSON.parse(await response.text());Object.assign(entry,{phase:'ACKNOWLEDGED',http:response.status,code:String(value.code),requestId:response.headers.get('x-request-id'),traceId:response.headers.get('x-trace-id')});save() + if(response.status!==200||String(value.code)!=='200')throw Error(`FORMAL_API_REJECTED_${response.status}_${value.code}`) + return value.data + } + try { + admin=await call('POST','/auth/login',{loginId:env.CHONGCHONG_ADMIN_USERNAME,password:env.CHONGCHONG_ADMIN_PASSWORD},'') + const roles=await call('GET','/platform/v1/iam/roles') + const permissions=await call('GET','/platform/v1/iam/permissions') + const role=roles.find(r=>r.roleCode===roleCode&&r.status==='ACTIVE') + const operate=permissions.find(p=>p.permissionCode==='lastleg:pda:operate') + if(!role||!operate||!(role.permissionIds||[]).some(id=>String(id)===String(operate.id)))throw Error('EXISTING_ROLE_OPERATE_PERMISSION_REQUIRED') + let phone + for(let i=0;i<10;i++){ + const candidate='18'+String(crypto.randomInt(0,1000000000)).padStart(9,'0') + if(await call('GET',`/platform/v1/iam/tenants/administrator-phone-availability?phone=${candidate}`)){phone=candidate;break} + } + if(!phone)throw Error('NO_AVAILABLE_SYNTHETIC_PHONE') + const username='pda_f33_20261004_'+crypto.randomBytes(5).toString('hex') + const password='Pda1!'+crypto.randomBytes(18).toString('hex') + const writeCredentials = membershipId => fs.writeFileSync(credentialFile, + `PDA_USERNAME=${username}\nPDA_PASSWORD=${password}\nPDA_MEMBERSHIP_ID=${membershipId}\n`,{mode:0o600}) + fs.mkdirSync(path.dirname(credentialFile),{recursive:true}) + fs.writeFileSync(credentialFile,`PDA_USERNAME=${username}\nPDA_PASSWORD=${password}\nPDA_MEMBERSHIP_ID=\n`,{flag:'wx',mode:0o600}) + const created=await call('POST','/platform/v1/iam/users',{username,password,phone, + displayName:'F33海外PDA独立验收-20261004',email:username+'@example.com',tenantId:'0',roleIds:[String(role.id)], + overseasWarehouseId:warehouseId,domesticWarehouseId:null,status:'ACTIVE'}) + report.subject={membershipId:String(created.membershipId),userId:String(created.userId),authUserId:String(created.authUserId), + tenantId:String(created.tenantId),roleIds:(created.roleIds||[]).map(String),overseasWarehouseId:String(created.overseasWarehouseId), + domesticWarehouseId:created.domesticWarehouseId??null,status:created.status,protectedAccount:created.protectedAccount} + writeCredentials(report.subject.membershipId);save() + if(report.subject.tenantId!=='0'||report.subject.overseasWarehouseId!==warehouseId||report.subject.status!=='ACTIVE' + ||report.subject.domesticWarehouseId!==null||report.subject.roleIds.length!==1||report.subject.roleIds[0]!==String(role.id))throw Error('CREATED_SUBJECT_SCOPE_MISMATCH') + pda=await call('POST','/auth/login',{loginId:username,password},'') + if(pda.passwordChangeRequired||pda.mustChangePassword||pda.firstLogin||!pda.accessToken)throw Error('FIRST_PASSWORD_CHANGE_OR_NORMAL_LOGIN_BLOCKED') + if(!(pda.authorities||[]).includes('lastleg:pda:operate')||!(pda.roles||[]).includes(roleCode)||(pda.roles||[]).length!==1)throw Error('NORMAL_LOGIN_ROLE_OR_PERMISSION_MISMATCH') + const profile=await call('GET','/pda/v1/last-leg/receiving-containers/warehouse-profile',undefined,pda.accessToken) + report.profile={warehouseId:String(profile.warehouseId),warehouseCode:profile.warehouseCode,warehouseName:profile.warehouseName} + if(report.profile.warehouseId!==warehouseId||report.profile.warehouseCode!=='US-LAX-01')throw Error('FORMAL_WAREHOUSE_PROFILE_MISMATCH') + report.cleanup={method:'PUT',path:`/platform/v1/iam/users/${report.subject.membershipId}/status`,body:{status:'DISABLED'},executeNow:false} + report.status='ACTIVE_FOR_COORDINATOR_UI';report.loginVerified=true;report.warehouseProfileVerified=true;report.success=true + }catch(error){report.failure=String(error.message).replace(/https?:\/\/\S+/g,'[redacted]')} + finally{ + if(pda?.refreshToken){try{await call('POST','/auth/logout',{refreshToken:pda.refreshToken},pda.accessToken);report.pdaSessionClosed=true}catch{report.pdaSessionClosed=false;report.success=false}}else report.pdaSessionClosed=!pda + if(admin?.refreshToken){try{await call('POST','/auth/logout',{refreshToken:admin.refreshToken},admin.accessToken);report.adminSessionClosed=true}catch{report.adminSessionClosed=false;report.success=false}}else report.adminSessionClosed=!admin + save() + } + return report +} diff --git a/scripts/fba-remaining-nodes-acceptance-transfer.mjs b/scripts/fba-remaining-nodes-acceptance-transfer.mjs new file mode 100644 index 000000000..3970b28da --- /dev/null +++ b/scripts/fba-remaining-nodes-acceptance-transfer.mjs @@ -0,0 +1,328 @@ +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' +import { digest } from './fba-remaining-nodes-acceptance.mjs' +export const TRANSFER = 'WT-WH-20261002-430594' +export const CUSTOMER = '2105875471489142785' +export const BASE = `/platform/v1/last-leg/warehouse-transfers/${TRANSFER}?customerId=${CUSTOMER}` +export function relabelPrerequisite(detail, order) { + if (detail.needRelabel !== true) return { ready: true, scope: 'NOT_REQUIRED' } + const linked = Boolean(detail.relabelNo && (order.relabelApplications || []).some(app => + app.relabelNo === detail.relabelNo && String(app.lastlegOrderId) === String(detail.lastlegOrderId))) + const confirmed = linked && order.relabelStatus === 'COMPLETED' + return { ready: confirmed, scope: 'ORIGINAL_TRANSFER_RELABEL', linked, + relabelNo: detail.relabelNo || null, relabelStatus: order.relabelStatus || null, + bindingCount: detail.relabelBindings?.length || 0, + reason: confirmed ? null : 'Required relabel is not linked/completed for this original transfer; available source quantity does not waive it.' } +} +export function validateDestination(detail, warehouse, pallet, location) { + if (!warehouse || !pallet || !location) throw Error('EXPLICIT_DESTINATION_RECEIVING_RESOURCES_REQUIRED') + if (warehouse.warehouseType !== 'OVERSEAS' || warehouse.status !== 'ACTIVE') throw Error('DESTINATION_MUST_BE_ACTIVE_OVERSEAS_RECEIVING_WAREHOUSE') + if (String(pallet.warehouseId) !== String(warehouse.id) || String(location.warehouseId) !== String(warehouse.id) + || String(pallet.locationId) !== String(location.id)) throw Error('DESTINATION_RESOURCE_OWNERSHIP_MISMATCH') + if (pallet.status !== 'IDLE' || String(pallet.customerId) !== '0' || pallet.fbaWarehouseCode !== detail.destinationWarehouseCode) throw Error('DESTINATION_RESOURCE_UNAVAILABLE_OR_WRONG_FBA') + return true +} +export function validateSource(detail, balance, position) { + if (detail.transferNo !== TRANSFER || detail.status !== 'APPROVED' || detail.packageCount !== 1 || detail.allocations.length) throw Error('ORIGINAL_APPROVED_UNALLOCATED_TICKET_REQUIRED') + if (!detail.sourceOrders.some(s => String(s.inboundOrderId) === String(balance.inboundOrderId))) throw Error('SOURCE_NOT_BOUND_TO_ORIGINAL_TICKET') + if (String(balance.customerId) !== CUSTOMER || String(position.customerId) !== CUSTOMER || String(position.balanceId) !== String(balance.id)) throw Error('CUSTOMER_OR_BALANCE_MISMATCH') + if (String(balance.overseasWarehouseId) !== String(detail.operatingWarehouseId) || !position.palletId || Number(position.availablePackages) < 1) throw Error('SOURCE_UNAVAILABLE') + if (Number(position.holdPackages || 0) || Number(position.frozenPackages || 0) || Number(position.allocatedPackages || 0) || Number(position.pickedPackages || 0)) throw Error('SOURCE_HAS_OTHER_OCCUPANCY') + return { inventoryPositionId: String(position.id), palletId: String(position.palletId), packageCount: 1 } +} +export async function withTransferSession({env, output}, work) { + if (fs.existsSync(output)) throw Error('REPORT_EXISTS_NO_OVERWRITE') + if (String(env.CHONGCHONG_ENV).toUpperCase() !== 'TEST') throw Error('EXPLICIT_TEST_REQUIRED') + const report = { transferNo: TRANSFER, customerId: CUSTOMER, startedAt: new Date().toISOString(), requests: [], success: false } + const save = () => { fs.mkdirSync(path.dirname(output), {recursive:true}); fs.writeFileSync(output, JSON.stringify(report,null,2)) } + let session + async function call(method, route, body) { + const auth = route.startsWith('/auth/') + if (!auth && !route.startsWith('/platform/v1/last-leg/') && !route.startsWith('/platform/v1/overseas-warehouse-resources/') && !route.startsWith('/platform/v1/warehouse-admin')) throw Error('FIRSTLEG_OR_UNSCOPED_ROUTE_FORBIDDEN') + const entry = { method, path: route, phase: 'INTENT', ...(!auth && body ? {body, bodyHash:digest(body)} : {}) } + report.requests.push(entry); save() + let response + try { response = await fetch(env.CHONGCHONG_ADMIN_API_BASE_URL.replace(/\/$/,'')+route, {method,redirect:'error',signal:AbortSignal.timeout(20000),headers:{Accept:'application/json',...(session?{Authorization:`Bearer ${session.accessToken}`} : {}),...(body?{'Content-Type':'application/json'}:{})},...(body?{body:JSON.stringify(body)}:{})}) } + catch { entry.phase='OUTCOME_UNKNOWN'; save(); throw Error('OUTCOME_UNKNOWN_RECONCILE_ONLY_NO_RETRY') } + const json=JSON.parse(await response.text()); Object.assign(entry,{phase:'ACKNOWLEDGED',http:response.status,code:String(json.code),requestId:response.headers.get('x-request-id'),traceId:response.headers.get('x-trace-id')});save() + if(response.status!==200||String(json.code)!=='200')throw Error(`API_FAILURE_${response.status}_${json.code}`) + return json.data + } + async function pages(route) { + const records=[]; let total + for(let page=1;page<=100;page++) {const value=await call('GET',`${route}${route.includes('?')?'&':'?'}page=${page}&size=200`);if(total!==undefined&&total!==Number(value.total))throw Error('PAGINATION_TOTAL_CHANGED');total=Number(value.total);records.push(...value.records);if(records.length>=total){if(records.length!==total)throw Error('PAGINATION_COUNT_INVALID');return records}} + throw Error('PAGINATION_BOUND_EXCEEDED') + } + try {session=await call('POST','/auth/login',{loginId:env.CHONGCHONG_ADMIN_USERNAME,password:env.CHONGCHONG_ADMIN_PASSWORD});await work({call,pages,report,save});report.success=true} + catch(error){report.failure=String(error.message).replace(/https?:\/\/\S+/g,'[redacted]')} + finally { if(session){try{await call('POST','/auth/logout',{refreshToken:session.refreshToken});report.sessionClosed=true}catch{report.sessionClosed=false;report.success=false}}else report.sessionClosed=true;save() } + return report +} +export async function freezeTransfer({env,output}) { + return withTransferSession({env,output},async({call,pages,report,save})=>{ + const detail=await call('GET',BASE) + const balances=await pages(`/platform/v1/last-leg/inventory/balances?customerId=${CUSTOMER}`) + const ledger=await pages(`/platform/v1/last-leg/inventory/ledger?customerId=${CUSTOMER}`) + const sourceCandidates=[] + for(const b of balances.filter(b=>detail.items.some(i=>i.skuCode===b.skuCode)))sourceCandidates.push(await call('GET',`/platform/v1/last-leg/inventory/balances/${b.id}?customerId=${CUSTOMER}`)) + const sources=sourceCandidates.filter(b=>detail.sourceOrders.some(s=>String(s.inboundOrderId)===String(b.inboundOrderId))) + const positions=[] + for(const b of sources)positions.push(...await call('GET',`/platform/v1/last-leg/inventory/balances/${b.id}/positions?customerId=${CUSTOMER}`)) + Object.assign(report,{detail,balances,ledger,sources,positions,snapshotHash:digest({detail,balances,ledger,positions})});save() + report.scopeReady=sources.length>0&&positions.some(p=>Number(p.availablePackages)>=1) + }) +} + +export const LA = '193000000000200003' +export const NY = '193000000000200004' +const PDA_MEMBER = '2106727769572478980' +export const TRANSFER_STAGES = Object.freeze({ + approve: {actor:'platform',permission:'platform:overseas-warehouse:relabel-order:status',replay:'SAME_STATUS_NOOP'}, + claim: {actor:'pda',permission:'lastleg:pda:operate',replay:'KEYED'}, + 'create-work-pallet': {actor:'pda',permission:'lastleg:pda:operate',replay:'KEYED'}, + unit: {actor:'pda',permission:'lastleg:pda:operate',replay:'KEYED'}, + 'unit-carton': {actor:'pda',permission:'lastleg:pda:operate',replay:'KEYED'}, + bind: {actor:'pda',permission:'lastleg:pda:operate',replay:'KEYED'}, + photo: {actor:'pda',permission:'lastleg:pda:operate',replay:'NONE'}, + complete: {actor:'pda',permission:'lastleg:pda:operate',replay:'KEYED'}, + 'review-read': {actor:'platform',permission:'platform:overseas-warehouse:relabel-order:list',replay:'READ_ONLY'}, + 'customer-confirm': {actor:'merchant',permission:'merchant:overseas-warehouse:relabel:confirm',replay:'KEYED'}, + outbound: {actor:'platform',permission:'lastleg:warehouse-transfer:manage',replay:'KEYED'}, + receipt: {actor:'platform',permission:'lastleg:warehouse-transfer:manage',replay:'KEYED'}, +}) +const demand = (value, message) => { if (!value) throw Error(message) } +const version = value => { demand(Number.isInteger(value)&&value>=0,'FORMAL_CURRENT_VERSION_REQUIRED');return value } +export function prepareOriginalTransferPlan(detail, runId) { + demand(/^[A-Za-z0-9_-]{1,48}$/.test(runId),'STABLE_RUN_ID_REQUIRED') + demand(detail?.transferNo===TRANSFER&&detail.needRelabel===true&&Number(detail.packageCount)===1 + &&String(detail.operatingWarehouseId)===LA&&detail.destinationWarehouseCode==='ABE4','ORIGINAL_TICKET_SCOPE_REQUIRED') + return {runId,transfer:structuredClone(detail),customerId:CUSTOMER,networkIssued:false, + linked:Boolean(detail.relabelNo&&detail.relabelApplicationId), + reconcile:{method:'GET',path:BASE}, + phases:Object.entries(TRANSFER_STAGES).map(([stage,rule])=>({stage,...rule})), + reviewBoundary:'PDA /review is intentionally rejected; platform review-task is read-only. Customer confirm-completion atomically completes review.', + receiptWarehouseId:NY,businessDestination:'ABE4',pdaMembershipId:PDA_MEMBER} +} +function ownRelabel(plan, linked) { + demand(plan.linked,'WAIT_FOR_ORIGINAL_ALLOCATION_NO_ASSUMED_RELABEL') + const app=linked?.application + demand(app&&app.relabelNo===plan.transfer.relabelNo&&String(app.id)===String(plan.transfer.relabelApplicationId) + &&String(app.lastlegOrderId)===String(plan.transfer.lastlegOrderId)&&app.customerOrderNo===TRANSFER + &&String(app.overseasWarehouseId)===LA,'EXACT_ORIGINAL_RELABEL_RELATION_REQUIRED') + return encodeURIComponent(app.relabelNo) +} +function commandHash(command) { + const {preparedHash,...value}=command + return digest(value) +} +export function prepareTransferStage(plan, stage, context={}) { + const rule=TRANSFER_STAGES[stage];demand(rule,'UNSUPPORTED_STAGE_NO_PDA_REVIEW_APPROVE') + const no=ownRelabel(plan,context.linked) + const key=`F33-WT-430594-${plan.runId}-${stage}` + const command={stage,runId:plan.runId,transferNo:TRANSFER,customerId:CUSTOMER,...rule,method:'POST',path:'',body:null} + const pdaBase=`/pda/v1/last-leg/relabel-tasks/${no}` + const platformBase=`/platform/v1/last-leg/relabel-applications/${no}` + const task=context.task + if(['claim','unit','unit-carton','complete'].includes(stage))demand(task?.relabelNo===plan.transfer.relabelNo&&task.customerOrderNo===TRANSFER,'OWN_CURRENT_PDA_TASK_REQUIRED') + if(stage==='approve'){command.method='PUT';command.path=platformBase+'/status';command.body={status:'PENDING_PROCESSING',remark:key}} + if(stage==='claim'){command.path=pdaBase+'/claim';command.body={idempotencyKey:key}} + if(stage==='create-work-pallet'){command.path='/pda/v1/last-leg/pallet-operations/relabel-pallet-label';command.body={relabelNo:plan.transfer.relabelNo,idempotencyKey:key}} + if(stage==='bind'){ + demand(task?.relabelNo===plan.transfer.relabelNo&&task.customerOrderNo===TRANSFER + &&task.taskStatus==='PROCESSING'&&Number(task.expectedPackages)===1&&Number(task.relabelledPackages)===1 + &&Number(task.skippedPackages)===0&&Number(task.exceptionPackages)===0,'COMPLETE_UNIT_FACTS_BEFORE_BIND_REQUIRED') + const selectedItems=task.items?.filter(i=>context.observedCartonMarks?.includes(i.cartonMark))||[] + demand(selectedItems.length===1&&selectedItems.every(i=>{ + if(!Number.isInteger(i.expectedPackages)||i.expectedPackages!==1)return false + const facts=(task.unitFacts||[]).filter(f=>String(f.itemId)===String(i.itemId)&&f.result==='RELABELLED') + return facts.length===1&&Number.isInteger(facts[0].quantity)&&facts[0].quantity===1 + }), 'OWN_RELABELLED_UNIT_FACT_BEFORE_BIND_REQUIRED') + const {source,target,observedCartonMarks}=context + demand(source&&target&&String(source.warehouseId)===LA&&String(target.warehouseId)===LA + &&target.destinationWarehouseCode==='ABE4'&&source.palletCode!==target.palletCode,'FORMAL_LA_WORK_PALLETS_REQUIRED') + const marks=new Set((context.linked.items||[]).map(i=>i.cartonMark)) + demand(Array.isArray(observedCartonMarks)&&observedCartonMarks.length===1&&observedCartonMarks.every(m=>marks.has(m)),'OBSERVED_OWN_CARTON_REQUIRED') + demand(plan.transfer.allocations?.some(a=>String(a.inventoryPositionId)===String(source.positionId) + &&String(a.palletId)===String(source.palletId)&&Number(a.packageCount)===1),'ORIGINAL_SELECTED_POSITION_REQUIRED') + command.path='/pda/v1/last-leg/pallet-operations/relabel-disassemble' + command.body={relabelNo:plan.transfer.relabelNo,idempotencyKey:key, + source:{palletCode:source.palletCode,expectedVersion:version(source.palletVersion)}, + target:{palletCode:target.palletCode,expectedVersion:version(target.palletVersion)},cartonMarks:observedCartonMarks, + actualSource:{positionId:source.positionId,expectedPositionVersion:version(source.positionVersion),packages:1},reason:key} + } + if(stage==='unit'){ + const item=task.items?.find(i=>String(i.itemId)===String(context.itemId)) + demand(item&&item.operationType==='CARTON_AND_SKU','OWN_COMBINED_ITEM_REQUIRED') + demand(context.observedOldLabel===item.cartonMark&&context.observedNewLabel===item.fbaCartonMark,'OBSERVED_LABELS_MUST_MATCH_OWN_ITEM') + demand(Array.isArray(context.observedSkus)&&digest(context.observedSkus)===digest((item.skuItems||[]).map(s=>({skuCode:s.skuCode,newSkuCode:s.newSkuCode,quantity:s.quantity}))), 'OBSERVED_SKU_QUANTITIES_REQUIRED') + command.path=pdaBase+'/units';command.body={itemId:item.itemId,oldLabel:context.observedOldLabel,newLabel:context.observedNewLabel, + expectedVersion:version(task.version),idempotencyKey:key,skuVerifications:context.observedSkus} + } + if(stage==='unit-carton'){ + const item=task.items?.find(i=>String(i.itemId)===String(context.itemId)) + demand(task.taskStatus==='PROCESSING'&&task.relabelledPackages===0&&item?.operationType==='CARTON_AND_SKU' + &&item.expectedPackages===1&&item.skuProcessedPackages===1&&item.cartonProcessedPackages===0 + &&!(task.unitFacts||[]).some(f=>String(f.itemId)===String(item.itemId)), 'OWN_SKU_ONLY_PARTIAL_UNIT_REQUIRED') + demand(context.observedOldLabel===item.cartonMark&&context.observedNewLabel===item.fbaCartonMark,'OBSERVED_LABELS_MUST_MATCH_OWN_ITEM') + command.path=pdaBase+'/cartons/scan';command.body={cartonMark:context.observedOldLabel,expectedVersion:version(task.version),idempotencyKey:key} + } + if(stage==='photo'){ + const photo=context.photo + demand(photo&&/^[a-f0-9]{64}$/.test(photo.sha256)&&Number.isSafeInteger(photo.sizeBytes)&&photo.sizeBytes>0 + &&photo.contentType?.startsWith('image/')&&photo.fileName,'EXPLICIT_PHOTO_CONTENT_REQUIRED') + command.path=pdaBase+'/photos/files';command.body=null + command.multipart={field:'file',fileName:photo.fileName,contentType:photo.contentType,sizeBytes:photo.sizeBytes,sha256:photo.sha256} + } + if(stage==='complete'){ + demand(task.taskStatus==='PROCESSING'&&Number(task.expectedPackages)===1&&Number(task.relabelledPackages)===1 + &&Number(task.skippedPackages)===0&&Number(task.exceptionPackages)===0,'FULL_NORMAL_WORK_FACTS_REQUIRED') + const ownMarks=new Set(task.items.map(i=>i.cartonMark)) + const bound=(task.history||[]).some(h=>{ + if(h.action!=='PALLET_BIND')return false + try{const proof=JSON.parse(h.detail);return proof.round===(task.reviewedAt||'INITIAL') + &&proof.cartonMarks?.some(m=>ownMarks.has(m))&&proof.targetPallets?.length>0}catch{return false} + }) + demand(bound&&Array.isArray(context.photoFileIds)&&context.photoFileIds.length>0 + &&context.photoFileIds.every(id=>/^[1-9]\d*$/.test(String(id))) + &&new Set(context.photoFileIds.map(String)).size===context.photoFileIds.length,'ACTUAL_BINDING_AND_OWN_PHOTOS_REQUIRED') + command.path=pdaBase+'/complete';command.body={photoFileIds:context.photoFileIds,expectedVersion:version(task.version),idempotencyKey:key,remark:key,manualCompletion:false} + } + if(stage==='review-read'){command.method='GET';command.path=platformBase+'/review-task';command.body=null} + if(stage==='customer-confirm'){ + demand(task?.taskStatus==='WAIT_REVIEW'&&task.relabelNo===plan.transfer.relabelNo&&Number(task.relabelledPackages)===1 + &&task.completionPhotoFileIds?.length>0,'SUBMITTED_WORK_AND_PHOTOS_REQUIRED') + command.path=`/merchant/v1/last-leg/relabel-applications/${no}/confirm-completion`;command.body={idempotencyKey:key,remark:key} + } + if(stage==='outbound'){ + demand(plan.transfer.status==='ALLOCATED'&&relabelPrerequisite(plan.transfer,context.order).ready,'OWN_RELABEL_CUSTOMER_CONFIRMATION_REQUIRED') + demand(plan.transfer.allocations?.length===1&&plan.transfer.allocations[0].status==='RESERVED' + &&plan.transfer.allocations[0].skuCode===plan.transfer.relabelBindings[0].newSku,'CURRENT_NEW_SKU_ALLOCATION_REQUIRED') + command.path=`/platform/v1/last-leg/warehouse-transfers/${TRANSFER}/outbound?customerId=${CUSTOMER}`;command.body={idempotencyKey:key,remark:key} + } + if(stage==='receipt'){ + const a=plan.transfer.allocations?.[0],t=context.receiptTarget + demand(plan.transfer.status==='OUTBOUNDED'&&plan.transfer.allocations.length===1&&a.status==='SHIPPED' + &&Number(a.packageCount)===1&&a.skuCode===plan.transfer.relabelBindings[0].newSku,'CURRENT_SHIPPED_NEW_SKU_ALLOCATION_REQUIRED') + demand(t&&String(t.warehouse?.id)===NY,'EXPLICIT_NEW_YORK_RECEIVING_TARGET_REQUIRED') + validateDestination(plan.transfer,t.warehouse,t.pallet,t.location) + command.path=`/platform/v1/last-leg/warehouse-transfers/${TRANSFER}/receipts?customerId=${CUSTOMER}` + command.body={sourceInventoryPositionId:a.inventoryPositionId,destinationOverseasWarehouseId:NY, + destinationOverseasWarehouseCode:t.warehouse.warehouseCode,destinationOverseasWarehouseName:t.warehouse.warehouseName, + destinationWarehouseId:'193000000000000003',destinationWarehouseCode:'ABE4',destinationWarehouseName:'ABE4', + destinationPalletId:t.pallet.id,destinationPalletNo:t.pallet.palletCode,warehouseLocationId:t.location.id, + warehouseLocationCode:t.location.locationCode,batchNo:TRANSFER,idempotencyKey:key,remark:key} + } + command.preparedHash=commandHash(command) + return command +} +export function redactTransferMessage(message) { + if(typeof message!=='string')return undefined + return message.replace(/https?:\/\/\S+/gi,'[url redacted]') + .replace(/Bearer\s+\S+/gi,'Bearer [redacted]') + .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g,'[token redacted]') + .replace(/\b(password|accessToken|refreshToken|username|loginId)\s*[:=]\s*[^\s,;]+/gi,'$1=[redacted]').slice(0,1000) +} +export async function runPreparedTransferStage({command,release,actor,transport,journalDirectory,binary,replay=false,recovery}) { + if(!release?.execute)return {mode:'PLAN',networkIssued:false,command} + demand(release.environment==='TEST'&&release.transferNo===TRANSFER&&release.customerId===CUSTOMER + &&release.stage===command.stage&&release.runId===command.runId,'EXACT_STAGE_RELEASE_REQUIRED') + demand(command.preparedHash===commandHash(command),'PREPARED_COMMAND_CHANGED') + const policy=TRANSFER_STAGES[command.stage];demand(policy,'UNSUPPORTED_STAGE') + demand(command.transferNo===TRANSFER&&command.customerId===CUSTOMER + &&/^[A-Za-z0-9_-]{1,48}$/.test(command.runId),'ORIGINAL_COMMAND_SCOPE_REQUIRED') + const commandFields=new Set(['stage','runId','transferNo','customerId','actor','permission','replay','method','path','body','multipart','preparedHash']) + demand(Object.keys(command).every(field=>commandFields.has(field)),'NO_CREDENTIALS_OR_EXTRA_TRANSPORT_FIELDS_IN_JOURNAL') + demand(actor?.kind===policy.actor&&Array.isArray(actor.authorities)&&actor.authorities.includes(policy.permission),'ACTOR_PERMISSION_REQUIRED') + if(policy.actor==='pda')demand(Array.isArray(actor.roles)&&actor.roles.includes('OVERSEAS_WAREHOUSE_PDA_OPERATOR') + &&actor.appCode==='PLATFORM'&&String(actor.tenantId)==='0' + &&String(actor.membershipId)===PDA_MEMBER&&String(actor.warehouseId)===LA,'OWN_PDA_IDENTITY_REQUIRED') + else demand(actor.appCode===(policy.actor==='merchant'?'MERCHANT':'PLATFORM') + &&String(actor.tenantId)===(policy.actor==='merchant'?CUSTOMER:'0'),'ACTOR_SCOPE_REQUIRED') + demand(typeof transport==='function'&&journalDirectory,'EXPLICIT_TRANSPORT_AND_JOURNAL_REQUIRED') + if(command.multipart)demand(binary&&binary.length===command.multipart.sizeBytes + &&crypto.createHash('sha256').update(binary).digest('hex')===command.multipart.sha256,'PHOTO_BYTES_CHANGED') + fs.mkdirSync(journalDirectory,{recursive:true}) + const originalFile=path.join(journalDirectory,`${command.runId}-${command.stage}.json`) + let file=originalFile + if(recovery){ + demand(recovery.authorization==='F33_BIND_A0409_AFTER_UNIT'&&command.stage==='bind' + &&command.runId==='f33-original-20261005-r1' + &&command.preparedHash==='bb1ccb10ebaef5f37d8f16782ae6f8f2051d31061142533a61e8ab020c1e2a0d', 'ONLY_RELEASED_ORIGINAL_F33_BIND_RECOVERY_ALLOWED') + const readProof=(name,sha)=>{ + demand(typeof name==='string'&&path.basename(name)===name&&/^[a-f0-9]{64}$/.test(sha),'EXACT_LOCAL_RECOVERY_PROOF_REQUIRED') + const bytes=fs.readFileSync(path.join(journalDirectory,name)) + demand(crypto.createHash('sha256').update(bytes).digest('hex')===sha,'RECOVERY_PROOF_CHANGED') + return JSON.parse(bytes) + } + demand(fs.existsSync(originalFile),'ORIGINAL_REJECTED_BIND_JOURNAL_REQUIRED') + const original=readProof(path.basename(originalFile),recovery.originalJournalSha256) + demand(original.phase==='ACKNOWLEDGED'&&original.http===200&&original.code==='A0409' + &&original.success===false&&!original.replay&&original.command.preparedHash===command.preparedHash + &&commandHash(original.command)===command.preparedHash,'ONLY_KNOWN_ACKNOWLEDGED_A0409_BIND_CAN_RECOVER') + const rollback=readProof(recovery.rollbackProofFile,recovery.rollbackProofSha256) + demand(rollback.verified===true&&['transfer','positions','ledger','linked','order','task'] + .every(k=>rollback.unchanged?.includes(k)),'COMPLETE_REJECTION_ROLLBACK_PROOF_REQUIRED') + const fresh=readProof(recovery.freshProofFile,recovery.freshProofSha256) + demand(fresh.runId===command.runId&&fresh.transferNo===TRANSFER&&fresh.relabelNo===command.body.relabelNo + &&fresh.originalKeyNotExecuted===true&&fresh.unitFactsVerified===true + &&fresh.unitFactCount===1&&fresh.relabelledPackages===1&&fresh.stockAndLedgerUnchangedByUnit===true + &&fresh.sourcePalletCode===command.body.source.palletCode&&fresh.targetPalletCode===command.body.target.palletCode + &&fresh.sourcePalletVersion===command.body.source.expectedVersion&&fresh.targetPalletVersion===command.body.target.expectedVersion + &&String(fresh.sourcePositionId)===String(command.body.actualSource.positionId) + &&fresh.sourcePositionVersion===command.body.actualSource.expectedPositionVersion,'FRESH_UNIT_AND_UNCHANGED_ORIGINAL_VERSIONS_REQUIRED') + file=path.join(journalDirectory,`${command.runId}-bind-recovery.json`) + } + let journal + if(fs.existsSync(file)){ + journal=JSON.parse(fs.readFileSync(file,'utf8')) + demand(replay&&['KEYED','SAME_STATUS_NOOP','READ_ONLY'].includes(policy.replay) + &&journal.success===true&&journal.command.preparedHash===command.preparedHash + &&!journal.replay,'ONLY_ACKNOWLEDGED_EXACT_ORIGINAL_REPLAY_ALLOWED') + journal.replay={phase:'INTENT'};fs.writeFileSync(file,JSON.stringify(journal,null,2)) + }else{ + demand(!replay,'ORIGINAL_ACKNOWLEDGED_COMMAND_REQUIRED') + journal={command,actor:{kind:actor.kind,membershipId:actor.membershipId,warehouseId:actor.warehouseId},phase:'INTENT',success:false,businessVerified:false, + ...(recovery?{recovery:{authorization:recovery.authorization,originalJournalFile:path.basename(originalFile),originalJournalSha256:recovery.originalJournalSha256, + rollbackProofFile:recovery.rollbackProofFile,rollbackProofSha256:recovery.rollbackProofSha256,freshProofFile:recovery.freshProofFile,freshProofSha256:recovery.freshProofSha256}}:{})} + fs.writeFileSync(file,JSON.stringify(journal,null,2),{flag:'wx'}) + } + const entry=replay?journal.replay:journal + try{ + const response=await transport(command,binary) + Object.assign(entry,{phase:'ACKNOWLEDGED',http:response.http,code:String(response.code),message:redactTransferMessage(response.message),requestId:response.requestId,traceId:response.traceId,resultDigest:digest(response.data??null)}) + entry.success=response.http===200&&String(response.code)==='200' + fs.writeFileSync(file,JSON.stringify(journal,null,2)) + demand(entry.success,'STAGE_REJECTED_STOP_NO_RECREATE') + return {mode:'EXECUTED',journalFile:file,response} + }catch(error){ + if(entry.phase==='INTENT'){entry.phase='OUTCOME_UNKNOWN';entry.success=false;fs.writeFileSync(file,JSON.stringify(journal,null,2))} + throw Error(entry.phase==='OUTCOME_UNKNOWN'?'OUTCOME_UNKNOWN_READBACK_ONLY_NO_RETRY':'STAGE_REJECTED_STOP_NO_RECREATE') + } +} + +export function verifyTransferMilestone(stage, facts) { + demand(['selection','bind','unit','customer-confirm','outbound','receipt'].includes(stage),'UNKNOWN_STOCK_MILESTONE') + const count=value=>{demand((typeof value==='number'||typeof value==='string'&&/^\d+$/.test(value)) + &&Number.isSafeInteger(Number(value))&&Number(value)>=0,'EXPLICIT_INTEGER_STOCK_FACT_REQUIRED');return Number(value)} + const beforeSource=count(facts.baselineSourceTotal),beforeTarget=count(facts.baselineTargetTotal) + const source=count(facts.sourceTotal),target=count(facts.targetTotal) + const departed=['outbound','receipt'].includes(stage),received=stage==='receipt' + demand(source===beforeSource-(departed?1:0)&&target===beforeTarget+(received?1:0),'ORIGINAL_ONE_CARTON_STOCK_DELTA_REQUIRED') + demand(facts.protectedBefore&&facts.protectedAfter&&digest(facts.protectedBefore)===digest(facts.protectedAfter),'OTHER_HOLD_AND_STOCK_MUST_REMAIN_IDENTICAL') + const past=new Map((facts.ledgerAfter||[]).map(row=>[String(row.id),row])) + demand(Array.isArray(facts.ledgerBefore)&&facts.ledgerBefore.every(row=>past.has(String(row.id))&&digest(past.get(String(row.id)))===digest(row)), 'PRIOR_LEDGER_FACTS_MUST_NOT_CHANGE') + demand(past.size===(facts.ledgerAfter||[]).length,'DUPLICATE_LEDGER_IDS_NOT_COMPLETE_EVIDENCE') + if(departed){ + const ship=facts.ledgerAfter.filter(row=>row.businessType==='SHIP'&&row.businessOrderNo===TRANSFER) + demand(facts.expectedNewSku&&ship.length===1&&Number(ship[0].deltaPackages)===-1 + &&ship[0].skuCode===facts.expectedNewSku,'UNIQUE_ORIGINAL_NEW_SKU_SHIPMENT_REQUIRED') + } + if(received){ + const receipts=facts.ledgerAfter.filter(row=>row.businessType==='STOCK_IN'&&row.businessOrderNo===TRANSFER + &&String(row.overseasWarehouseId)===NY) + demand(receipts.length===1&&Number(receipts[0].deltaPackages)===1 + &&receipts[0].skuCode===facts.expectedNewSku,'UNIQUE_NEW_YORK_NEW_SKU_RECEIPT_REQUIRED') + } + return {verified:true,stage,sourceTotal:source,targetTotal:target,inTransitCartons:departed&&!received?1:0} +} diff --git a/scripts/fba-remaining-nodes-acceptance-transfer.test.mjs b/scripts/fba-remaining-nodes-acceptance-transfer.test.mjs new file mode 100644 index 000000000..abc5b0004 --- /dev/null +++ b/scripts/fba-remaining-nodes-acceptance-transfer.test.mjs @@ -0,0 +1,235 @@ +import {test} from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import os from 'node:os' +import crypto from 'node:crypto' +import {validateSource,validateDestination,relabelPrerequisite,TRANSFER,CUSTOMER} from './fba-remaining-nodes-acceptance-transfer.mjs' +import {LA,NY,prepareOriginalTransferPlan,prepareTransferStage,runPreparedTransferStage,verifyTransferMilestone,redactTransferMessage} from './fba-remaining-nodes-acceptance-transfer.mjs' +const detail={transferNo:TRANSFER,status:'APPROVED',packageCount:1,allocations:[],operatingWarehouseId:'w',sourceOrders:[{inboundOrderId:'o'}]} +const balance={id:'b',customerId:CUSTOMER,inboundOrderId:'o',overseasWarehouseId:'w'} +const position={id:'p',balanceId:'b',customerId:CUSTOMER,palletId:'t',availablePackages:1,frozenPackages:0,pickedPackages:0} +test('original approved one-carton source passes exact ownership',()=>assert.deepEqual(validateSource(detail,balance,position),{inventoryPositionId:'p',palletId:'t',packageCount:1})) +test('other source, customer, warehouse and unavailable or occupied cartons stop before commands',()=>{ + for(const b of [{...balance,inboundOrderId:'other'},{...balance,customerId:'other'},{...balance,overseasWarehouseId:'other'}])assert.throws(()=>validateSource(detail,b,position)) + for(const p of [{...position,availablePackages:0},{...position,frozenPackages:1},{...position,holdPackages:1},{...position,allocatedPackages:1},{...position,pickedPackages:1},{...position,palletId:null}])assert.throws(()=>validateSource(detail,balance,p)) + assert.throws(()=>validateSource({...detail,allocations:[{}]},balance,position)) +}) +test('missing destination or FBA master cannot silently become a receiving overseas resource',()=>{ + assert.throws(()=>validateDestination(detail)) + assert.throws(()=>validateDestination(detail,{warehouseType:'FBA',status:'ACTIVE'},{},{})) + const d={destinationWarehouseCode:'ABE4'},w={id:'ny',warehouseType:'OVERSEAS',status:'ACTIVE'} + const p={warehouseId:'ny',locationId:'loc',status:'IDLE',customerId:'0',fbaWarehouseCode:'ABE4'} + const l={id:'loc',warehouseId:'ny'} + assert.equal(validateDestination(d,w,p,l),true) + assert.throws(()=>validateDestination(d,w,{...p,warehouseId:'la'},l)) + assert.throws(()=>validateDestination(d,w,{...p,status:'OCCUPIED'},l)) +}) +test('available original-source cartons cannot waive original ticket relabel binding',()=>{ + const d={needRelabel:true,lastlegOrderId:'13',relabelBindings:[{quantity:1}]} + assert.equal(relabelPrerequisite(d,{relabelStatus:'PENDING_BINDING'}).ready,false) + assert.equal(relabelPrerequisite({...d,relabelNo:'original'},{relabelApplications:[{relabelNo:'other',lastlegOrderId:'13'}],relabelStatus:'COMPLETED'}).ready,false) + assert.equal(relabelPrerequisite({...d,relabelNo:'original'},{relabelApplications:[{relabelNo:'original',lastlegOrderId:'13'}],relabelStatus:'PENDING_PROCESSING'}).ready,false) + assert.equal(relabelPrerequisite({...d,relabelNo:'original'},{relabelApplications:[{relabelNo:'original',lastlegOrderId:'other'}],relabelStatus:'COMPLETED'}).ready,false) + assert.equal(relabelPrerequisite({...d,relabelNo:'original'},{relabelApplications:[{relabelNo:'original',lastlegOrderId:'13'}],relabelStatus:'COMPLETED'}).ready,true) +}) + +function linkedFixture(status='WAIT_RELABEL'){ + const transfer={transferNo:TRANSFER,needRelabel:true,packageCount:1,operatingWarehouseId:LA,destinationWarehouseCode:'ABE4', + relabelNo:'RL-SYNTHETIC-ONLY',relabelApplicationId:'app',lastlegOrderId:'tail',status, + relabelBindings:[{newSku:'NEW'}],allocations:[{inventoryPositionId:'selected-old',palletId:'source-pallet',packageCount:1,status:'RESERVED',skuCode:'OLD'}]} + const linked={application:{id:'app',relabelNo:transfer.relabelNo,lastlegOrderId:'tail',customerOrderNo:TRANSFER,overseasWarehouseId:LA},items:[{cartonMark:'BOX-1'}]} + const task={relabelNo:transfer.relabelNo,customerOrderNo:TRANSFER,taskStatus:'PROCESSING',version:2, + expectedPackages:1,relabelledPackages:1,skippedPackages:0,exceptionPackages:0, + items:[{itemId:'item',expectedPackages:1,cartonMark:'BOX-1',fbaCartonMark:'FBA-BOX-1',operationType:'CARTON_AND_SKU',skuItems:[{skuCode:'OLD',newSkuCode:'NEW',quantity:1}]}], + unitFacts:[{factId:'fact',itemId:'item',result:'RELABELLED',quantity:1}], + history:[{action:'PALLET_BIND',detail:JSON.stringify({round:'INITIAL',cartonMarks:['BOX-1'],targetPallets:['WORK-1']})}]} + return {transfer,linked,task,plan:prepareOriginalTransferPlan(transfer,'offline-contract')} +} +const platform={kind:'platform',appCode:'PLATFORM',tenantId:'0',authorities:['platform:overseas-warehouse:relabel-order:status']} +const release=stage=>({execute:true,environment:'TEST',transferNo:TRANSFER,customerId:CUSTOMER,stage,runId:'offline-contract'}) +function temporaryJournal(){const directory=fs.mkdtempSync(path.join(os.tmpdir(),'f33-stage-'));return {directory,cleanup(){for(const file of fs.readdirSync(directory))fs.unlinkSync(path.join(directory,file));fs.rmdirSync(directory)}}} +test('unallocated original ticket remains a zero-network plan without inventing a relabel number',async()=>{ + const f=linkedFixture();delete f.transfer.relabelNo;delete f.transfer.relabelApplicationId + const plan=prepareOriginalTransferPlan(f.transfer,'unallocated') + assert.equal(plan.linked,false);assert.equal(plan.networkIssued,false) + assert.throws(()=>prepareTransferStage(plan,'approve',{linked:f.linked}),/WAIT_FOR_ORIGINAL_ALLOCATION/) + assert.throws(()=>prepareTransferStage(f.plan,'review-approve',{linked:f.linked}),/UNSUPPORTED_STAGE/) + const command=prepareTransferStage(f.plan,'approve',{linked:f.linked});let calls=0 + assert.equal((await runPreparedTransferStage({command,transport:async()=>{calls++}})).mode,'PLAN');assert.equal(calls,0) +}) +test('different relabel parent IDs or customer-order linkage cannot enter the original pipeline',()=>{ + const f=linkedFixture() + for(const patch of [{id:'other'},{lastlegOrderId:'other'},{customerOrderNo:'OTHER-TICKET'},{overseasWarehouseId:NY}]){ + assert.throws(()=>prepareTransferStage(f.plan,'approve',{linked:{...f.linked,application:{...f.linked.application,...patch}}}),/EXACT_ORIGINAL_RELABEL/) + } +}) +test('actual labels and approved SKU piece counts are required for one combined unit fact',()=>{ + const f=linkedFixture(),context={linked:f.linked,task:f.task,itemId:'item',observedOldLabel:'BOX-1',observedNewLabel:'FBA-BOX-1',observedSkus:[{skuCode:'OLD',newSkuCode:'NEW',quantity:1}]} + const command=prepareTransferStage(f.plan,'unit',context);assert.equal(command.body.expectedVersion,2);assert.match(command.path,/\/units$/) + assert.throws(()=>prepareTransferStage(f.plan,'unit',{...context,observedOldLabel:'OTHER'})) + assert.throws(()=>prepareTransferStage(f.plan,'unit',{...context,observedSkus:[{skuCode:'OLD',newSkuCode:'NEW',quantity:2}]})) +}) +test('normal completion requires current-round physical binding photos and no skipped cartons',()=>{ + const f=linkedFixture(),context={linked:f.linked,task:f.task,photoFileIds:['77']} + assert.equal(prepareTransferStage(f.plan,'complete',context).body.manualCompletion,false) + assert.throws(()=>prepareTransferStage(f.plan,'complete',{...context,task:{...f.task,reviewedAt:'NEW-ROUND'}})) + assert.throws(()=>prepareTransferStage(f.plan,'complete',{...context,task:{...f.task,skippedPackages:1}})) + assert.throws(()=>prepareTransferStage(f.plan,'complete',{...context,photoFileIds:[]})) +}) +test('receipt consumes the current shipped output position and keeps New York separate from ABE4',()=>{ + const f=linkedFixture('OUTBOUNDED');f.transfer.allocations=[{inventoryPositionId:'new-output',palletId:'work-pallet',packageCount:1,status:'SHIPPED',skuCode:'NEW'}] + const plan=prepareOriginalTransferPlan(f.transfer,'receipt') + const receiptTarget={warehouse:{id:NY,warehouseType:'OVERSEAS',status:'ACTIVE',warehouseCode:'US-NYC-01',warehouseName:'纽约仓'}, + pallet:{id:'ny-pallet',warehouseId:NY,locationId:'ny-location',palletCode:'F33-NY',status:'IDLE',customerId:'0',fbaWarehouseCode:'ABE4'},location:{id:'ny-location',warehouseId:NY,locationCode:'F33-NY-LOC'}} + const command=prepareTransferStage(plan,'receipt',{linked:f.linked,receiptTarget}) + assert.equal(command.body.sourceInventoryPositionId,'new-output');assert.equal(command.body.destinationOverseasWarehouseId,NY) + assert.equal(command.body.destinationWarehouseCode,'ABE4');assert.equal(command.body.destinationWarehouseId,'193000000000000003') + assert.throws(()=>prepareTransferStage(plan,'receipt',{linked:f.linked,receiptTarget:{...receiptTarget,warehouse:{...receiptTarget.warehouse,id:LA}}})) +}) +test('write intent is durable before a single transport call; only known exact original replay is allowed',async()=>{ + const f=linkedFixture(),command=prepareTransferStage(f.plan,'approve',{linked:f.linked}),temp=temporaryJournal();let count=0 + try{ + const transport=async c=>{count++;const j=JSON.parse(fs.readFileSync(path.join(temp.directory,`${c.runId}-${c.stage}.json`)));assert.equal(count===1?j.phase:j.replay.phase,'INTENT');return {http:200,code:'200',data:{approved:true}}} + const options={command,release:release('approve'),actor:platform,transport,journalDirectory:temp.directory} + await runPreparedTransferStage(options);await assert.rejects(runPreparedTransferStage(options));await runPreparedTransferStage({...options,replay:true}) + assert.equal(count,2);const journal=JSON.parse(fs.readFileSync(path.join(temp.directory,`${command.runId}-${command.stage}.json`)));assert.equal(journal.businessVerified,false) + await assert.rejects(runPreparedTransferStage({...options,replay:true}));assert.equal(count,2) + }finally{temp.cleanup()} +}) +test('unknown result or explicit rejection preserves evidence and blocks retry even with the original key',async()=>{ + for(const unknown of [true,false]){ + const f=linkedFixture(),command=prepareTransferStage(f.plan,'approve',{linked:f.linked}),temp=temporaryJournal();let calls=0 + try{ + const transport=async()=>{calls++;if(unknown)throw Error('secret diagnostics must not be recorded');return {http:409,code:'A0409',requestId:'req',traceId:'trace'}} + const options={command,release:release('approve'),actor:platform,transport,journalDirectory:temp.directory} + await assert.rejects(runPreparedTransferStage(options));await assert.rejects(runPreparedTransferStage({...options,replay:true}));assert.equal(calls,1) + const raw=fs.readFileSync(path.join(temp.directory,`${command.runId}-${command.stage}.json`),'utf8');assert.doesNotMatch(raw,/secret diagnostics/) + const j=JSON.parse(raw);assert.equal(j.phase,unknown?'OUTCOME_UNKNOWN':'ACKNOWLEDGED');if(!unknown)assert.equal(j.requestId,'req') + }finally{temp.cleanup()} + } +}) +test('stage release actor or prepared-payload mismatch cannot send a request',async()=>{ + const f=linkedFixture(),command=prepareTransferStage(f.plan,'approve',{linked:f.linked});let calls=0 + const options={command,release:release('approve'),actor:platform,journalDirectory:'not-created',transport:async()=>{calls++}} + await assert.rejects(runPreparedTransferStage({...options,release:release('receipt')})) + await assert.rejects(runPreparedTransferStage({...options,actor:{...platform,tenantId:CUSTOMER}})) + await assert.rejects(runPreparedTransferStage({...options,command:{...command,body:{status:'REJECTED'}}})) + assert.equal(calls,0) +}) +test('photo upload bytes are frozen and an acknowledged no-key upload cannot be repeated',async()=>{ + const f=linkedFixture(),binary=Buffer.from('synthetic offline transport fixture') + const command=prepareTransferStage(f.plan,'photo',{linked:f.linked,photo:{fileName:'fixture.png',contentType:'image/png',sizeBytes:binary.length,sha256:crypto.createHash('sha256').update(binary).digest('hex')}}) + const actor={kind:'pda',appCode:'PLATFORM',tenantId:'0',membershipId:'2106727769572478980',warehouseId:LA,roles:['OVERSEAS_WAREHOUSE_PDA_OPERATOR'],authorities:['lastleg:pda:operate']} + const temp=temporaryJournal();let calls=0 + try{ + const options={command,release:release('photo'),actor,binary,journalDirectory:temp.directory,transport:async()=>{calls++;return {http:200,code:'200',data:{fileId:'77'}}}} + await assert.rejects(runPreparedTransferStage({...options,binary:Buffer.from('changed')}));assert.equal(calls,0) + await runPreparedTransferStage(options);await assert.rejects(runPreparedTransferStage({...options,replay:true}));assert.equal(calls,1) + }finally{temp.cleanup()} +}) +test('physical binding uses selected source and current versions without client customer/warehouse scope in PDA body',()=>{ + const f=linkedFixture(),context={linked:f.linked,task:f.task,source:{warehouseId:LA,positionId:'selected-old',palletId:'source-pallet',palletCode:'SOURCE',palletVersion:3,positionVersion:4},target:{warehouseId:LA,palletCode:'WORK',palletVersion:0,destinationWarehouseCode:'ABE4'},observedCartonMarks:['BOX-1']} + const c=prepareTransferStage(f.plan,'bind',context) + assert.equal(c.body.actualSource.positionId,'selected-old');assert.equal(c.body.actualSource.packages,1);assert.equal(c.body.target.expectedVersion,0) + assert.equal(c.body.customerId,undefined);assert.equal(c.body.warehouseId,undefined) + assert.throws(()=>prepareTransferStage(f.plan,'bind',{...context,target:{...context.target,warehouseId:NY}})) +}) +test('one original carton stays conserved and existing HOLD and append-only facts remain untouched',()=>{ + const unchanged={positionId:'protected',availablePackages:0,holdPackages:1} + const ship={id:'ship',businessType:'SHIP',businessOrderNo:TRANSFER,deltaPackages:-1,skuCode:'NEW'} + const receipt={id:'receipt',businessType:'STOCK_IN',businessOrderNo:TRANSFER,overseasWarehouseId:NY,deltaPackages:1,skuCode:'NEW'} + const base={baselineSourceTotal:4,baselineTargetTotal:0,expectedNewSku:'NEW',protectedBefore:unchanged,protectedAfter:unchanged,ledgerBefore:[{id:'old',packages:1}],ledgerAfter:[ship,receipt,{id:'old',packages:1}]} + assert.equal(verifyTransferMilestone('customer-confirm',{...base,sourceTotal:4,targetTotal:0}).verified,true) + assert.equal(verifyTransferMilestone('outbound',{...base,sourceTotal:3,targetTotal:0}).inTransitCartons,1) + assert.equal(verifyTransferMilestone('receipt',{...base,sourceTotal:3,targetTotal:1}).inTransitCartons,0) + assert.throws(()=>verifyTransferMilestone('receipt',{...base,sourceTotal:2,targetTotal:1})) + assert.throws(()=>verifyTransferMilestone('receipt',{...base,sourceTotal:3,targetTotal:1,protectedAfter:{...unchanged,holdPackages:0}})) + assert.throws(()=>verifyTransferMilestone('receipt',{...base,sourceTotal:3,targetTotal:1,ledgerAfter:[{id:'old',packages:2}]})) + assert.throws(()=>verifyTransferMilestone('receipt',{...base,sourceTotal:3,targetTotal:1,ledgerAfter:[ship,{...receipt,skuCode:'OLD'},{id:'old',packages:1}]})) +}) + +test('actual PDA detail shape requires unit facts before bind and allows unit without PALLET_BIND history',()=>{ + const f=linkedFixture(),context={linked:f.linked,task:f.task, + source:{warehouseId:LA,positionId:'selected-old',palletId:'source-pallet',palletCode:'SOURCE',palletVersion:1,positionVersion:4}, + target:{warehouseId:LA,palletCode:'WORK',palletVersion:0,destinationWarehouseCode:'ABE4'},observedCartonMarks:['BOX-1']} + assert.ok(f.plan.phases.findIndex(x=>x.stage==='unit')x.stage==='bind')) + assert.throws(()=>prepareTransferStage(f.plan,'bind',{...context,task:{...f.task,relabelledPackages:0,unitFacts:[]}}),/COMPLETE_UNIT_FACTS/) + for(const facts of [[],[{itemId:'other',result:'RELABELLED',quantity:1}],[{itemId:'item',result:'SKIPPED',quantity:1}],[{itemId:'item',result:'RELABELLED',quantity:2}]]) + assert.throws(()=>prepareTransferStage(f.plan,'bind',{...context,task:{...f.task,unitFacts:facts}}),/OWN_RELABELLED_UNIT_FACT/) + for(const expectedPackages of [undefined,null,0,NaN,2,'1']) + assert.throws(()=>prepareTransferStage(f.plan,'bind',{...context,task:{...f.task,items:f.task.items.map(i=>({...i,expectedPackages}))}}),/OWN_RELABELLED_UNIT_FACT/) + for(const quantity of [undefined,null,0,NaN,-1,'1']) + assert.throws(()=>prepareTransferStage(f.plan,'bind',{...context,task:{...f.task,unitFacts:[{itemId:'item',result:'RELABELLED',quantity}]}}),/OWN_RELABELLED_UNIT_FACT/) + assert.throws(()=>prepareTransferStage(f.plan,'bind',{...context,task:{...f.task,unitFacts:[{itemId:'item',result:'RELABELLED',quantity:-1},{itemId:'item',result:'RELABELLED',quantity:2}]}}),/OWN_RELABELLED_UNIT_FACT/) + const actual={...f.task,history:[],unitFacts:[{factId:'2106799999999999999',itemId:'item',oldLabel:'BOX-1',newLabel:'FBA-BOX-1',result:'RELABELLED',quantity:1,operatorName:'SYNTHETIC',occurredAt:'2026-10-05T00:00:00'}]} + assert.ok(prepareTransferStage(f.plan,'bind',{...context,task:actual})) + assert.ok(prepareTransferStage(f.plan,'unit',{linked:f.linked,task:{...actual,relabelledPackages:0,unitFacts:[]},itemId:'item',observedOldLabel:'BOX-1',observedNewLabel:'FBA-BOX-1',observedSkus:[{skuCode:'OLD',newSkuCode:'NEW',quantity:1}]})) +}) + +function originalBindRecoveryFixture(directory){ + const command={stage:'bind',runId:'f33-original-20261005-r1',transferNo:TRANSFER,customerId:CUSTOMER,actor:'pda',permission:'lastleg:pda:operate',replay:'KEYED',method:'POST',path:'/pda/v1/last-leg/pallet-operations/relabel-disassemble',body:{relabelNo:'RL-WH-20261005-174913',idempotencyKey:'F33-WT-430594-f33-original-20261005-r1-bind',source:{palletCode:'DP-261002-0018',expectedVersion:1},target:{palletCode:'PLT-USLAX01-261005-G08C1U1NQMMA',expectedVersion:0},cartonMarks:['CM-ADMIN-API-20261002042009973'],actualSource:{positionId:'2105878074206765058',expectedPositionVersion:4,packages:1},reason:'F33-WT-430594-f33-original-20261005-r1-bind'},preparedHash:'bb1ccb10ebaef5f37d8f16782ae6f8f2051d31061142533a61e8ab020c1e2a0d'} + const journal={command,phase:'ACKNOWLEDGED',http:200,code:'A0409',success:false,businessVerified:false} + const rollback={verified:true,unchanged:['transfer','positions','ledger','linked','order','task']} + const fresh={runId:command.runId,transferNo:TRANSFER,relabelNo:command.body.relabelNo,originalKeyNotExecuted:true,unitFactsVerified:true,unitFactCount:1,relabelledPackages:1,stockAndLedgerUnchangedByUnit:true,sourcePalletCode:command.body.source.palletCode,targetPalletCode:command.body.target.palletCode,sourcePalletVersion:1,targetPalletVersion:0,sourcePositionId:command.body.actualSource.positionId,sourcePositionVersion:4} + const journalFile=path.join(directory,command.runId+'-bind.json'),rollbackFile=path.join(directory,'rollback.json'),freshFile=path.join(directory,'fresh.json') + const store=()=>{fs.writeFileSync(journalFile,JSON.stringify(journal));fs.writeFileSync(rollbackFile,JSON.stringify(rollback));fs.writeFileSync(freshFile,JSON.stringify(fresh))} + const sha=p=>crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex') + store() + const recovery=()=>({authorization:'F33_BIND_A0409_AFTER_UNIT',originalJournalSha256:sha(journalFile),rollbackProofFile:'rollback.json',rollbackProofSha256:sha(rollbackFile),freshProofFile:'fresh.json',freshProofSha256:sha(freshFile)}) + const actor={kind:'pda',appCode:'PLATFORM',tenantId:'0',membershipId:'2106727769572478980',warehouseId:LA,roles:['OVERSEAS_WAREHOUSE_PDA_OPERATOR'],authorities:['lastleg:pda:operate']} + return {command,journal,rollback,fresh,journalFile,store,recovery,options:{command,release:{...release('bind'),runId:command.runId},actor,journalDirectory:directory}} +} + +test('released exact A0409 binding recovers once into durable side journal and preserves original failure bytes',async()=>{ + const temp=temporaryJournal();let calls=0 + try{ + const f=originalBindRecoveryFixture(temp.directory),original=fs.readFileSync(f.journalFile) + const side=path.join(temp.directory,f.command.runId+'-bind-recovery.json') + const transport=async()=>{calls++;const j=JSON.parse(fs.readFileSync(side));assert.equal(calls===1?j.phase:j.replay.phase,'INTENT');return {http:200,code:'200',data:{replayed:calls>1},message:'完成'}} + const options={...f.options,recovery:f.recovery(),transport} + await assert.rejects(runPreparedTransferStage({...options,recovery:undefined}));assert.equal(calls,0) + await runPreparedTransferStage(options);assert.deepEqual(fs.readFileSync(f.journalFile),original) + await assert.rejects(runPreparedTransferStage(options));assert.equal(calls,1) + await runPreparedTransferStage({...options,replay:true});assert.deepEqual(fs.readFileSync(f.journalFile),original) + await assert.rejects(runPreparedTransferStage({...options,replay:true}));assert.equal(calls,2) + }finally{temp.cleanup()} +}) + +test('recovery never releases unknown, other rejects, changed payload, incomplete proof or changed live versions',async()=>{ + for(const patch of [f=>f.journal.phase='OUTCOME_UNKNOWN',f=>f.journal.code='A0500',f=>f.journal.http=409, + f=>f.journal.success=true,f=>f.rollback.unchanged=['transfer'],f=>f.fresh.sourcePositionVersion=5, + f=>f.fresh.targetPalletVersion=1,f=>f.fresh.unitFactCount=0,f=>f.fresh.originalKeyNotExecuted=false]){ + const temp=temporaryJournal();let calls=0 + try{const f=originalBindRecoveryFixture(temp.directory);patch(f);f.store();await assert.rejects(runPreparedTransferStage({...f.options,recovery:f.recovery(),transport:async()=>{calls++}}));assert.equal(calls,0)}finally{temp.cleanup()} + } + const temp=temporaryJournal();let calls=0 + try{const f=originalBindRecoveryFixture(temp.directory),options={...f.options,transport:async()=>{calls++}} + await assert.rejects(runPreparedTransferStage({...options,recovery:{...f.recovery(),authorization:'OTHER'}})) + await assert.rejects(runPreparedTransferStage({...options,recovery:{...f.recovery(),originalJournalSha256:'0'.repeat(64)}})) + await assert.rejects(runPreparedTransferStage({...options,recovery:f.recovery(),command:{...f.command,body:{...f.command.body,reason:'NEW PAYLOAD'}}})) + assert.equal(calls,0) + }finally{temp.cleanup()} +}) + +test('business rejection message is retained with tokens credentials and endpoint URLs redacted',async()=>{ + assert.equal(redactTransferMessage('请先完成本箱全部换标作业,再进行组托'),'请先完成本箱全部换标作业,再进行组托') + const temp=temporaryJournal() + try{const f=linkedFixture(),command=prepareTransferStage(f.plan,'approve',{linked:f.linked});await assert.rejects(runPreparedTransferStage({command,release:release('approve'),actor:platform,journalDirectory:temp.directory,transport:async()=>({http:200,code:'A0409',message:'业务拒绝 password=secret Bearer token123 https://private.example/path'})})); + const raw=fs.readFileSync(path.join(temp.directory,'offline-contract-approve.json'),'utf8');assert.match(raw,/业务拒绝/);assert.doesNotMatch(raw,/secret|token123|private\.example/) + }finally{temp.cleanup()} +}) + +test('combined /units SKU-only actual response needs carton scan before one final fact and physical bind',()=>{ + const f=linkedFixture();const task={...f.task,version:1,relabelledPackages:0,unitFacts:[],history:[{action:'MIXED_SKU',detail:JSON.stringify({itemId:'item',round:'INITIAL',stage:'SKU'})}], + items:f.task.items.map(i=>({...i,relabelledPackages:0,cartonProcessedPackages:0,skuProcessedPackages:1}))} + const context={linked:f.linked,task,itemId:'item',observedOldLabel:'BOX-1',observedNewLabel:'FBA-BOX-1'} + const command=prepareTransferStage(f.plan,'unit-carton',context) + assert.match(command.path,/\/cartons\/scan$/);assert.equal(command.body.expectedVersion,1);assert.equal(command.body.cartonMark,'BOX-1') + assert.ok(f.plan.phases.findIndex(p=>p.stage==='unit')p.stage==='unit-carton')) + assert.ok(f.plan.phases.findIndex(p=>p.stage==='unit-carton')p.stage==='bind')) + for(const patch of [{skuProcessedPackages:0},{cartonProcessedPackages:1},{expectedPackages:undefined}]) + assert.throws(()=>prepareTransferStage(f.plan,'unit-carton',{...context,task:{...task,items:task.items.map(i=>({...i,...patch}))}}),/OWN_SKU_ONLY_PARTIAL/) + assert.throws(()=>prepareTransferStage(f.plan,'unit-carton',{...context,task:{...task,relabelledPackages:1}})) + assert.throws(()=>prepareTransferStage(f.plan,'unit-carton',{...context,observedNewLabel:'OTHER'})) +}) diff --git a/scripts/fba-remaining-nodes-acceptance.mjs b/scripts/fba-remaining-nodes-acceptance.mjs new file mode 100644 index 000000000..083b26dde --- /dev/null +++ b/scripts/fba-remaining-nodes-acceptance.mjs @@ -0,0 +1,122 @@ +// Internal evidence helper; invoked explicitly by the assigned audit, never runs on import. +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' + +export const digest = value => crypto.createHash('sha256').update(typeof value === 'string' ? value : JSON.stringify(value)).digest('hex') +export function selectSteps(report, predicate) { + return (report.steps || []).flatMap((step, index) => predicate(step) ? [{ pointer: `/steps/${index}`, ...step }] : []) +} +export function requirePassed(steps) { + return steps.length > 0 && steps.every(s => s.passed === true) +} +export function transferConclusion(detail) { + const shipped = Boolean(detail.actualShipAt) + const terminal = detail.status === 'RECEIVED' + const received = (detail.allocations || []).length > 0 && detail.allocations.every(a => a.status === 'RECEIVED' && a.destinationInventoryPositionId) + return { status: detail.status, orderStatus: detail.orderStatus, transferNo: detail.transferNo, + allocationCount: detail.allocations?.length || 0, packageCount: detail.packageCount, + sourceCount: detail.sourceOrders?.length || 0, actualShipAt: detail.actualShipAt || null, + completed: terminal && shipped && received, requiresRealOperation: !(terminal && shipped && received) } +} +export async function auditRemainingNodes({ root, output, env, live = false }) { + if (fs.existsSync(output)) throw Error('REPORT_EXISTS_NO_OVERWRITE') + const read = relative => JSON.parse(fs.readFileSync(path.join(root, relative), 'utf8')) + const files = ['reports/node-completion-audit-root-20261004-r1.json', + 'reports/admin-full-current-20261002-r4.json', 'reports/overseas-receiving-current-20261002-r1.json', + 'reports/fba-core-f40-tax-fleet-20261003-r1/root_first.json', + 'reports/f26-loaded-transit-verification-20261004-r2.json', + 'reports/discard-review-entry-root-20261004-r1/final-evidence.json'] + const reports = Object.fromEntries(files.map(f => [f, read(f)])) + const full = reports[files[1]] + const evidence = (file, predicate) => ({ file, sha256: digest(fs.readFileSync(path.join(root, file), 'utf8')), + steps: selectSteps(reports[file], predicate) }) + const result = { checkedAt: new Date().toISOString(), goalComplete: false, + scope: 'Existing TEST evidence only; no business commands, SQL, fault injection, deployment or UI operation', + sourceFiles: files.map(file => ({ file, sha256: digest(fs.readFileSync(path.join(root, file), 'utf8')) })), + integrationRequired: { file: 'scripts/README.md', owner: '总控', + reason: 'Current write boundary excludes shared script index; documentation contract identifies this new helper as undocumented.', + text: '`scripts/fba-remaining-nodes-acceptance.mjs`:本轮FBA剩余节点内部证据助手,仅显式调用auditRemainingNodes;读取指定原报告并按JSON pointer/SHA-256保留证据。live=true仅使用明确TEST环境文件的独立Auth会话与串行GET,正常退出;不执行业务写命令、SQL、故障注入、部署或页面操作。报告reports/fba-remaining-nodes-20261004/,既有报告拒绝覆盖,失败保留且不自动重试。对应离线测试fba-remaining-nodes-acceptance.test.mjs。' }, + nodes: [], requests: [], sessionClosed: !live } + result.nodes.push({ node: 'F23.2', conclusion: 'HTTP actual AT_ORIGIN_PORT supported; Admin save and two-end projection remain unverified', + category: '需真实操作', evidence: [evidence(files[3], s => /AT_ORIGIN_PORT/.test(s.name))], + next: 'Use a current not-yet-at-origin-port sample; freeze query version/history and customer projection, record actual node once through Admin, capture POST and both readbacks; preserve customs/container identity and do not rewrite a later actual node.' }) + result.nodes.push({ node: 'F25', conclusion: 'Clearance/DO upload and replay, fleet/appointment/empty-return supported; PU, distinct actual pickup and mail lifecycle unverified', + category: '需真实操作', evidence: [evidence(files[1], s => s.module === 'phase5-terminal-drayage'), + evidence(files[1], s => s.module === 'phase3-container-documents' && /DELIVERY_ORDER|DESTINATION_CLEARANCE/.test(s.name))], + boundaries: { mailRequestsInOriginalReport: full.steps.filter(s => /terminal-drayages.*\/emails?|terminal-drayages.*pickup-email/.test(s.request?.path || '')).length, + pickupAssertion: 'Original drayage assertion compares actualPickupAt to unloadingCompletedAt; this does not establish a distinct pickup command/time.' }, + next: 'Freeze pre-pickup container and actual node history; inspect current DO/PU contract, confirm distinct PICKED_UP before unloading; capture explicit email queue/recipient snapshot, SMTP acceptance separately, fleet-change ordered cancellation/request and failed-mail retry without assuming delivery.' }) + result.nodes.push({ node: 'F27', conclusion: 'Normal conversion/resources/replay supported; resource-failure retry UI unverified', + category: '需真实操作', evidence: [evidence(files[1], s => s.module === 'phase4-tail-order-controls')], + explicitRetryEvidence: selectSteps(full, s => /资源.*重试|计划.*重试|重试.*资源/.test(s.name)), + next: 'First locate a real resourcePendingPlanCount>0 container; freeze plans, reservations, resource identities and original command key. Use original container conversion UI retry once, verify only failed resources finish and no new plans/reservations; healthy completed samples cannot prove recovery. Failure injection requires controller window.' }) + result.nodes.push({ node: 'F33-transfer', conclusion: 'Original stock transfer approved; outbound/target receipt terminal not established', + category: '需真实操作', originalTransferNo: 'WT-WH-20261002-430594', + evidence: [evidence(files[1], s => (s.request?.path || '').includes('WT-WH-20261002-430594'))], + originalFixture: full.createdResources.phase4OverseasClientServices, + next: 'Continue this exact original transfer only after current source stock, holds, destination and allocations are frozen. Verify allocation, actual outbound and target receipt with per-position balances/unique movements and same-command replay. Incoming transfer fixtures are separate sources and cannot replace this ticket.' }) + result.nodes.push({ node: 'F33-discard', conclusion: 'Rejection release supported; approval/decrement decision pending', + category: '缺用户输入', evidenceFiles: [files[5]], next: 'Await approval-time versus PDA actual-quantity decrement decision; no approval or decrement issued.' }) + result.nodes.push({ node: 'F26', conclusion: 'Existing 68-check source/transit scope is already evidenced; no repeat run', + category: '可立即关闭指定分支', evidenceFiles: [files[4]], boundary: 'Only 11 FBA sources/13 actual cartons and pre-receipt mapping; not all event faults or UI.' }) + result.nodes.push({ node: 'F24-external', conclusion: 'External carrier lifecycle lacks valid input', + category: '缺用户输入', next: 'Provide valid carrier TEST input; synthetic fixture or manual nodes cannot close supplier lifecycle.' }) + const save = () => { fs.mkdirSync(path.dirname(output), { recursive: true }); fs.writeFileSync(output, JSON.stringify(result, null, 2)) } + save() + if (!live) return result + let session + const call = async (method, route, body, token = session?.accessToken) => { + if (method !== 'GET' && !['/auth/login', '/auth/logout'].includes(route)) throw Error('BUSINESS_WRITE_FORBIDDEN') + const entry = { method, path: route, phase: 'INTENT' }; result.requests.push(entry); save() + const response = await fetch(env.CHONGCHONG_ADMIN_API_BASE_URL.replace(/\/$/, '') + route, { + method, redirect: 'error', signal: AbortSignal.timeout(20000), + headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }) }) + const json = JSON.parse(await response.text()) + Object.assign(entry, { phase: 'ACKNOWLEDGED', http: response.status, code: String(json.code), + requestId: response.headers.get('x-request-id'), traceId: response.headers.get('x-trace-id') }); save() + if (response.status !== 200 || String(json.code) !== '200') throw Error(`READ_FAILED_${response.status}_${json.code}`) + return json.data + } + try { + if (String(env.CHONGCHONG_ENV).toUpperCase() !== 'TEST' || !env.CHONGCHONG_ADMIN_API_BASE_URL || !env.CHONGCHONG_ADMIN_USERNAME || !env.CHONGCHONG_ADMIN_PASSWORD) throw Error('EXPLICIT_TEST_ENV_REQUIRED') + session = await call('POST', '/auth/login', { loginId: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD }, '') + const transferRoute = '/platform/v1/last-leg/warehouse-transfers/WT-WH-20261002-430594?customerId=2105875471489142785' + const before = await call('GET', transferRoute) + if (before.transferNo !== 'WT-WH-20261002-430594') throw Error('TRANSFER_SCOPE_MISMATCH') + result.nodes.find(n => n.node === 'F33-transfer').current = transferConclusion(before) + const container = full.createdResources.phase5TerminalDrayage.containerBookingId + const drayage = await call('GET', `/platform/v1/terminal-drayages/${container}`) + const history = await call('GET', `/platform/v1/terminal-drayages/${container}/history`) + const mailbox = await call('GET', `/platform/v1/terminal-drayages/${container}/emails`) + result.nodes.find(n => n.node === 'F25').current = { + containerNo: drayage.containerNo, status: drayage.status, actualPickupAt: drayage.actualPickupAt, + unloadingCompletedAt: drayage.unloadingCompletedAt, actualEmptyReturnAt: drayage.actualEmptyReturnAt, + pickupEqualsUnloading: drayage.actualPickupAt === drayage.unloadingCompletedAt, + historyActions: history.map(h => h.action), mailboxCount: mailbox.records?.length || 0, + mailStatuses: (mailbox.records || []).map(m => ({ status: m.status, kind: m.kind })) } + const overview = await call('GET', `/platform/v1/containers/overseas/${container}/overview`) + Object.assign(result.nodes.find(n => n.node === 'F25').current, { + pickupCodePresent: Boolean(overview.container?.pickupCode), + documents: (overview.documents || []).map(d => ({ documentType: d.documentType, version: d.version, + filePresent: Boolean(d.fileId), completed: d.completed === true })) }) + const preview = await call('GET', `/platform/v1/last-leg/inbound-plans/container-preview?sourceContainerBookingId=${container}`) + result.nodes.find(n => n.node === 'F27').current = { containerNo: preview.containerNo, + totalOrderCount: preview.totalOrderCount, pendingOrderCount: preview.pendingOrderCount, + blockedOrderCount: preview.blockedOrderCount, totalPlannedPalletCount: preview.totalPlannedPalletCount, + plans: (preview.groups || []).flatMap(g => (g.orders || []).map(o => ({ planNo: o.existingPlanNo, planStatus: o.planStatus }))), + recoveryProven: false } + const after = await call('GET', transferRoute) + result.transferReadStable = digest(before) === digest(after) + result.liveReadSuccess = result.transferReadStable + } catch (error) { result.liveReadSuccess = false; result.failure = String(error.message).replace(/https?:\/\/\S+/g, '[redacted]') } + finally { + if (!session) result.sessionClosed = true + if (session) { try { await call('POST', '/auth/logout', { refreshToken: session.refreshToken }); result.sessionClosed = true } + catch { result.sessionClosed = false; result.liveReadSuccess = false } } + save() + } + return result +} diff --git a/scripts/fba-remaining-nodes-acceptance.test.mjs b/scripts/fba-remaining-nodes-acceptance.test.mjs new file mode 100644 index 000000000..4fbffa3c1 --- /dev/null +++ b/scripts/fba-remaining-nodes-acceptance.test.mjs @@ -0,0 +1,20 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { requirePassed, selectSteps, transferConclusion } from './fba-remaining-nodes-acceptance.mjs' + +test('missing and partially failed evidence cannot close a branch', () => { + assert.equal(requirePassed([]), false) + assert.equal(requirePassed([{ passed: true }, { passed: false }]), false) + assert.equal(requirePassed([{ passed: true }, {}]), false) + assert.equal(requirePassed([{ passed: true }]), true) +}) +test('approved stock transfer and a signed unrelated order do not prove transfer receipt', () => { + assert.equal(transferConclusion({ status: 'APPROVED', orderStatus: 'DELIVERED' }).completed, false) + assert.equal(transferConclusion({ status: 'RECEIVED' }).completed, false) + assert.equal(transferConclusion({ status: 'OUTBOUNDED', actualShipAt: '2026-10-04' }).completed, false) + assert.equal(transferConclusion({ status: 'RECEIVED', actualShipAt: '2026-10-04', allocations: [{ status: 'RECEIVED', destinationInventoryPositionId: '42' }] }).completed, true) +}) +test('selection keeps exact original JSON pointer and failure evidence', () => { + const rows = selectSteps({ steps: [{ name: 'other', passed: true }, { name: 'original', passed: false }] }, s => s.name === 'original') + assert.deepEqual(rows, [{ pointer: '/steps/1', name: 'original', passed: false }]) +}) diff --git a/scripts/fee-void-recovery-test-env.mjs b/scripts/fee-void-recovery-test-env.mjs new file mode 100644 index 000000000..3271ae95b --- /dev/null +++ b/scripts/fee-void-recovery-test-env.mjs @@ -0,0 +1,170 @@ +import { mkdirSync, writeFileSync, openSync, closeSync, unlinkSync, existsSync } from 'node:fs' +import { dirname, resolve, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { isDeepStrictEqual } from 'node:util' +import { createHash } from 'node:crypto' +import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs' +import { createTestEvidenceReaders } from './fba-core-regression-evidence.mjs' +import { redactEvidence } from './fba-core-regression-test-env.mjs' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const FEES = '/api/platform/v1/last-leg/finance-fee-sources' +const TYPE = 'OVERSEAS_WAREHOUSE_FEE' +const id = value => String(value ?? '') +const check = (value, message) => { if (!value) throw Error(message) } +export function parseFeeVoidArguments(args) { + const config = { execute: false, environment: 'TEST', runId: 'plan' }, seen = new Set() + for (let i = 0; i < args.length; i++) { + const arg = args[i] + if (['--plan', '--execute'].includes(arg)) { + check(!seen.has('action'), 'Choose one execution action'); seen.add('action'); config.execute = arg === '--execute'; continue + } + const field = { '--environment': 'environment', '--run-id': 'runId', '--customer-id': 'customerId', '--control-customer-id': 'controlCustomerId', '--warehouse-id': 'warehouseId' }[arg] + check(field && !seen.has(arg) && args[i + 1] && !args[i + 1].startsWith('--'), 'Invalid or duplicate argument') + seen.add(arg); config[field] = args[++i] + } + validateConfig(config) + if (config.execute) check(seen.has('--environment'), 'Execution requires explicit TEST') + return config +} +function validateConfig(config) { + check(config.environment === 'TEST' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId), 'TEST and safe run-id required') + if (config.execute) check(config.runId !== 'plan' && ['customerId', 'controlCustomerId', 'warehouseId'].every(key => typeof config[key] === 'string' && /^[1-9][0-9]{0,19}$/.test(config[key])) + && config.customerId !== config.controlCustomerId, 'Two explicit distinct customers and warehouse required') +} +export function feeVoidPlan(config) { + return { environment: 'TEST', execute: config.execute, runId: config.runId, customerId: config.customerId, controlCustomerId: config.controlCustomerId, + warehouseId: config.warehouseId, output: `reports/fee-void-${config.runId}`, sourceType: TYPE, amount: 17.31, currency: 'CNY', + stages: ['read customer/warehouse and isolation baselines', 'create/submit/approve new warehouse service fee', 'wait original publication', + 'inject only VOIDED ACK loss', 'original DEAD retry and full runtime restore', 'same-command replay and isolation readback'], + limits: 'Only a new unbilled TEST receivable. Retain fixtures and failures, never automatically restart. No business SQL writes. Exclusive Lastleg fault window; no overlapping deployments or writers.' } +} +export async function runFeeVoid(config, { env = process.env, outputRoot = ROOT, readers, faultFactory, + contextFactory = options => new AcceptanceRun(options), wait = ms => new Promise(resolve => setTimeout(resolve, ms)) } = {}) { + validateConfig(config) + if (!config.execute) return feeVoidPlan(config) + check(env.CHONGCHONG_ENV === 'test' && env.CHONGCHONG_ADMIN_USERNAME && env.CHONGCHONG_ADMIN_PASSWORD, 'Explicit TEST credentials required') + const base = new URL(env.CHONGCHONG_ADMIN_API_BASE_URL) + check(['http:', 'https:'].includes(base.protocol) && ['/api', '/api/'].includes(base.pathname) && !base.username && !base.password && !base.search && !base.hash, 'Invalid TEST gateway') + readers ||= createTestEvidenceReaders(env) + faultFactory ||= (await import('./test-event-fault-executor.mjs')).createTestFeeVoidFaultExecutor + check(typeof faultFactory === 'function' && typeof readers.readFleetInventory === 'function', 'Fee fault executor and complete inventory reader required') + const reports = resolve(outputRoot, 'reports'), output = join(reports, `fee-void-${config.runId}`) + mkdirSync(reports, { recursive: true }) + for (const name of ['.fba-core-regression.lock', '.fleet-acceptance.lock']) check(!existsSync(join(reports, name)), 'Another shared-environment acceptance is running or needs review') + const lockPath = join(reports, '.test-event-recovery.lock'), lock = openSync(lockPath, 'wx') + const report = { success: false, stage: 'PREFLIGHT', requests: [], inventory: [], goalComplete: false } + let context, fault, initialized = false, refreshToken, attempted = false, prepared = false + const secrets = [env.CHONGCHONG_ADMIN_PASSWORD, env.JENKINS_API_TOKEN] + const save = () => { if (initialized) writeFileSync(join(output, 'report.json'), JSON.stringify(redactEvidence(report, secrets), null, 2)) } + try { + writeFileSync(lock, JSON.stringify({ pid: process.pid, runId: config.runId })) + mkdirSync(output); initialized = true + writeFileSync(join(output, 'manifest.json'), JSON.stringify(feeVoidPlan(config), null, 2)) + await readers.preflight() + context = contextFactory(validateExecutionOptions({ ...parseArguments(['--modules', 'merchant-qualification-fixture', '--quiet', 'true']), + environment: 'test', gateway: base.origin, username: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD, + markerNamespace: createHash('sha256').update(config.runId).digest('hex').slice(0, 12) })) + report.marker = context.marker + const request = async (method, path, body, headers = {}) => { + report.pending = { method, path, body }; save() + const result = await context.request(method, path, body, context.token, headers) + report.requests.push({ method, path, http: result.http, code: result.body?.code, requestId: result.requestId, traceId: result.traceId }) + delete report.pending; save() + check(result.http === 200 && id(result.body?.code) === '200', `Fee recovery request failed: ${method} ${path} (${result.http}/${result.body?.code})`) + return result.body.data + } + const login = await request('POST', '/api/auth/login', { loginId: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD }) + check(login?.accessToken && login.refreshToken, 'Complete administrator session required') + context.token = login.accessToken; refreshToken = login.refreshToken; secrets.push(context.token, refreshToken) + const customers = [] + for (const customerId of [config.customerId, config.controlCustomerId]) { + const customer = await request('GET', `/api/platform/v1/iam/tenants/${customerId}`) + check(id(customer.id) === customerId && customer.status === 'ACTIVE' && customer.tenantName, 'Exact active customer required') + customers.push(customer) + } + const warehouse = await request('GET', `/api/platform/v1/warehouse-admin/${config.warehouseId}?tenantId=0`) + check(id(warehouse.warehouse?.id) === config.warehouseId && id(warehouse.warehouse.tenantId) === '0' && warehouse.warehouse.status === 'ACTIVE' + && warehouse.warehouse.warehouseType === 'OVERSEAS', 'Exact active overseas warehouse required') + const feeList = customerId => request('GET', `${FEES}?${new URLSearchParams({ customerId })}`) + const pool = customerId => request('GET', `/api/platform/finance/v1/fee-pool?${new URLSearchParams({ customerId, direction: 'RECEIVABLE' })}`) + const snapshot = async () => { + const value = [] + for (const customerId of [config.customerId, config.controlCustomerId]) { + const sources = await feeList(customerId), finance = await pool(customerId) + check(Array.isArray(sources) && sources.every(row => id(row.customerId) === customerId) && Array.isArray(finance) && finance.every(row => id(row.customerId) === customerId), 'Customer fee scope mismatch') + const inventory = await readers.readFleetInventory({ marker: context.marker, customerId, warehouseId: config.warehouseId }) + value.push({ customerId, sources, finance, inventory }) + } + return value + } + report.before = await snapshot() + report.stage = 'CREATE_NEW_FEE' + const body = { customerId: config.customerId, customerName: customers[0].tenantName, sourceType: TYPE, businessType: 'WAREHOUSE_SERVICE', + businessId: null, businessNo: `${context.marker}-VOID-TEST`, warehouseId: config.warehouseId, + feeItemCode: 'TEST-VOID-ACK', feeItemName: '受控测试作废响应丢失费用', direction: 'RECEIVABLE', businessDate: new Date().toISOString().slice(0, 10), + currency: 'CNY', amount: 17.31, paymentVoucherFileIds: [] } + report.createBody = body + let fee = await request('POST', FEES, body, { 'Idempotency-Key': `${context.marker}-CREATE` }) + report.sourceFeeId = id(fee.sourceFeeId) + check(/^[1-9][0-9]+$/.test(report.sourceFeeId) && fee.reviewStatus === 'DRAFT' && id(fee.customerId) === config.customerId && Number(fee.sourceVersion) === 1, 'New isolated fee draft required') + const path = `${FEES}/${report.sourceFeeId}` + fee = await request('POST', `${path}/submit`, { idempotencyKey: `${context.marker}-SUBMIT` }) + check(fee.reviewStatus === 'PENDING', 'New fee must await review') + fee = await request('POST', `${path}/review`, { decision: 'APPROVE', reason: context.marker, idempotencyKey: `${context.marker}-REVIEW` }) + check(fee.reviewStatus === 'APPROVED' && fee.publicationStatus === 'PUBLISHED' && fee.sourceStatus === 'ACTIVE', 'Approved active source required') + report.approved = fee + let published = [] + for (let i = 0; i < 30; i++) { + published = (await pool(config.customerId)).filter(row => id(row.sourceId) === report.sourceFeeId && Number(row.sourceVersion) === 1) + if (published.length) break + await wait(1000) + } + check(published.length === 1 && Number(published[0].amount) === 17.31 && published[0].currency === 'CNY', 'Original approved fee must exist exactly once in Finance') + report.originalFinance = published + report.scope = { marker: context.marker, customerId: config.customerId, sourceFeeId: report.sourceFeeId, sourceVersion: '1', sourceType: TYPE } + fault = faultFactory(env) + report.stage = 'PREPARE_INTENT'; attempted = true; save() + await fault.prepare(report.scope) + prepared = true + report.stage = 'VOID_REQUEST' + report.voidRequest = { expectedVersion: 1, reason: '受控TEST作废确认响应丢失验收', idempotencyKey: `${context.marker}-VOID` } + const pending = await request('POST', `${path}/void`, report.voidRequest) + check(pending.sourceStatus === 'ACTIVE' && pending.voidStatus === 'PENDING_FINANCE', 'Sending void request must not confirm business-side cancellation') + report.pendingSource = pending; report.stage = 'WAIT_DEAD_AND_RECOVER'; save() + report.eventProof = await fault.readProof(report.scope) + report.stage = 'RESTORED_READBACK'; save() + const confirmed = (await feeList(config.customerId)).filter(row => id(row.sourceFeeId) === report.sourceFeeId) + check(confirmed.length === 1 && confirmed[0].sourceStatus === 'VOIDED' && confirmed[0].voidStatus === 'CONFIRMED' && Number(confirmed[0].sourceVersion) === 1, 'Matching Finance ACK must confirm only the original source version') + report.confirmedSource = confirmed[0] + const replay = await request('POST', `${path}/void`, report.voidRequest) + check(isDeepStrictEqual(replay, confirmed[0]), 'Same void command replay changed the confirmed source') + report.after = await snapshot() + check(isDeepStrictEqual(report.before[1], report.after[1]), 'Other customer fees or inventory changed') + check(isDeepStrictEqual(report.before[0].inventory, report.after[0].inventory), 'Fee cancellation changed inventory') + check(isDeepStrictEqual(report.before[0].sources, report.after[0].sources.filter(row => id(row.sourceFeeId) !== report.sourceFeeId)), 'Unrelated original source changed') + check(isDeepStrictEqual(report.before[0].finance, report.after[0].finance), 'Voided fee remains in pool or unrelated finance row changed') + report.success = true; report.stage = 'VERIFIED' + } catch (error) { report.error = error.message; report.success = false; report.stage = 'FAILED' } + finally { + if (fault) try { + report.restoration = await fault.cleanup() + const noMutation = !prepared && report.restoration?.attempted === false + if (attempted && !noMutation && !(report.restoration?.restored && report.restoration?.healthy && report.restoration?.proxyStopped)) throw Error('Fee void restoration not verified') + } catch (error) { report.success = false; report.stage = 'RESTORATION_FAILED'; report.restorationError = error.message } + if (context?.token) try { + const result = await context.request('POST', '/api/auth/logout', { refreshToken }, context.token) + report.sessionClosed = result.http === 200 && id(result.body?.code) === '200' + if (!report.sessionClosed) report.success = false + } catch (error) { report.success = false; report.sessionClosed = false; report.logoutError = error.message } + try { save() } finally { closeSync(lock); unlinkSync(lockPath) } + } + return report +} +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + const config = parseFeeVoidArguments(process.argv.slice(2)), result = await runFeeVoid(config) + console.log(JSON.stringify(config.execute ? { success: result.success, stage: result.stage, error: result.error, sourceFeeId: result.sourceFeeId, restored: result.restoration?.restored, output: feeVoidPlan(config).output } : result, null, 2)) + if (config.execute && !result.success) process.exitCode = 1 + } catch (error) { console.error(error.message); process.exitCode = 1 } +} diff --git a/scripts/fee-void-recovery-test-env.test.mjs b/scripts/fee-void-recovery-test-env.test.mjs new file mode 100644 index 000000000..4ebe8b729 --- /dev/null +++ b/scripts/fee-void-recovery-test-env.test.mjs @@ -0,0 +1,72 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, existsSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, dirname, basename, resolve } from 'node:path' +import { parseFeeVoidArguments, runFeeVoid } from './fee-void-recovery-test-env.mjs' + +const config = () => parseFeeVoidArguments(['--execute', '--environment', 'TEST', '--run-id', 'offline', + '--customer-id', '41', '--control-customer-id', '42', '--warehouse-id', '900']) +const env = { CHONGCHONG_ENV: 'test', CHONGCHONG_ADMIN_USERNAME: 'offline-admin', + CHONGCHONG_ADMIN_PASSWORD: 'private-password', CHONGCHONG_ADMIN_API_BASE_URL: 'https://test.example.invalid/api' } +function harness(outputRoot, failure) { + const calls = []; let fee, finished = false, cleanup = 0 + const context = { marker: 'FEE-OFFLINE', async request(method, path, body) { + calls.push({ method, path }) + let data + if (path === '/api/auth/login') data = { accessToken: 'private-access', refreshToken: 'private-refresh' } + else if (path === '/api/auth/logout') { if (failure === 'logout') throw Error('LOGOUT_TRANSPORT'); data = {} } + else if (path.includes('/iam/tenants/')) data = { id: path.split('/').at(-1), tenantName: 'Offline customer', status: 'ACTIVE' } + else if (path.includes('/warehouse-admin/')) data = { warehouse: { id: '900', tenantId: '0', status: 'ACTIVE', warehouseType: 'OVERSEAS' } } + else if (path.includes('/fee-pool?')) data = path.includes('customerId=41') && fee?.reviewStatus === 'APPROVED' && (!finished || failure === 'pool') + ? [{ customerId: '41', sourceId: '81', sourceVersion: 1, amount: 17.31, currency: 'CNY' }] : [] + else if (method === 'GET') data = path.includes('customerId=41') && fee ? [structuredClone(fee)] + : finished && failure === 'isolation' ? [{ customerId: '42', sourceFeeId: '99' }] : [] + else if (path.endsWith('/submit')) { fee.reviewStatus = 'PENDING'; data = fee } + else if (path.endsWith('/review')) { Object.assign(fee, { reviewStatus: 'APPROVED', publicationStatus: 'PUBLISHED', sourceStatus: 'ACTIVE' }); data = fee } + else if (path.endsWith('/void')) { + fee.voidStatus = finished ? 'CONFIRMED' : 'PENDING_FINANCE' + if (failure === 'early-confirm') fee.sourceStatus = 'VOIDED' + data = fee + } else { fee = { sourceFeeId: '81', sourceVersion: 1, customerId: body.customerId, reviewStatus: 'DRAFT' }; data = fee } + return { http: 200, body: { code: '200', data: structuredClone(data) } } + } } + return { calls, get cleanup() { return cleanup }, options: { env, outputRoot, contextFactory: () => context, + wait: async () => {}, readers: { preflight: async () => {}, readFleetInventory: async scope => ({ scope, balances: [], positions: [], ledger: [] }) }, + faultFactory: () => ({ prepare: async () => { if (['prepare', 'preflight'].includes(failure)) throw Error('PREPARE_FAILED') }, + readProof: async () => { finished = true; Object.assign(fee, { sourceStatus: 'VOIDED', voidStatus: 'CONFIRMED' }); return { status: 'VERIFIED' } }, + cleanup: async () => { cleanup++; return failure === 'preflight' || failure === 'missing-restoration' + ? { attempted: false } : { restored: failure !== 'restore', healthy: true, proxyStopped: true } } }) } } +} +test('fee void plan has no network and refuses implicit environment or ambiguous customer scope', async () => { + assert.equal((await runFeeVoid(parseFeeVoidArguments([]), { env: {} })).execute, false) + for (const args of [['--execute'], ['--environment', 'PROD'], ['--run-id', '../escape'], ['--plan', '--execute']]) + assert.throws(() => parseFeeVoidArguments(args)) + await assert.rejects(runFeeVoid({ ...config(), controlCustomerId: '41' }, { env }), /distinct/) + await assert.rejects(runFeeVoid(config(), { env: { ...env, CHONGCHONG_ENV: 'prod' } }), /TEST/) +}) +for (const failure of [undefined, 'prepare', 'preflight', 'early-confirm', 'isolation', 'pool', 'restore', 'missing-restoration', 'logout']) { + test(`fee void ${failure || 'success'} retains evidence and releases its lock after cleanup`, async () => { + const root = mkdtempSync(join(tmpdir(), 'fee-void-contract-')) + try { + const h = harness(root, failure), report = await runFeeVoid(config(), h.options) + assert.equal(report.success, !failure, JSON.stringify(report)) + assert.equal(h.cleanup, 1) + if (failure === 'preflight') assert.equal(report.stage, 'FAILED') + if (failure === 'missing-restoration') assert.equal(report.stage, 'RESTORATION_FAILED') + assert.equal(h.calls.filter(c => c.path === '/api/auth/logout').length, 1) + assert.equal(existsSync(join(root, 'reports/.test-event-recovery.lock')), false) + const path = join(root, 'reports/fee-void-offline/report.json'), saved = readFileSync(path, 'utf8') + assert.doesNotMatch(saved, /private-password|private-access|private-refresh/) + if (failure === 'logout') assert.equal(JSON.parse(saved).logoutError, 'LOGOUT_TRANSPORT') + const previous = h.calls.length + assert.equal((await runFeeVoid(config(), h.options)).success, false) + assert.equal(h.calls.length, previous) + assert.equal(readFileSync(path, 'utf8'), saved) + } finally { + assert.equal(dirname(resolve(root)), resolve(tmpdir())) + assert.ok(basename(root).startsWith('fee-void-contract-')) + rmSync(root, { recursive: true, force: true }) + } + }) +} diff --git a/scripts/finance-deployment-contract.test.mjs b/scripts/finance-deployment-contract.test.mjs index 17a496a53..a5664120d 100644 --- a/scripts/finance-deployment-contract.test.mjs +++ b/scripts/finance-deployment-contract.test.mjs @@ -9,17 +9,17 @@ test('Jenkins pipelines build finance as an independent service', async () => { const controller = await read('Jenkinsfile') const service = await read('Jenkinsfile.service') - assert.match(controller, /'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance'/) + assert.match(controller, /'gateway', 'auth', 'iam', 'firstleg', 'finance'/) assert.match(controller, /'finance': 'SERVICE_FINANCE'/) assert.match(controller, /booleanParam\(name: 'SERVICE_FINANCE'/) - assert.match(service, /'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance'/) + assert.match(service, /'gateway', 'auth', 'iam', 'firstleg', 'finance'/) assert.match(service, /'finance': 'finance\/target\/finance-service-1\.0\.0-SNAPSHOT\.jar'/) }) test('Compose and deploy script expose finance with fail-closed database config', async () => { const compose = await read('deploy/docker-compose.yml') const serviceMap = await read('deploy/service-map.yml') - const deployScript = await read('deploy/scripts/deploy-services.sh') + const deployScript = await read('deploy/scripts/deploy-built-service.sh') assert.match(compose, /^ finance:\s*$/m) assert.match(compose, /image: \$\{IMAGE_REPOSITORY\}\/finance:\$\{IMAGE_TAG\}/) @@ -28,5 +28,5 @@ test('Compose and deploy script expose finance with fail-closed database config' assert.doesNotMatch(compose, /FINANCE_WALLET_DEFAULT_CREDIT_LIMIT/) assert.match(serviceMap, /^ finance:\s*$/m) assert.match(serviceMap, /jar: finance\/target\/finance-service-1\.0\.0-SNAPSHOT\.jar/) - assert.match(deployScript, /FINANCE_DB_URL FINANCE_DB_USERNAME FINANCE_DB_PASSWORD/) + assert.match(deployScript, /for suffix in URL USERNAME PASSWORD/) }) diff --git a/scripts/fleet-booking-quotes-test-env-contract.test.mjs b/scripts/fleet-booking-quotes-test-env-contract.test.mjs new file mode 100644 index 000000000..d145b5071 --- /dev/null +++ b/scripts/fleet-booking-quotes-test-env-contract.test.mjs @@ -0,0 +1,51 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { warehouseQuoteContext, verifyBookingQuotes } from './fleet-booking-quotes-test-env.mjs' + +test('warehouse date uses authoritative timezone and rejects ambiguous or customer-owned warehouse', () => { + const detail = { warehouse: { id: '92', tenantId: '0', status: 'ACTIVE', warehouseType: 'OVERSEAS', timezone: 'America/Los_Angeles' }, addresses: [{ warehouseId: '92', tenantId: '0', city: 'LA' }] } + assert.equal(warehouseQuoteContext(detail, '2026-10-04T01:00:00Z').day, '2026-10-03') + assert.equal(warehouseQuoteContext({ ...detail, warehouse: { ...detail.warehouse, timezone: 'UTC-8' } }, '2026-10-04T01:00:00Z').day, '2026-10-03') + assert.throws(() => warehouseQuoteContext({ ...detail, warehouse: { ...detail.warehouse, tenantId: '1' } }, '2026-10-04T01:00:00Z')) + assert.throws(() => warehouseQuoteContext({ ...detail, addresses: [...detail.addresses, { ...detail.addresses[0], city: 'SF' }] }, '2026-10-04T01:00:00Z')) +}) +function harness(fault) { + const quotes = [], bookings = [], replay = new Map(), calls = [] + let trip = { tripNo: 'TR-OWN', recordVersion: 0 }, expired = false + const context = { marker: 'TEST-CURRENT', createdResources: { merchantQualificationFixture: { marker: 'TEST-CURRENT', tenantId: '9' } }, async request(method, path, body) { + calls.push({ method, path, body: structuredClone(body) }); const url = new URL(path, 'http://offline.invalid'), p = url.pathname + const response = (data, code = '200') => ({ http: 200, body: { code, data: structuredClone(data) }, requestId: 'request', traceId: 'trace' }) + if (p.endsWith('/quote-candidates')) { + const basis = url.searchParams.get('priceBasis'), currency = url.searchParams.get('currency') + return response({ records: quotes.filter(q => !expired && q.details.validFrom === '2026-10-04' && q.details.currency === currency && q.details[basis === 'PALLET' ? 'looseDeliveryPrice' : 'fullDeliveryPrice'] != null).map(q => ({ quoteNo: q.quoteNo, recordVersion: q.recordVersion, quotedUnitPrice: q.details[basis === 'PALLET' ? 'looseDeliveryPrice' : 'fullDeliveryPrice'], priceItem: basis === 'PALLET' ? 'LOOSE_DELIVERY' : 'FULL_DELIVERY', effectiveOn: '2026-10-04' })) }) + } + if (p.endsWith('/quotes') && method === 'POST') { const q = { quoteNo: `FQ-${quotes.length}`, recordVersion: 0, details: body.details }; quotes.push(q); return response(q) } + if (p.includes('/quotes/FQ-')) { const q = quotes.find(q => p.endsWith(q.quoteNo)); q.details = body.details; q.recordVersion++; return response(q) } + if (p.endsWith('/trips') && method === 'POST') return response({ trip }) + if (p.endsWith('/plan')) { expired = true; trip.recordVersion++; return response({ trip }) } + if (p.endsWith('/confirm')) return response(null, fault === 'accepts-stale' ? '200' : 'A0409') + if (p.endsWith('/bookings') && method === 'GET') return response({ tripRecordVersion: trip.recordVersion, records: bookings }) + if (p.endsWith('/bookings') && replay.has(body.idempotencyKey)) return response({ ...replay.get(body.idempotencyKey), replayed: true }) + const q = body.quoteRef ? quotes.find(q => q.quoteNo === body.quoteRef.quoteNo) : null + if (q && q.recordVersion !== body.quoteRef.recordVersion) return response(null, 'A0409') + let b + if (method === 'POST') { b = { bookingNo: `FB-${bookings.length}`, status: 'DRAFT', recordVersion: 0 }; bookings.push(b) } + else { b = bookings.find(b => p.endsWith(b.bookingNo)); b.recordVersion++ } + Object.assign(b, { cargo: body.cargo, agreedAmount: body.cargo.unitPrice * body.cargo.palletSpaces, quoteSource: q ? { quoteNo: q.quoteNo, recordVersion: q.recordVersion, quotedUnitPrice: q.details.looseDeliveryPrice } : null }) + trip.recordVersion++ + const result = { tripRecordVersion: trip.recordVersion, booking: b, replayed: false } + if (method === 'POST') replay.set(body.idempotencyKey, structuredClone(result)) + return response(result) + } } + return { context, calls } +} +test('independent draft fixture covers source version, date, currency, manual pricing without inventory or departure writes', async () => { + const h = harness(), proof = await verifyBookingQuotes(h.context, { warehouseId: '92', originCity: 'la', day: '2026-10-04' }, '2026-10-04T12:00:00Z') + assert.equal(proof.status, 'VERIFIED_DRAFT_SOURCE_LINKAGE'); assert.ok(proof.checks.every(c => c.passed)) + assert.ok(h.calls.every(c => !/inventory|\/depart$|\/assignment$|\/receipt-confirmation$/.test(c.path))) + await assert.rejects(verifyBookingQuotes(h.context, {}, ''), /no restart/) +}) +test('unexpected acceptance of stale confirmation cannot be reported as successful', async () => { + const h = harness('accepts-stale') + await assert.rejects(verifyBookingQuotes(h.context, { warehouseId: '92', originCity: 'la', day: '2026-10-04' }, '2026-10-04T12:00:00Z'), /A0409/) +}) diff --git a/scripts/fleet-booking-quotes-test-env.mjs b/scripts/fleet-booking-quotes-test-env.mjs new file mode 100644 index 000000000..4ab271d83 --- /dev/null +++ b/scripts/fleet-booking-quotes-test-env.mjs @@ -0,0 +1,117 @@ +import { readFile, mkdir, open } from 'node:fs/promises' +import { resolve, dirname } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { parseEnv } from 'node:util' +import { AcceptanceRun } from './admin-test-env-acceptance.mjs' +import { requireTestProfile, installQuoteJournal } from './fleet-quotes-test-env.mjs' +import { run as prepareCustomer } from './admin-test-env-modules/merchant-qualification-fixture.mjs' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const MARKER = 'FLEET-BOOKING-QUOTE-20261003-R2' +const TRIPS = '/api/platform/v1/last-leg/fleet/trips', QUOTES = '/api/platform/v1/last-leg/fleet/quotes' +export function warehouseQuoteContext(detail, planned) { + const w = detail?.warehouse + if (!w?.id || String(w.tenantId) !== '0' || w.status !== 'ACTIVE' || !['OVERSEAS', 'OVERSEAS_PARTNER'].includes(w.warehouseType) || !w.timezone) throw Error('Warehouse quote context unavailable') + const cities = [...new Set((detail.addresses || []).filter(a => String(a.warehouseId) === String(w.id) && String(a.tenantId) === '0' && a.city?.trim()).map(a => a.city.trim().toLowerCase()))] + if (cities.length !== 1) throw Error('Warehouse quote city ambiguous') + const offset = /^(?:UTC)?([+-])(\d{1,2})(?::(\d{2}))?$/.exec(w.timezone) + if (offset && (Number(offset[2]) > 18 || Number(offset[3] || 0) > 59 || Number(offset[2]) === 18 && Number(offset[3] || 0) !== 0)) throw Error('Warehouse offset invalid') + const day = offset ? new Date(Date.parse(planned) + (offset[1] === '-' ? -1 : 1) * (Number(offset[2]) * 60 + Number(offset[3] || 0)) * 60000).toISOString().slice(0, 10) + : new Intl.DateTimeFormat('en-CA', { timeZone: w.timezone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(planned)) + return { warehouseId: String(w.id), originCity: cities[0], timezone: w.timezone, day } +} +export async function verifyBookingQuotes(context, warehouse, planned) { + if (context.createdResources.bookingQuoteAcceptance || context.createdResources.merchantQualificationFixture?.marker !== context.marker) throw Error('Fresh own fixture required; no restart') + const proof = context.createdResources.bookingQuoteAcceptance = { marker: context.marker, status: 'CHECKING', warehouse, quotes: [], bookings: [], checks: [], confirmationSuccess: 'NOT_TESTED_REQUIRES_LOAD_PLAN', receiptAndDeparture: 'NOT_TESTED' } + const check = (name, pass) => { proof.checks.push({ name, passed: Boolean(pass) }); if (!pass) throw Error(name) } + const request = async (method, path, body, expected = '200') => { + const r = await context.request(method, path, body) + check(`${method} ${path.split('?')[0]} => ${expected}`, r.http === 200 && String(r.body?.code) === expected && r.requestId && r.traceId) + return r.body.data + } + const customerId = String(context.createdResources.merchantQualificationFixture.tenantId) + const destination = 'QA-BOOKING', stops = [{ name: '本轮报价测试起运仓' }, { name: destination, destinationKey: `FBA:${destination}` }] + let trip = (await request('POST', TRIPS, { warehouseId: warehouse.warehouseId, plannedDepartureAt: planned, expectedDriverEndAt: new Date(Date.parse(planned) + 3600000).toISOString(), stops, remark: context.marker, idempotencyKey: `${context.marker}-trip` })).trip + proof.tripNo = trip.tripNo + const path = `${TRIPS}/${trip.tripNo}/bookings` + const detail = { kind: 'SALE', partyId: customerId, originCity: warehouse.originCity, destinationCode: destination, distanceMiles: 10, currency: 'USD', selfPickupPrice: null, fullDeliveryPrice: 25, looseDeliveryPrice: 10, validFrom: warehouse.day, validTo: warehouse.day, remark: context.marker } + const createQuote = async (suffix, change) => { const quote = await request('POST', QUOTES, { idempotencyKey: `${context.marker}-${suffix}`, details: { ...detail, ...change } }); proof.quotes.push(quote.quoteNo); return quote } + const valid = await createQuote('valid', {}) + const expiredDay = new Date(Date.parse(`${warehouse.day}T00:00:00Z`) - 86400000).toISOString().slice(0, 10) + await createQuote('expired', { distanceMiles: 11, validFrom: expiredDay, validTo: expiredDay }) + const cny = await createQuote('cny', { distanceMiles: 12, currency: 'CNY', fullDeliveryPrice: 35, looseDeliveryPrice: null }) + const zero = await createQuote('zero', { distanceMiles: 13, fullDeliveryPrice: null, looseDeliveryPrice: 0 }) + const candidates = (currency, priceBasis) => request('GET', `${path}/quote-candidates?${new URLSearchParams({ customerId, destinationKey: `FBA:${destination}`, currency, priceBasis, page: '1', size: '100' })}`) + const pallet = await candidates('USD', 'PALLET'), order = await candidates('USD', 'ORDER'), cnyPallet = await candidates('CNY', 'PALLET'), cnyOrder = await candidates('CNY', 'ORDER') + check('USD pallet candidate excludes expired/currency mismatch but preserves explicit zero', pallet.records.length === 2 && pallet.records.some(q => q.quoteNo === zero.quoteNo && Number(q.quotedUnitPrice) === 0) && pallet.records.some(q => q.quoteNo === valid.quoteNo)) + check('ORDER selects full-delivery instead of loose-delivery price', order.records.length === 1 && order.records[0].quoteNo === valid.quoteNo && order.records[0].priceItem === 'FULL_DELIVERY' && Number(order.records[0].quotedUnitPrice) === 25) + check('Missing CNY pallet price is not zero', cnyPallet.records.length === 0 && cnyOrder.records.length === 1 && cnyOrder.records[0].quoteNo === cny.quoteNo) + check('Server date equals authoritative warehouse local planned day', pallet.records.every(q => q.effectiveOn === warehouse.day)) + const cargo = { destinationKey: `FBA:${destination}`, cargoName: '报价联动测试', packageCount: 2, palletSpaces: 2, weightKg: 10, volumeCbm: 1, priceBasis: 'PALLET', unitPrice: 7, currency: 'USD', contactName: null, contactPhone: null, remark: context.marker } + const reference = { quoteNo: valid.quoteNo, recordVersion: valid.recordVersion, priceItem: 'LOOSE_DELIVERY' } + const firstBody = { tripRecordVersion: trip.recordVersion, customerId, cargo, quoteRef: reference, idempotencyKey: `${context.marker}-booking` } + const first = await request('POST', path, firstBody); trip.recordVersion = first.tripRecordVersion; proof.bookings.push(first.booking.bookingNo) + check('Source price frozen separately from negotiated amount', Number(first.booking.quoteSource.quotedUnitPrice) === 10 && Number(first.booking.cargo.unitPrice) === 7 && Number(first.booking.agreedAmount) === 14 && String(first.booking.quoteSource.recordVersion) === String(valid.recordVersion)) + const manual = await request('POST', path, { tripRecordVersion: trip.recordVersion, customerId, cargo: { ...cargo, unitPrice: 0 }, quoteRef: null, idempotencyKey: `${context.marker}-manual` }); trip.recordVersion = manual.tripRecordVersion; proof.bookings.push(manual.booking.bookingNo) + check('Manual zero-price draft never gains a mandatory source', manual.booking.quoteSource == null && Number(manual.booking.agreedAmount) === 0) + const changed = await request('PUT', `${QUOTES}/${valid.quoteNo}`, { recordVersion: valid.recordVersion, details: { ...detail, looseDeliveryPrice: 12 } }) + const replay = await request('POST', path, firstBody) + check('Exact create replay retains original source snapshot after quote edit', replay.replayed === true && Number(replay.booking.quoteSource.quotedUnitPrice) === 10) + await request('POST', path, { ...firstBody, tripRecordVersion: trip.recordVersion, idempotencyKey: `${context.marker}-stale` }, 'A0409') + await request('POST', `${path}/${first.booking.bookingNo}/confirm`, { tripRecordVersion: trip.recordVersion, recordVersion: first.booking.recordVersion, idempotencyKey: `${context.marker}-stale-confirm` }, 'A0409') + const afterReject = await request('GET', path) + check('Stale source rejection does not mutate booking or parent version', String(afterReject.tripRecordVersion) === String(trip.recordVersion) && afterReject.records.find(b => b.bookingNo === first.booking.bookingNo)?.status === 'DRAFT') + const refreshed = await candidates('USD', 'PALLET'), current = refreshed.records.find(q => q.quoteNo === valid.quoteNo) + check('Explicit refresh exposes new source version and rate', String(current?.recordVersion) === String(changed.recordVersion) && Number(current?.quotedUnitPrice) === 12) + const updated = await request('PUT', `${path}/${first.booking.bookingNo}`, { tripRecordVersion: trip.recordVersion, recordVersion: first.booking.recordVersion, cargo, quoteRef: { ...reference, recordVersion: current.recordVersion } }); trip.recordVersion = updated.tripRecordVersion + check('Explicit reselection refreshes source while preserving negotiated price', Number(updated.booking.quoteSource.quotedUnitPrice) === 12 && Number(updated.booking.agreedAmount) === 14) + const later = new Date(Date.parse(planned) + 2 * 86400000).toISOString() + trip = (await request('PUT', `${TRIPS}/${trip.tripNo}/plan`, { recordVersion: trip.recordVersion, plannedDepartureAt: later, expectedDriverEndAt: new Date(Date.parse(later) + 3600000).toISOString(), stops, remark: context.marker, reason: '仅本轮报价有效日期验证' })).trip + check('Plan date outside validity removes candidates', (await candidates('USD', 'PALLET')).records.length === 0) + await request('POST', `${path}/${first.booking.bookingNo}/confirm`, { tripRecordVersion: trip.recordVersion, recordVersion: updated.booking.recordVersion, idempotencyKey: `${context.marker}-expired-confirm` }, 'A0409') + proof.finalBookings = await request('GET', path); proof.status = 'VERIFIED_DRAFT_SOURCE_LINKAGE'; proof.cleanup = 'RETAINED_OWN_PLANS_QUOTES_AND_DRAFTS_NO_RESOURCE_ASSIGNMENT' + return proof +} +export async function execute(args = process.argv.slice(2), execArgs = process.execArgv) { + const envFile = requireTestProfile(args, execArgs), env = parseEnv(await readFile(envFile, 'utf8')) + if (env.CHONGCHONG_ENV !== 'test' || !env.CHONGCHONG_ADMIN_USERNAME || !env.CHONGCHONG_ADMIN_PASSWORD) throw Error('TEST credentials missing') + const base = new URL(env.CHONGCHONG_ADMIN_API_BASE_URL) + if (!['http:', 'https:'].includes(base.protocol) || base.pathname.replace(/\/$/, '') !== '/api' || base.username || base.password || base.search || base.hash) throw Error('Invalid TEST API') + // R1 stopped before creating any business fixture because its offset parser rejected valid Java ZoneId UTC-8. + // Preserve that evidence. This corrected run has a distinct claim, marker and request-key namespace. + const directory = resolve(root, 'reports/fleet-booking-quotes-live-20261003-r2') + await mkdir(directory, { recursive: true }) + const context = new AcceptanceRun({ environment: 'test', gateway: base.origin, username: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD, customerName: `选价测试客户-${MARKER}`, quiet: true }) + context.marker = MARKER + const journal = await installQuoteJournal(context, directory, { secrets: [env.CHONGCHONG_ADMIN_PASSWORD, env.CHONGCHONG_ADMIN_USERNAME], persist: async (name, data) => { + const file = await open(resolve(directory, name), 'wx', 0o600) + try { await file.writeFile(JSON.stringify({ ...data, marker: MARKER, scope: 'BOOKING_QUOTE_ONLY', deployedCommit: '4ff55ba3e08c7fe0c6e04832fa95ebb680c76b8c' }, null, 2)); await file.sync() } finally { await file.close() } + } }) + let error = null, loggedOut = false + try { + const login = await context.request('POST', '/api/auth/login', { loginId: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD }, '') + if (login.http !== 200 || String(login.body?.code) !== '200' || !login.body.data?.accessToken || !login.body.data?.refreshToken) throw Error('Login failed') + context.token = login.body.data.accessToken; context.refreshToken = login.body.data.refreshToken + const options = await context.request('GET', '/api/platform/v1/last-leg/fleet/drivers/warehouse-options?region=OVERSEAS') + if (String(options.body?.code) !== '200' || !Array.isArray(options.body.data)) throw Error('Warehouse directory failed') + const planned = new Date(Date.now() + 86400000).toISOString(); let warehouse + for (const option of options.body.data.slice(0, 20)) { + const detail = await context.request('GET', `/api/platform/v1/warehouse-admin/${encodeURIComponent(option.id)}?tenantId=0`) + if (String(detail.body?.code) !== '200') throw Error('Warehouse details failed') + try { warehouse = warehouseQuoteContext(detail.body.data, planned); break } catch { /* Read next real active warehouse; never write missing master data. */ } + } + if (!warehouse) throw Error('No active warehouse has unambiguous city/timezone; no business fixtures created') + context.createdResources.quoteWarehouse = warehouse + await context.executeModule('merchant-qualification-fixture', () => prepareCustomer(context, { publishEnvironment: false })) + if (context.steps.some(s => !s.passed)) throw Error('Fresh customer fixture failed') + await verifyBookingQuotes(context, warehouse, planned) + } catch (cause) { error = cause.message; if (context.createdResources.bookingQuoteAcceptance) context.createdResources.bookingQuoteAcceptance.status = 'FAILED' } + finally { + try { if (context.token) { const r = await context.request('POST', '/api/auth/logout', { refreshToken: context.refreshToken }); loggedOut = r.http === 200 && String(r.body?.code) === '200' } } catch { error ||= 'Logout outcome unknown' } + context.token = ''; context.refreshToken = '' + } + const report = { success: !error && loggedOut && !journal.unknown(), error, loggedOut, createdResources: context.createdResources, steps: context.steps, ui: 'NOT_TESTED', operations: 'NOT_TESTED' } + await journal.checkpoint('final', report) + return { success: report.success, directory } +} +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) execute().then(r => { console.log(JSON.stringify(r)); process.exitCode = r.success ? 0 : 1 }).catch(() => { console.error('Booking quote run blocked; preserve checkpoints, no automatic rerun.'); process.exitCode = 1 }) diff --git a/scripts/fleet-company-cost-test-env.mjs b/scripts/fleet-company-cost-test-env.mjs new file mode 100644 index 000000000..434795a29 --- /dev/null +++ b/scripts/fleet-company-cost-test-env.mjs @@ -0,0 +1,102 @@ +import { readFile, mkdir, writeFile } from 'node:fs/promises' +import { parseEnv } from 'node:util' +import { resolve, dirname } from 'node:path' +import { pathToFileURL, fileURLToPath } from 'node:url' +import { AcceptanceRun } from './admin-test-env-acceptance.mjs' +import { run as verifyCompanyCost } from './admin-test-env-modules/fleet-company-cost-acceptance.mjs' + +export const MARKER = 'FLEET-COST-20261003' +// Production source anchor, not a deployment-health assertion. The coordinator must independently +// verify the deployed commit (including any descendant) and service readiness before passing this exact anchor. +// This gate intentionally does not infer ancestry, compare deployed source, or perform network checks. +export const READY_COMMIT = '8700fc34238bc33339351c73c15f3af8de8ce517' +export function requireReady(args, execArgs) { + if (!args.includes(`--ready-commit=${READY_COMMIT}`)) throw new Error('等待总控确认目标服务就绪后,才可显式传入本轮ready-commit') + return requireTestProfile(execArgs) +} +export function requireTestProfile(execArgs) { + const joined = execArgs.find(value => value.startsWith('--env-file=')) + const index = execArgs.indexOf('--env-file') + const file = joined?.slice('--env-file='.length) || (index >= 0 ? execArgs[index + 1] : '') + if (!file || !resolve(file).replaceAll('\\', '/').endsWith('/.codex/env/chongchong-global/test-acceptance.env')) + throw new Error('须在脚本前显式指定授权目录的test-acceptance.env,禁止复用其他环境或任务会话文件') + return resolve(file) +} + +/** Explicit single-task runner. It does not expand other acceptance modules or reuse their resources/sessions. */ +export async function execute(args = process.argv.slice(2), execArgs = process.execArgv) { + const preflight = args.includes('--preflight') + const envFile = preflight ? requireTestProfile(execArgs) : requireReady(args, execArgs) + const env = parseEnv(await readFile(envFile, 'utf8')) + if (env.CHONGCHONG_ENV !== 'test' || !env.CHONGCHONG_ADMIN_USERNAME || !env.CHONGCHONG_ADMIN_PASSWORD) throw new Error('TEST身份配置缺失') + const base = new URL(env.CHONGCHONG_ADMIN_API_BASE_URL) + if (!['http:', 'https:'].includes(base.protocol) || base.pathname.replace(/\/$/, '') !== '/api' || base.username || base.password || base.search || base.hash) + throw new Error('TEST统一API基础地址格式与当前验收合同不符') + const repository = resolve(dirname(fileURLToPath(import.meta.url)), '..') + if (!repository.replaceAll('\\', '/').endsWith('/chongchong-global/baiyue-firstleg')) throw new Error('公司成本验收只允许在登记的TEST项目运行') + const output = resolve(repository, `reports/fleet-cost-live-20261003/${preflight ? `preflight-${new Date().toISOString().replace(/\D/g, '').slice(0,17)}` : 'api-report'}.json`) + await mkdir(dirname(output), { recursive: true }) + const context = new AcceptanceRun({ gateway: base.origin, quiet: true, modules: [] }) + context.marker = MARKER + const report = { marker: MARKER, mode: preflight ? 'READ_ONLY_PREFLIGHT' : 'BUSINESS_ACCEPTANCE', expectedCommit: READY_COMMIT, status: 'PREPARING', startedAt: new Date().toISOString(), session: { login: 'NOT_STARTED', logout: 'NOT_STARTED' }, createdResources: context.createdResources, steps: context.steps } + let refresh = '', failure + const secrets = [env.CHONGCHONG_ADMIN_PASSWORD] + async function checkpoint(first = false) { + const value = JSON.stringify(report, null, 2) + if (secrets.some(secret => secret && secret.length >= 8 && value.includes(secret))) throw new Error('报告敏感信息检查失败,停止输出') + await writeFile(output, `${value}\n`, { encoding: 'utf8', flag: first ? 'wx' : 'w' }) + } + await checkpoint(true) // Refuse automatic repeat runs over any prior evidence. + context.checkpoint = checkpoint + try { + const response = await context.request('POST', '/api/auth/login', { loginId: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD }, '') + report.session.login = response?.http === 200 && String(response?.body?.code) === '200' ? 'SUCCESS' : 'FAILED' + report.session.loginDiagnostic = { http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId } + if (report.session.login !== 'SUCCESS' || !response.body.data?.accessToken || !response.body.data?.refreshToken) throw new Error('独立TEST登录未成功,未执行业务写入') + context.token = response.body.data.accessToken; refresh = response.body.data.refreshToken + secrets.push(context.token, refresh) + report.status = 'RUNNING'; await checkpoint() + if (preflight) { + const checks = [ + ['permissions', '/api/platform/v1/iam/authorization/permissions'], + ['suppliers', '/api/platform/v1/suppliers?page=1&size=1&status=ACTIVE'], + ['exchange', `/api/platform/v1/last-leg/master-data/exchange-rates/${new Date().toISOString().slice(0,7)}`], + ] + report.preflight = {} + for (const [name, path] of checks) { + const checked = await context.request('GET', path) + report.preflight[name] = { http: checked?.http, code: checked?.body?.code, requestId: checked?.requestId, traceId: checked?.traceId } + if (checked?.http !== 200 || String(checked?.body?.code) !== '200') throw new Error(`只读预检未通过:${name}`) + if (name === 'permissions') { + const required = ['platform:supplier:create', 'platform:supplier:list', 'platform:overseas-master-data:vehicle:list', + 'platform:overseas-master-data:vehicle:create', 'platform:overseas-master-data:vehicle:status', + 'platform:overseas-master-data:vehicle:record-create', 'platform:overseas-master-data:vehicle:record-edit', + 'platform:overseas-master-data:vehicle:record-finance-submit', 'platform:finance:customer-arap:list', + ...['list','approve','void','pay','upload'].map(action => `platform:finance:vehicle-cost:${action}`)] + if (!Array.isArray(checked.body.data)) throw new Error('权限快照格式不匹配') + report.preflight.missingPermissions = required.filter(code => !checked.body.data.includes(code)) + if (report.preflight.missingPermissions.length) throw new Error('公司成本验收所需权限缺失') + } + if (name === 'exchange') report.preflight.exchange.active = checked.body.data?.status === 'ACTIVE' + await checkpoint() + } + } else await verifyCompanyCost(context) + report.status = 'VERIFIED' + } catch (error) { failure = error; report.status = 'FAILED'; report.error = error instanceof Error ? error.message : String(error) } + finally { + if (refresh) { + const response = await context.request('POST', '/api/auth/logout', { refreshToken: refresh }) + report.session.logout = response?.http === 200 && String(response?.body?.code) === '200' ? 'SUCCESS' : 'FAILED' + report.session.logoutDiagnostic = { http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId } + if (report.session.logout !== 'SUCCESS') { report.status = 'FAILED'; failure ??= new Error('本任务独立TEST会话退出未确认') } + } + context.token = ''; context.refreshToken = ''; refresh = '' + report.finishedAt = new Date().toISOString() + await checkpoint() + } + return { output, status: report.status, failed: Boolean(failure), error: failure ? '验收失败,按报告中的原请求核对;未自动重新造数' : null } +} +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + execute().then(result => { console.log(JSON.stringify(result)); process.exitCode = result.failed ? 1 : 0 }) + .catch(() => { console.error('公司成本验收未启动或报告保存失败;请核对就绪标记、TEST配置及既有报告,未输出凭据'); process.exitCode = 1 }) +} diff --git a/scripts/fleet-cross-customer-test-env.mjs b/scripts/fleet-cross-customer-test-env.mjs new file mode 100644 index 000000000..809bd6654 --- /dev/null +++ b/scripts/fleet-cross-customer-test-env.mjs @@ -0,0 +1,154 @@ +import { mkdirSync, writeFileSync, openSync, closeSync, unlinkSync, readFileSync } from 'node:fs' +import { execFileSync } from 'node:child_process' +import { dirname, resolve, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { createHash } from 'node:crypto' +import { AcceptanceRun, parseArguments, validateExecutionOptions, buildAcceptanceSummary } from './admin-test-env-acceptance.mjs' +import { createTestEvidenceReaders } from './fba-core-regression-evidence.mjs' +import { redactEvidence } from './fba-core-regression-test-env.mjs' +import { resolvePickerAccess, createPicker, disablePicker } from './admin-test-env-modules/phase4-overseas-pda.mjs' +import { verifyFleetCrossCustomer } from './admin-test-env-modules/fleet-cross-customer-acceptance.mjs' +import { finishFleetTrip } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const ok = response => response?.http === 200 && String(response.body?.code) === '200' +export function parseCrossCustomerArguments(args) { + const config = { execute: false, environment: 'TEST', runId: 'plan', warehouseId: '', destinationWarehouseId: '' } + const seen = new Set() + for (let i = 0; i < args.length; i++) { + const arg = args[i] + if (['--execute', '--plan'].includes(arg)) { + if (seen.has('action')) throw Error('Duplicate execution mode') + seen.add('action'); config.execute = arg === '--execute'; continue + } + const field = { '--environment': 'environment', '--run-id': 'runId', '--warehouse-id': 'warehouseId', '--destination-warehouse-id': 'destinationWarehouseId' }[arg] + if (!field || seen.has(arg) || !args[i + 1] || args[i + 1].startsWith('--')) throw Error('Unsupported or duplicate argument') + seen.add(arg); config[field] = args[++i] + } + if (config.environment !== 'TEST' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId) + || (config.warehouseId && !/^[1-9][0-9]{0,19}$/.test(config.warehouseId)) + || (config.destinationWarehouseId && !/^[1-9][0-9]{0,19}$/.test(config.destinationWarehouseId)) + || (config.execute && (!seen.has('--environment') || config.runId === 'plan' || !config.warehouseId || !config.destinationWarehouseId))) throw Error('Explicit TEST, fresh run-id and warehouse/destination IDs required') + return config +} +export function crossCustomerPlan(config) { + return { environment: 'TEST', execute: config.execute, runId: config.runId, warehouseId: config.warehouseId || null, destinationWarehouseId: config.destinationWarehouseId || null, + output: `reports/fleet-cross-${config.runId}`, stages: ['two new customers', 'two ordinary transfer receipts', 'two dispatches on one trip', + 'unready customer gate rejection', 'shared departure and replay', 'independent receipts', 'trip completion and separate resource release'], + limits: 'Prerequisite rejection is not mid-transaction rollback. No deployment, configuration change, SQL writes or automatic rerun. Retain business samples; disable temporary PDA in finally.' } +} + +export async function runCrossCustomer(config, { env = process.env, outputRoot = ROOT, readers, prepare, verify = verifyFleetCrossCustomer, + finish = finishFleetTrip, contextFactory = options => new AcceptanceRun(options), pickerAccess = resolvePickerAccess, pickerCreate = createPicker, pickerDisable = disablePicker } = {}) { + const plan = crossCustomerPlan(config) + if (!config.execute) return plan + if (config.environment !== 'TEST' || env.CHONGCHONG_ENV !== 'test' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId) + || config.runId === 'plan' || !/^[1-9][0-9]{0,19}$/.test(config.warehouseId) || !/^[1-9][0-9]{0,19}$/.test(config.destinationWarehouseId) + || !env.CHONGCHONG_ADMIN_USERNAME || !env.CHONGCHONG_ADMIN_PASSWORD) throw Error('Explicit TEST configuration required') + const base = new URL(env.CHONGCHONG_ADMIN_API_BASE_URL) + if (!['http:', 'https:'].includes(base.protocol) || base.username || base.password || base.search || base.hash || !['/api', '/api/'].includes(base.pathname)) throw Error('Invalid TEST gateway') + readers ||= createTestEvidenceReaders(env) + // Load the reviewed producer before creating any resources. + prepare ||= (await import('./admin-test-env-modules/fleet-cross-customer-fixture.mjs')).createFleetCrossCustomerFixture + if (typeof prepare !== 'function' || typeof readers.readFleetInventory !== 'function') throw Error('Fixture producer and inventory reader required') + const reports = resolve(outputRoot, 'reports'), output = join(reports, `fleet-cross-${config.runId}`) + mkdirSync(reports, { recursive: true }) + const lockPath = join(reports, '.fleet-acceptance.lock'), lock = openSync(lockPath, 'wx') + let context, picker, createdPicker, initialized = false, report, evidenceIndex = 0, requestIndex = 0 + const sessions = new Map() + const secrets = Object.entries(env).filter(([name]) => /PASSWORD|TOKEN/.test(name)).map(([, value]) => value) + const save = (name, value) => writeFileSync(join(output, name), JSON.stringify(redactEvidence(value, secrets), null, 2) + '\n') + try { + writeFileSync(lock, JSON.stringify({ pid: process.pid, runId: config.runId })) + mkdirSync(output); initialized = true; mkdirSync(join(output, 'evidence')); mkdirSync(join(output, 'requests')) + const sources = ['scripts/fleet-cross-customer-test-env.mjs', 'scripts/fleet-inventory-evidence.mjs', 'scripts/fba-core-regression-evidence.mjs', + 'scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs', 'scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs', + 'scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs'].map(path => ({ path, sha256: createHash('sha256').update(readFileSync(join(ROOT, path))).digest('hex') })) + save('manifest.json', { ...plan, sourceCommit: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: ROOT, encoding: 'utf8' }).trim(), sources }) + await readers.preflight() + const options = validateExecutionOptions({ ...parseArguments(['--modules', 'merchant-qualification-fixture', '--quiet', 'true']), + environment: 'test', gateway: base.origin, username: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD, + markerNamespace: createHash('sha256').update(config.runId).digest('hex').slice(0, 12) }) + context = contextFactory(options) + for (const method of ['request', 'requestWithBody']) { + const original = context[method] + context[method] = async function (...args) { + const number = ++requestIndex, [verb, path, body] = args + const record = { number, method: verb, path, stage: 'INTENT', body: body instanceof FormData ? '[multipart file]' : body } + save(`requests/${number}.json`, record) + const response = await original.apply(this, args) + if (verb === 'POST' && path === '/api/platform/v1/iam/users' && ok(response) + && String(body?.overseasWarehouseId) === config.warehouseId && body?.username?.startsWith('pda_overseas_')) { + createdPicker = { membershipId: response.body?.data?.membershipId } + } + if (path === '/api/auth/login' && ok(response) && response.body?.data?.accessToken) { + const tokens = response.body.data + sessions.set(tokens.accessToken, tokens.refreshToken) + secrets.push(tokens.accessToken, tokens.refreshToken) + } + save(`requests/${number}.json`, { ...record, stage: 'RESPONSE', response }) + save('progress.json', { marker: context.marker, steps: context.steps, createdResources: context.createdResources }) + return response + } + } + const login = await context.request('POST', '/api/auth/login', { loginId: options.username, password: options.password }, '') + if (!ok(login) || !login.body.data?.accessToken) throw Error('TEST administrator login failed') + context.token = login.body.data.accessToken; context.refreshToken = login.body.data.refreshToken + const warehouses = await context.request('GET', '/api/platform/v1/last-leg/fleet/drivers/warehouse-options?region=OVERSEAS') + if (!ok(warehouses) || !warehouses.body.data?.some(row => String(row.id) === config.warehouseId)) throw Error('Requested warehouse not available') + const access = await pickerAccess(context) + if (!access) throw Error('PDA role unavailable') + picker = await pickerCreate(context, 81, config.warehouseId, access) + if (!picker?.token) throw Error('Temporary PDA login failed') + secrets.push(picker.password, picker.token, picker.refreshToken) + const readInventory = async scope => { + const result = await readers.readFleetInventory(scope) + save(`evidence/inventory-${++evidenceIndex}.json`, result); return result + } + const fixture = await prepare(context, { execute: true, warehouseId: config.warehouseId, destinationWarehouseId: config.destinationWarehouseId, pdaToken: picker.token, readInventory }) + save('fixture.json', fixture) + const verification = await verify(context, fixture, { execute: true, readInventory }) + if (verification?.status !== 'VERIFIED' || verification.tripNo !== fixture.tripNo) throw Error('Current fixture verification incomplete') + const trip = context.createdResources.fleetTrips?.find(row => row.tripNo === fixture.tripNo) + if (!trip) throw Error('Original fixture trip missing for resource completion') + const beforeClosure = [] + for (const member of fixture.members) beforeClosure.push(await readInventory({ marker: context.marker, customerId: member.customerId, warehouseId: config.warehouseId })) + await finish(context, 'fleet-cross-customer-closure', trip) + const afterClosure = [] + for (const member of fixture.members) afterClosure.push(await readInventory({ marker: context.marker, customerId: member.customerId, warehouseId: config.warehouseId })) + if (JSON.stringify(beforeClosure) !== JSON.stringify(afterClosure)) throw Error('Trip closure changed customer inventory') + report = { success: verification.status === 'VERIFIED' + && trip.closure?.status === 'CARGO_COMPLETED_AND_RESOURCES_RELEASED', closureInventoryUnchanged: true } + } catch (error) { + report = { success: false, error: error instanceof Error ? error.message : 'CROSS_CUSTOMER_FAILED' } + } finally { + if (context && (picker?.membershipId || createdPicker?.membershipId)) { + try { await pickerDisable(context, picker?.membershipId ? picker : createdPicker, 81) } catch { report.success = false; report.cleanupError = 'PDA disable failed' } + } + if (context) { + let sessionsClosed = true + for (const [accessToken, refreshToken] of sessions) { + const response = await context.request('POST', '/api/auth/logout', { refreshToken }, accessToken) + if (!ok(response) && response.http !== 401 && String(response.body?.code) !== 'A0401') sessionsClosed = false + } + report.sessionsClosed = sessionsClosed + report.summary = buildAcceptanceSummary(context.steps, context.modules, context.coverageGaps) + report.success &&= sessionsClosed && report.summary.failed === 0 + report.marker = context.marker; report.createdResources = context.createdResources; report.steps = context.steps + } + report ||= { success: false } + report.requestCount = requestIndex; report.inventorySnapshots = evidenceIndex; report.goalComplete = false + try { if (initialized) save('report.json', report) } finally { closeSync(lock); unlinkSync(lockPath) } + } + return report +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { + const config = parseCrossCustomerArguments(process.argv.slice(2)) + const result = await runCrossCustomer(config) + console.log(JSON.stringify(config.execute ? { success: result.success, error: result.error, summary: result.summary, + requestCount: result.requestCount, inventorySnapshots: result.inventorySnapshots, output: crossCustomerPlan(config).output } : result, null, 2)) + if (config.execute && !result.success) process.exitCode = 1 + } catch (error) { console.error(error.message); process.exitCode = 1 } +} diff --git a/scripts/fleet-cross-customer-test-env.test.mjs b/scripts/fleet-cross-customer-test-env.test.mjs new file mode 100644 index 000000000..660bc30ba --- /dev/null +++ b/scripts/fleet-cross-customer-test-env.test.mjs @@ -0,0 +1,101 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, existsSync, readdirSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, dirname, basename, resolve } from 'node:path' +import { parseCrossCustomerArguments, runCrossCustomer } from './fleet-cross-customer-test-env.mjs' + +const config = () => parseCrossCustomerArguments(['--execute', '--environment', 'TEST', '--run-id', 'offline-one', '--warehouse-id', '900', '--destination-warehouse-id', '901']) +const env = { CHONGCHONG_ENV: 'test', CHONGCHONG_ADMIN_API_BASE_URL: 'https://test.example.invalid/api', CHONGCHONG_ADMIN_USERNAME: 'fixture-admin', CHONGCHONG_ADMIN_PASSWORD: 'private-admin-password' } +function harness(root, fail = false) { + const calls = [], cleanup = [] + const context = { marker: 'CROSS-OFFLINE', steps: [], modules: [], coverageGaps: [], context: {}, createdResources: {}, + async request(method, path) { + calls.push({ method, path }) + const data = path === '/api/auth/login' ? { accessToken: 'private-access', refreshToken: 'private-refresh' } + : path.includes('warehouse-options') ? [{ id: '900' }] : {} + return { http: 200, body: { code: '200', data } } + }, async requestWithBody() { throw Error('Unexpected multipart') } } + return { calls, cleanup, context, options: { env, outputRoot: root, contextFactory: () => context, + readers: { preflight: async () => {}, readFleetInventory: async scope => ({ scope, balances: [], positions: [], ledger: [] }) }, + pickerAccess: async () => ({}), pickerCreate: async () => ({ membershipId: '81', token: 'private-pda', password: 'private-pda-password' }), + pickerDisable: async (_context, picker) => { cleanup.push(picker.membershipId) }, + prepare: async (_context, options) => { + assert.equal(options.pdaToken, 'private-pda') + await context.request('POST', '/api/test-owned-command', { idempotencyKey: 'stable-command', password: 'private-pda-password' }) + if (fail) throw Error('FIXTURE_FAILED') + return { pdaToken: options.pdaToken, marker: context.marker, tripNo: 'TR-OFFLINE', members: [{ customerId: '42' }, { customerId: '43' }] } + }, verify: async (_context, _fixture, options) => { + await options.readInventory({ marker: context.marker, customerId: '42', warehouseId: '900' }) + context.createdResources.fleetCrossCustomer = { status: 'VERIFIED', tripNo: 'TR-OFFLINE' } + context.createdResources.fleetTrips = [{ tripNo: 'TR-OFFLINE' }] + return context.createdResources.fleetCrossCustomer + }, + finish: async (_context, _module, trip) => { trip.closure = { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' } }, + } } +} +test('plan is network-free and execution requires explicit TEST, unique run and warehouse', async () => { + const plan = await runCrossCustomer(parseCrossCustomerArguments([]), { env: {}, prepare: () => { throw Error('must not execute') } }) + assert.equal(plan.execute, false) + for (const args of [['--execute'], ['--execute', '--environment', 'PROD', '--run-id', 'one', '--warehouse-id', '900'], ['--run-id', '../outside'], ['--plan', '--execute'], ['--warehouse-id', '1 OR 1=1']]) assert.throws(() => parseCrossCustomerArguments(args)) + await assert.rejects(runCrossCustomer(config(), { env: { ...env, CHONGCHONG_ENV: 'prod' } }), /TEST/) +}) + +for (const result of [undefined,{status:'FAILED',tripNo:'TR-OFFLINE'},{status:'VERIFIED',tripNo:'TR-OTHER'}]) test('unverified or foreign result cannot close fixture resources',async()=>{ + const root=mkdtempSync(join(tmpdir(),'fleet-cross-contract-')) + try { + const h=harness(root);let finished=false + h.options.verify=async()=>result;h.options.finish=async()=>{finished=true} + const report=await runCrossCustomer(config(),h.options) + assert.equal(report.success,false);assert.equal(finished,false);assert.deepEqual(h.cleanup,['81']) + }finally{assert.equal(dirname(resolve(root)),resolve(tmpdir()));assert.ok(basename(root).startsWith('fleet-cross-contract-'));rmSync(root,{recursive:true,force:true})} +}) +for (const fail of [false, true]) test(`execution ${fail ? 'failure' : 'success'} preserves evidence, closes session and disables only its operator`, async () => { + const root = mkdtempSync(join(tmpdir(), 'fleet-cross-contract-')) + try { + const h = harness(root, fail), report = await runCrossCustomer(config(), h.options) + assert.equal(report.success, !fail) + assert.deepEqual(h.cleanup, ['81']) + assert.equal(h.calls.filter(c => c.path === '/api/test-owned-command').length, 1) + assert.equal(h.calls.filter(c => c.path === '/api/auth/logout').length, 1) + assert.equal(report.sessionsClosed, true) + assert.equal(existsSync(join(root, 'reports/.fleet-acceptance.lock')), false) + const folder = join(root, 'reports/fleet-cross-offline-one') + for (const file of readdirSync(join(folder, 'requests'))) { + const saved = readFileSync(join(folder, 'requests', file), 'utf8') + assert.doesNotMatch(saved, /private-admin-password|private-access|private-refresh|private-pda/) + } + const originalReport = readFileSync(join(folder, 'report.json'), 'utf8') + const replay = await runCrossCustomer(config(), h.options) + assert.equal(replay.success, false) + assert.equal(readFileSync(join(folder, 'report.json'), 'utf8'), originalReport) + assert.equal(h.calls.filter(c => c.path === '/api/test-owned-command').length, 1) + } finally { + assert.equal(dirname(resolve(root)), resolve(tmpdir())) + assert.ok(basename(root).startsWith('fleet-cross-contract-')) + rmSync(root, { recursive: true, force: true }) + } +}) + +test('operator creation followed by login failure still disables the newly created membership', async () => { + const root = mkdtempSync(join(tmpdir(), 'fleet-cross-contract-')) + try { + const h = harness(root), original = h.context.request + h.context.request = async function (...args) { + if (args[1] === '/api/platform/v1/iam/users') return { http: 200, body: { code: '200', data: { membershipId: '82' } } } + return original.apply(this, args) + } + h.options.pickerCreate = async context => { + await context.request('POST', '/api/platform/v1/iam/users', { username: 'pda_overseas_current_81', overseasWarehouseId: '900', password: 'private-pda-password' }) + throw Error('PDA_LOGIN_FAILED') + } + const report = await runCrossCustomer(config(), h.options) + assert.equal(report.success, false) + assert.deepEqual(h.cleanup, ['82']) + assert.equal(h.calls.some(row => row.path === '/api/test-owned-command'), false) + } finally { + assert.equal(dirname(resolve(root)), resolve(tmpdir())) + assert.ok(basename(root).startsWith('fleet-cross-contract-')) + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/scripts/fleet-inventory-evidence.mjs b/scripts/fleet-inventory-evidence.mjs new file mode 100644 index 000000000..4c4bc2741 --- /dev/null +++ b/scripts/fleet-inventory-evidence.mjs @@ -0,0 +1,53 @@ +// Internal read-only snapshot for two-customer fleet acceptance. Import has no side effects. +const collections = { + balances: { table: 'll_inventory_balance', fields: 'id customerId customerName overseasWarehouseId overseasWarehouseCode overseasWarehouseName destinationWarehouseId destinationWarehouseCode destinationWarehouseName inboundOrderId inboundOrderNo inboundCode skuCode productNameZh productNameEn availablePackages holdPackages allocatedPackages pickedPackages inTransitPackages receivingPackages procurementPackages version createdAt updatedAt' }, + positions: { table: 'll_inventory_position', fields: 'id customerId balanceId overseasWarehouseId destinationWarehouseCode palletId palletNo batchNo warehouseLocationId warehouseLocationCode availablePackages holdPackages allocatedPackages pickedPackages receivingPackages status version createdAt updatedAt' }, + ledger: { table: 'll_inventory_ledger', fields: 'id customerId customerName balanceId positionId overseasWarehouseId destinationWarehouseCode inboundOrderId palletId palletNo batchNo warehouseLocationCode skuCode cartonMark cartonPackages businessType businessOrderId businessOrderNo inboundCode inboundOrderNo outboundOrderNo sourceEventId idempotencyKey statusFrom statusTo beforePackages deltaPackages afterPackages operatorId remark occurredAt createdAt' }, +} +const snake = value => value.replace(/[A-Z]/g, letter => '_' + letter.toLowerCase()) +function validate(scope) { + if (!scope || !/^[A-Za-z0-9_-]{1,100}$/.test(scope.marker || '') + || ['customerId', 'warehouseId'].some(key => typeof scope[key] !== 'string' || !/^[1-9][0-9]{0,19}$/.test(scope[key]))) { + throw Error('Invalid fleet inventory scope') + } +} +const literal = value => `CONVERT(0x${Buffer.from(value, 'utf8').toString('hex')} USING utf8mb4) COLLATE utf8mb4_bin` + +export function buildFleetInventorySql(scope) { + validate(scope) + const where = `BINARY customer_id=BINARY (${literal(scope.customerId)}) AND BINARY overseas_warehouse_id=BINARY (${literal(scope.warehouseId)})` + const queries = [] + for (const [collection, { table, fields }] of Object.entries(collections)) { + const json = fields.split(' ').map(field => `'${field}',CAST(${snake(field)} AS CHAR)`).join(',') + queries.push(`SELECT JSON_OBJECT('collection','${collection}','row',JSON_OBJECT(${json})) FROM ${table} WHERE ${where} ORDER BY id;`) + queries.push(`SELECT JSON_OBJECT('collection','${collection}','count',CAST(COUNT(*) AS CHAR)) FROM ${table} WHERE ${where};`) + } + return ['SET NAMES utf8mb4 COLLATE utf8mb4_bin;', 'SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;', + 'START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;', ...queries, 'COMMIT;'].join('\n') +} + +export function fleetInventoryFromRows(scope, rows) { + validate(scope) + if (!Array.isArray(rows)) throw Error('Missing fleet inventory snapshot') + const result = { scope: { marker: scope.marker, customerId: scope.customerId, warehouseId: scope.warehouseId, coverage: 'CUSTOMER_WAREHOUSE_COMPLETE' }, balances: [], positions: [], ledger: [] } + const counts = new Map() + for (const entry of rows) { + const definition = collections[entry?.collection] + if (!definition) throw Error('Unexpected fleet inventory collection') + if (Object.hasOwn(entry, 'count')) { + if (counts.has(entry.collection) || entry.row || !/^(0|[1-9][0-9]*)$/.test(String(entry.count))) throw Error('Invalid fleet inventory count') + counts.set(entry.collection, BigInt(entry.count)); continue + } + const row = entry.row + if (!row || definition.fields.split(' ').some(field => !Object.hasOwn(row, field)) + || typeof row.id !== 'string' || !/^[1-9][0-9]*$/.test(row.id) + || row.customerId !== scope.customerId || row.overseasWarehouseId !== scope.warehouseId) throw Error('Incomplete or foreign fleet inventory row') + result[entry.collection].push(row) + } + for (const name of Object.keys(collections)) { + if (counts.get(name) !== BigInt(result[name].length) || new Set(result[name].map(row => row.id)).size !== result[name].length) { + throw Error('Truncated or duplicated fleet inventory snapshot') + } + } + return result +} diff --git a/scripts/fleet-inventory-evidence.test.mjs b/scripts/fleet-inventory-evidence.test.mjs new file mode 100644 index 000000000..59722f667 --- /dev/null +++ b/scripts/fleet-inventory-evidence.test.mjs @@ -0,0 +1,32 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { buildFleetInventorySql, fleetInventoryFromRows } from './fleet-inventory-evidence.mjs' +import { assertReadOnlySql, createTestEvidenceReaders } from './fba-core-regression-evidence.mjs' + +const scope = { marker: 'CROSS-TEST-1', customerId: '2106128532165988353', warehouseId: '193000000000200003' } +const empty = () => ['balances', 'positions', 'ledger'].map(collection => ({ collection, count: '0' })) +test('customer/warehouse snapshot includes zero rows and all ledger kinds in a single read-only transaction', () => { + const sql = assertReadOnlySql(buildFleetInventorySql(scope)) + assert.equal((sql.match(/SELECT JSON_OBJECT/g) || []).length, 6) + assert.doesNotMatch(sql, /\bLIMIT\b|available_packages\s*>|business_type\s*=/) + assert.match(sql, /'deltaPackages',CAST\(delta_packages AS CHAR\)/) + assert.match(sql, /'version',CAST\(version AS CHAR\)/) + assert.equal((sql.match(/BINARY customer_id=/g) || []).length, 6) + assert.equal((sql.match(/BINARY overseas_warehouse_id=/g) || []).length, 6) + assert.equal(fleetInventoryFromRows(scope, empty()).scope.coverage, 'CUSTOMER_WAREHOUSE_COMPLETE') +}) +test('rejects missing count, truncation, duplicate count, foreign or incomplete rows', () => { + for (const rows of [[], empty().slice(1), [...empty(), empty()[0]], [...empty(), { collection: 'balances', row: { id: '1', customerId: 'wrong' } }], [{ collection: 'balances', count: '1' }, ...empty().slice(1)]]) { + assert.throws(() => fleetInventoryFromRows(scope, rows)) + } + for (const changed of [{ customerId: '1 OR 1=1' }, { warehouseId: 193000000000200003 }, { marker: "';DROP" }]) assert.throws(() => buildFleetInventorySql({ ...scope, ...changed })) +}) +test('transport integration uses the existing secret-safe TEST reader', async () => { + let requests = 0 + const reader = createTestEvidenceReaders({ JENKINS_URL: 'https://ci.example.invalid/', JENKINS_USER: 'test', JENKINS_API_TOKEN: 'secret', FBA_CORE_JENKINS_AGENT: 'java-test-agent' }, async (_url, options) => { + requests++; assert.equal(options.method, 'POST') + return { ok: true, text: async () => 'FBA_CORE_JSON=' + JSON.stringify({ ok: true, rows: empty() }) } + }) + assert.deepEqual((await reader.readFleetInventory(scope)).balances, []) + assert.equal(requests, 1) +}) diff --git a/scripts/fleet-operations-test-env-contract.test.mjs b/scripts/fleet-operations-test-env-contract.test.mjs new file mode 100644 index 000000000..55f3942ad --- /dev/null +++ b/scripts/fleet-operations-test-env-contract.test.mjs @@ -0,0 +1,81 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { operationsFixtureFromHandover } from './admin-test-env-modules/fleet-quotes-operations.mjs' +import { parseOperationsArguments, restrictOperationsTransport, runOperationsSession } from './fleet-operations-test-env.mjs' +import { AcceptanceRun } from './admin-test-env-acceptance.mjs' + +function sample() { + const marker = 'CURRENT', tripNo = 'TR-CURRENT', departedAt = '2026-10-03T01:00:00Z' + return { marker, runId: 'current-run', tripNo, warehouseId: '92', status: 'DEPART_AND_HANDOVER_VERIFIED', handoverNo: 'HO-CURRENT', + departure: { tripNo, status: 'IN_TRANSIT', departedAt, shippedPackages: 14 }, + immutableDeparture: { action: 'DEPART', recordVersion: '5', snapshot: { + trip: { tripNo, warehouseId: '92', departedAt, remark: marker, stops: [{ name: 'origin' }, { destinationKey: 'FBA:A' }, { destinationKey: 'FBA:B' }] }, confirmedLoad: { manifestHash: 'original-load', + pallets: [{ warehousePalletId: '1', palletSpaces: 1, deliveryIndex: 1 }, { warehousePalletId: '2', palletSpaces: 1, deliveryIndex: 1 }], + cargo: [{ dispatchNo: 'DP-OWN', dispatchItemId: '1', customerId: '9', warehousePalletId: '1', packages: 10, destinationKey: 'FBA:A' }, { dispatchNo: 'DP-EXT', dispatchItemId: '2', customerId: '9', warehousePalletId: '2', packages: 4, destinationKey: 'FBA:A' }] }, + externalBookings: [{ bookingNo: 'FB-EXT', status: 'BOUND', dispatchNo: 'DP-EXT', customerId: '9', agreedAmount: '30.00', cargo: { currency: 'CNY' }, receipt: { source: { receivedPackages: 4, pallets: [{ warehousePalletId: '2', packages: 4 }] } } }] } }, + costs: { marker, tripNo, status: 'VERIFIED', expected: { finalCny: '100.03', usd: '13.07', allocationMethod: 'SAME_ROUTE_PALLET_SPACES', allocationStatus: 'AVAILABLE' }, shares: [ + { businessNo: 'DP-OWN', sourceType: 'OWN', dispatchNo: 'DP-OWN', palletSpaces: 1, warehousePalletIds: ['1'] }, + { businessNo: 'FB-EXT', sourceType: 'EXTERNAL', dispatchNo: 'DP-EXT', palletSpaces: 1, warehousePalletIds: ['2'] }] } } +} +function convert(value, overrides = {}) { + const bytes = Buffer.from(JSON.stringify(value)) + return operationsFixtureFromHandover(bytes, { sha256: createHash('sha256').update(bytes).digest('hex'), marker: 'CURRENT', runId: 'current-run', ...overrides }) +} +test('adapts actual C artifact with independent physical cargo and exact cent currency shares', () => { + const f = convert(sample()) + assert.deepEqual(f.expected, { physicalPallets: 2, palletSpaces: 2, ownPackages: 10, externalPackages: 4, + externalAgreedAmounts: [{ currency: 'CNY', amount: 30 }], costs: [ + { currency: 'CNY', directCost: 100.03, ownCost: 50.02, externalCost: 50.01 }, + { currency: 'USD', directCost: 13.07, ownCost: 6.54, externalCost: 6.53 }] }) + assert.equal(f.stage, 'CURRENT_RUN_DEPARTED'); assert.equal(f.departureVerified, true) +}) +test('multi-stop costs retain full trip amounts and never infer source shares', () => { + const f = sample(), load = f.immutableDeparture.snapshot.confirmedLoad + load.pallets[1].deliveryIndex = 2; load.cargo[1].destinationKey = 'FBA:B' + Object.assign(f.costs.expected, { allocationMethod: null, allocationStatus: 'UNAVAILABLE', allocationBlockReason: '各票卸货站不同,暂不可按同路线托位自动分摊' }) + assert.deepEqual(convert(f).expected.costs, [{ currency: 'CNY', directCost: 100.03, ownCost: null, externalCost: null }, { currency: 'USD', directCost: 13.07, ownCost: null, externalCost: null }]) + f.costs.expected.allocationMethod = 'SAME_ROUTE_PALLET_SPACES' + assert.throws(() => convert(f)) +}) +test('SHA-bound current artifact does not require optional trip remarks in the DEPART snapshot', () => { + const f = sample(); delete f.immutableDeparture.snapshot.trip.remark + assert.equal(convert(f).tripNo, f.tripNo) + f.costs.marker = 'OLD' + assert.throws(() => convert(f)) +}) +for (const [name, mutate] of [ + ['stale marker', f => { f.marker = 'OLD' }], ['stale run', f => { f.runId = 'old' }], + ['unverified costs', f => { f.costs.status = 'CHECKING' }], ['wrong currency amount', f => { f.costs.expected.usd = '0' }], + ['not departed', f => { f.departure.status = 'PLANNED' }], ['replaced history', f => { f.immutableDeparture.action = 'HANDOVER' }], + ['duplicate physical pallet', f => { f.immutableDeparture.snapshot.confirmedLoad.pallets.push(f.immutableDeparture.snapshot.confirmedLoad.pallets[0]) }], + ['external over-attribution', f => { f.immutableDeparture.snapshot.externalBookings[0].receipt.source.pallets[0].packages = 99 }], + ['wrong shares', f => { f.costs.shares[1].warehousePalletIds = ['1'] }], ['wrong departure total', f => { f.departure.shippedPackages = 15 }], +]) test(`rejects ${name} before any HTTP`, () => { const f = sample(); mutate(f); assert.throws(() => convert(f)) }) +test('rejects mismatched file hash even when its contents look valid', () => assert.throws(() => convert(sample(), { sha256: '0'.repeat(64) }))) +test('requires explicit report/hash/current marker/run and TEST readiness; denies write and resume flags', () => { + const args = ['--ready-commit=a6fd0ba3', '--fixture=reports/fleet-current-run/handover-fixture.json', `--fixture-sha256=${'a'.repeat(64)}`, '--marker=CURRENT', '--run-id=current-run'] + const runtime = ['--env-file=C:/Users/BY-OY/.codex/env/chongchong-global/test-acceptance.env'] + assert.equal(parseOperationsArguments(args, runtime).runId, 'current-run') + for (const bad of [args.slice(1), [...args, '--write-measured=true'], [...args, '--resume=true'], args.map(a => a.startsWith('--fixture=') ? '--fixture=../old/handover-fixture.json' : a)]) assert.throws(() => parseOperationsArguments(bad, runtime)) +}) +test('transport permits only current-trip/statistics reads plus independent session login/logout', async () => { + const context = new AcceptanceRun({ environment: 'test' }), calls = [] + for (const name of ['request', 'requestWithBody', 'requestBinary']) context[name] = async (...args) => { calls.push(args); return {} } + restrictOperationsTransport(context, 'TR-CURRENT') + await context.request('GET', '/api/platform/v1/last-leg/fleet/trips/TR-CURRENT') + for (const [method, path] of [['PUT', '/api/platform/v1/last-leg/fleet/operations/TR-CURRENT'], ['POST', '/api/platform/v1/last-leg/fleet/trips'], ['GET', '/api/platform/v1/last-leg/fleet/trips/TR-OLD'], ['GET', '/api/platform/v1/iam/tenants']]) assert.throws(() => context.request(method, path)) + assert.equal(calls.length, 1) +}) +test('new login and logout surround a rejected live immutable snapshot without business writes', async () => { + const context = new AcceptanceRun({ environment: 'test', username: 'offline', password: 'offline', quiet: true }), calls = [] + context.request = async (method, path) => { + calls.push([method, path]) + return { http: 200, body: { code: '200', data: path.endsWith('/login') ? { accessToken: 'offline-access', refreshToken: 'offline-refresh' } : path.includes('/history') ? { total: 0, records: [] } : null }, requestId: 'offline', traceId: 'offline' } + } + const report = await runOperationsSession(context, convert(sample())) + assert.equal(report.success, false); assert.equal(report.loggedOut, true); assert.equal(report.measuredStatus, 'NOT_TESTED') + assert.deepEqual(calls.map(c => c[0]), ['POST', 'GET', 'POST']) + assert.equal(calls[0][1], '/api/auth/login'); assert.equal(calls.at(-1)[1], '/api/auth/logout') + assert.equal(context.token, '') +}) diff --git a/scripts/fleet-operations-test-env.mjs b/scripts/fleet-operations-test-env.mjs new file mode 100644 index 000000000..153402ce6 --- /dev/null +++ b/scripts/fleet-operations-test-env.mjs @@ -0,0 +1,84 @@ +import { readFile, mkdir, open } from 'node:fs/promises' +import { resolve, dirname, relative, isAbsolute } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { parseEnv } from 'node:util' +import { AcceptanceRun } from './admin-test-env-acceptance.mjs' +import { requireTestProfile, installQuoteJournal, READY_COMMIT } from './fleet-quotes-test-env.mjs' +import { operationsFixtureFromHandover, verifyOperations } from './admin-test-env-modules/fleet-quotes-operations.mjs' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +export function parseOperationsArguments(args, execArgs) { + const allowed = ['ready-commit', 'fixture', 'fixture-sha256', 'marker', 'run-id'], values = {} + for (const arg of args) { + const match = /^--([^=]+)=(.+)$/.exec(arg) + if (!match || !allowed.includes(match[1]) || values[match[1]]) throw new Error('Explicit unique operations arguments required; no resume or write mode') + values[match[1]] = match[2] + } + if (allowed.some(name => !values[name]) || !/^[a-f0-9]{64}$/.test(values['fixture-sha256']) || !/^[A-Za-z0-9_-]{1,80}$/.test(values.marker) || !/^[A-Za-z0-9_-]{1,48}$/.test(values['run-id'])) throw new Error('Missing current-run identity or SHA256') + const envFile = requireTestProfile([`--ready-commit=${values['ready-commit']}`], execArgs) + const fixturePath = resolve(root, values.fixture), rel = relative(resolve(root, 'reports'), fixturePath) + if (isAbsolute(rel) || rel.startsWith('..') || !/^[^/\\]+[/\\]handover-fixture\.json$/.test(rel)) throw new Error('Only an explicit C report under this TEST repository reports directory is accepted') + return { envFile, fixturePath, sha256: values['fixture-sha256'], marker: values.marker, runId: values['run-id'] } +} + +export async function runOperationsSession(context, fixture) { + if (context.options?.environment !== 'test' || context.token || context.refreshToken || Object.keys(context.createdResources).length) throw new Error('Fresh independent TEST session required') + context.marker = fixture.marker + context.context.fleetQuotesOperationsFixture = fixture + const successful = r => r?.http === 200 && String(r.body?.code) === '200' + let failure = null, loggedOut = false + try { + const login = await context.request('POST', '/api/auth/login', { loginId: context.options.username, password: context.options.password }, '') + if (!successful(login) || !login.body.data?.accessToken || !login.body.data?.refreshToken) throw new Error('Independent TEST login failed') + context.token = login.body.data.accessToken; context.refreshToken = login.body.data.refreshToken + await verifyOperations(context, { writeMeasured: false }) + } catch (error) { failure = error.message } + finally { + try { + if (context.token) loggedOut = successful(await context.request('POST', '/api/auth/logout', { refreshToken: context.refreshToken })) + } catch { failure ||= 'Logout outcome unknown' } + context.token = ''; context.refreshToken = '' + } + return { marker: fixture.marker, scope: 'OPERATIONS_ONLY', success: !failure && loggedOut && context.steps.every(s => s.passed), failure, loggedOut, + measuredStatus: 'NOT_TESTED', financeStatus: 'NOT_TESTED', steps: context.steps, createdResources: context.createdResources } +} + +export function restrictOperationsTransport(context, tripNo) { + for (const name of ['request', 'requestWithBody', 'requestBinary']) { + const original = context[name].bind(context) + context[name] = (method, path, ...args) => { + const trip = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(tripNo)}` + const read = method === 'GET' && (path === trip || path.startsWith(`${trip}/history?`) || /^\/api\/platform\/v1\/last-leg\/fleet\/operations(?:\/export)?\?/.test(path)) + const auth = name === 'request' && method === 'POST' && ['/api/auth/login', '/api/auth/logout'].includes(path) + if (!read && !auth) throw new Error('Operations reader forbids business writes and unrelated reads') + return original(method, path, ...args) + } + } +} + +export async function execute(args = process.argv.slice(2), execArgs = process.execArgv) { + const config = parseOperationsArguments(args, execArgs) + if (!root.replaceAll('\\', '/').endsWith('/chongchong-global/baiyue-firstleg')) throw new Error('Registered TEST workspace required') + const fixture = operationsFixtureFromHandover(await readFile(config.fixturePath), config) + const env = parseEnv(await readFile(config.envFile, 'utf8')), base = new URL(env.CHONGCHONG_ADMIN_API_BASE_URL) + if (env.CHONGCHONG_ENV !== 'test' || !env.CHONGCHONG_ADMIN_USERNAME || !env.CHONGCHONG_ADMIN_PASSWORD || !['http:', 'https:'].includes(base.protocol) + || base.pathname.replace(/\/$/, '') !== '/api' || base.username || base.password || base.search || base.hash) throw new Error('Explicit unified TEST configuration required') + const directory = resolve(root, 'reports', `fleet-operations-${config.runId}`) + await mkdir(directory, { recursive: true }) + const context = new AcceptanceRun({ environment: 'test', gateway: base.origin, username: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD, quiet: true }) + restrictOperationsTransport(context, fixture.tripNo) + const journal = await installQuoteJournal(context, directory, { secrets: [env.CHONGCHONG_ADMIN_USERNAME, env.CHONGCHONG_ADMIN_PASSWORD], persist: async (name, evidence) => { + const file = await open(resolve(directory, name), 'wx', 0o600) + try { + await file.writeFile(JSON.stringify({ ...evidence, marker: config.marker, scope: 'OPERATIONS_ONLY', expectedCommit: READY_COMMIT, + input: { fixturePath: config.fixturePath, sha256: config.sha256, runId: config.runId }, expected: fixture.expected }, null, 2)); await file.sync() + } finally { await file.close() } + } }) + const report = await runOperationsSession(context, fixture) + await journal.checkpoint('final', report) + return { success: report.success && !journal.unknown(), directory } +} +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + execute().then(result => { console.log(JSON.stringify(result)); process.exitCode = result.success ? 0 : 1 }) + .catch(() => { console.error('运营只读验收未启动或中止,请核对本轮C报告、SHA及持久检查点;未自动重跑。'); process.exitCode = 1 }) +} diff --git a/scripts/fleet-quotes-test-env-contract.test.mjs b/scripts/fleet-quotes-test-env-contract.test.mjs new file mode 100644 index 000000000..4b8ed444c --- /dev/null +++ b/scripts/fleet-quotes-test-env-contract.test.mjs @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { installQuoteJournal, requireTestProfile, READY_COMMIT } from './fleet-quotes-test-env.mjs' +import { AcceptanceRun } from './admin-test-env-acceptance.mjs' +import { run, runQuoteSession } from './admin-test-env-modules/fleet-quotes-operations.mjs' + +const ok = data => ({ http: 200, body: { code: '200', data }, requestId: 'offline-request', traceId: 'offline-trace' }) +function harness(respond = () => ok({})) { + const context = new AcceptanceRun({ environment: 'test', username: 'synthetic-user', password: 'synthetic-password', quiet: true }) + const events = [], calls = [] + const persist = async (name, value) => events.push({ name, value: structuredClone(value) }) + for (const name of ['request', 'requestWithBody', 'requestBinary']) context[name] = async (...args) => { + const before = events.at(-1) + assert.match(before.name, /-before.json$/) + assert.equal(before.value.method, args[0]); assert.equal(before.value.path, args[1]) + calls.push({ name, args }) + return respond(...args) + } + return { context, events, calls, persist } +} +test('requires exactly one explicit authorized TEST profile and the deployed commit acknowledgement', () => { + const file = 'C:/Users/BY-OY/.codex/env/chongchong-global/test-acceptance.env' + assert.ok(requireTestProfile([`--ready-commit=${READY_COMMIT}`], [`--env-file=${file}`])) + for (const [args, runtime] of [ + [[], [`--env-file=${file}`]], [['--ready-commit=old'], [`--env-file=${file}`]], + [[`--ready-commit=${READY_COMMIT}`], []], [[`--ready-commit=${READY_COMMIT}`], [`--env-file=${file.replace('test-', 'prod-')}`]], + [[`--ready-commit=${READY_COMMIT}`], [`--env-file=${file}`, `--env-file=${file}`]], + [[`--ready-commit=${READY_COMMIT}`, '--resume'], [`--env-file=${file}`]], + ]) assert.throws(() => requireTestProfile(args, runtime)) +}) +test('writes immutable exact business body and key before sending and pairs response diagnostics', async () => { + const h = harness(); await installQuoteJournal(h.context, '', { persist: h.persist }) + const body = { idempotencyKey: 'same-original-key', details: { looseDeliveryPrice: 0, selfPickupPrice: null } } + await h.context.request('POST', '/api/platform/v1/last-leg/fleet/quotes', body) + body.details.looseDeliveryPrice = 20 + assert.equal(h.events[1].value.body.value.details.looseDeliveryPrice, 0) + assert.equal(h.events[1].value.idempotencyKey, 'same-original-key') + assert.equal(h.events[2].value.sample, h.events[1].name) + assert.equal(h.events[2].value.requestId, 'offline-request') +}) +for (const response of [{ http: 0 }, { http: 408 }, { http: 503 }, { http: 200, body: { code: 'S0500' } }, { http: 200, body: null }]) { + test(`unknown write ${JSON.stringify(response)} halts without retry while allowing journaled logout`, async () => { + const h = harness((method, path) => path === '/api/auth/logout' ? ok(null) : response) + const journal = await installQuoteJournal(h.context, '', { persist: h.persist }) + await assert.rejects(h.context.request('POST', '/api/platform/v1/suppliers', { name: 'isolated' }), /Unknown write/) + await assert.rejects(h.context.request('POST', '/api/platform/v1/suppliers', { name: 'isolated' }), /halted/) + await assert.rejects(h.context.request('PATCH', '/api/platform/v1/suppliers/1/status', { status: 'INACTIVE' }), /halted/) + assert.equal(h.calls.length, 1); assert.equal(journal.unknown(), true) + await h.context.request('POST', '/api/auth/logout', { refreshToken: 'synthetic-refresh' }) + assert.equal(h.calls.length, 2); assert.equal(h.events[2].value.outcome, 'UNKNOWN_STOP') + }) +} +test('failure to persist before sending blocks all HTTP', async () => { + const h = harness(); let writes = 0 + await installQuoteJournal(h.context, '', { persist: async () => { if (++writes > 1) throw new Error('disk full') } }) + await assert.rejects(h.context.request('POST', '/api/platform/v1/suppliers', {}), /disk full/) + assert.equal(h.calls.length, 0) +}) +test('an uncertain quote command skips all resource deactivation and retains the original supplier', async () => { + const h = harness((method, path) => path.includes('/suppliers') ? ok({ id: '321' }) : { http: 0 }) + h.context.marker = 'FLEET-QUOTE-20261003' + h.context.createdResources.merchantQualificationFixture = { marker: h.context.marker, tenantId: '123' } + await installQuoteJournal(h.context, '', { persist: h.persist }) + await assert.rejects(run(h.context), /Unknown write/) + assert.equal(h.context.createdResources.fleetQuoteAcceptance.cleanup, 'NOT_ATTEMPTED_UNKNOWN_OUTCOME') + assert.equal(h.context.createdResources.fleetQuoteAcceptance.supplierId, '321') + assert.equal(h.calls.length, 2) + assert.ok(h.calls.every(call => call.args[0] === 'POST')) +}) +test('failure to persist a response retains the before sample and forbids replay', async () => { + const h = harness() + await installQuoteJournal(h.context, '', { persist: async (name, value) => { if (name.includes('-after')) throw new Error('disk full'); await h.persist(name, value) } }) + await assert.rejects(h.context.request('POST', '/api/platform/v1/suppliers', { name: 'isolated' }), /disk full/) + await assert.rejects(h.context.request('POST', '/api/platform/v1/suppliers', { name: 'isolated' }), /halted/) + assert.equal(h.calls.length, 1); assert.match(h.events.at(-1).name, /before/) +}) +test('multipart checkpoint preserves upload bytes, filenames, digest and scalar fields', async () => { + const h = harness(); await installQuoteJournal(h.context, '', { persist: h.persist }) + const body = new FormData(); body.append('businessLicense', new Blob(['fixture-png'], { type: 'image/png' }), 'fixture.png'); body.append('kind', 'BUSINESS_LICENSE') + await h.context.requestWithBody('POST', '/api/platform/v1/files/qualifications/1/required-package', body) + const parts = h.events[1].value.body.parts + assert.equal(Buffer.from(parts[0].base64, 'base64').toString(), 'fixture-png') + assert.equal(parts[0].filename, 'fixture.png'); assert.equal(parts[0].sha256.length, 64) + assert.equal(parts[1].value, 'BUSINESS_LICENSE') +}) +test('login, fresh fixture and logout have durable checkpoints without credentials even when fixture outcome is unknown', async () => { + const h = harness((method, path) => path === '/api/auth/login' ? ok({ accessToken: 'synthetic-access', refreshToken: 'synthetic-refresh' }) : path === '/api/auth/logout' ? ok(null) : { http: 0 }) + await installQuoteJournal(h.context, '', { persist: h.persist, secrets: ['synthetic-password', 'synthetic-user'] }) + const report = await runQuoteSession(h.context, { prepareCustomer: async current => { await current.request('POST', '/api/platform/v1/iam/tenants', { tenantName: current.marker }) } }) + assert.equal(report.success, false); assert.equal(report.loggedOut, true) + assert.deepEqual(h.calls.map(call => call.args[1]), ['/api/auth/login', '/api/platform/v1/iam/tenants', '/api/auth/logout']) + assert.ok(h.events.some(event => event.name.includes('module-end'))) + assert.doesNotMatch(JSON.stringify(h.events), /synthetic-password|synthetic-user|synthetic-access|synthetic-refresh/) +}) +test('redacts newly issued customer credentials including echoes in diagnostic strings', async () => { + const h = harness(() => ok({ temporaryPassword: 'generated-secret', administratorUsername: 'generated-user', message: 'generated-secret generated-user' })) + await installQuoteJournal(h.context, '', { persist: h.persist }) + await h.context.request('POST', '/api/platform/v1/iam/tenants', { tenantName: 'current-run' }) + assert.doesNotMatch(JSON.stringify(h.events), /generated-secret|generated-user/) +}) +test('exclusive on-disk claim prevents a second run from replacing original evidence', async () => { + const directory = await mkdtemp(join(tmpdir(), 'fleet-quote-journal-')) + try { + const h = harness(); const original = async () => ok(null) + h.context.request = original + await installQuoteJournal(h.context, directory) + await h.context.request('POST', '/api/platform/v1/suppliers', { name: 'original-sample' }) + const files = await readdir(directory) + assert.equal(files.length, 3) + await assert.rejects(installQuoteJournal(harness().context, directory), { code: 'EEXIST' }) + assert.match(await readFile(join(directory, '00002-before.json'), 'utf8'), /original-sample/) + } finally { await rm(directory, { recursive: true, force: true }) } +}) diff --git a/scripts/fleet-quotes-test-env.mjs b/scripts/fleet-quotes-test-env.mjs new file mode 100644 index 000000000..853a71de5 --- /dev/null +++ b/scripts/fleet-quotes-test-env.mjs @@ -0,0 +1,130 @@ +import { open, mkdir, readFile } from 'node:fs/promises' +import { resolve, dirname } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { parseEnv } from 'node:util' +import { createHash } from 'node:crypto' +import { AcceptanceRun } from './admin-test-env-acceptance.mjs' +import { runQuoteSession } from './admin-test-env-modules/fleet-quotes-operations.mjs' + +export const READY_COMMIT = 'a6fd0ba3' +export const MARKER = 'FLEET-QUOTE-20261003' +const repository = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const outputDirectory = resolve(repository, 'reports/fleet-quote-live-20261003') +const sensitive = /password|token|authorization|cookie|secret|loginId|username/i + +export function requireTestProfile(args, execArgs) { + if (args.length !== 1 || args[0] !== `--ready-commit=${READY_COMMIT}`) throw new Error('Explicit current ready-commit required; no resume or replay mode') + const files = [] + for (let i = 0; i < execArgs.length; i++) { + if (execArgs[i].startsWith('--env-file=')) files.push(execArgs[i].slice(11)) + else if (execArgs[i] === '--env-file') files.push(execArgs[++i]) + else throw new Error('Only the explicit TEST env-file runtime option is allowed') + } + if (files.length !== 1 || !files[0] || !resolve(files[0]).replaceAll('\\', '/').endsWith('/.codex/env/chongchong-global/test-acceptance.env')) throw new Error('Explicit authorized TEST profile required') + return resolve(files[0]) +} + +/** Append-only, fsync-before-send evidence. A fixed exclusive claim prohibits automatic reruns even after a crash. */ +export async function installQuoteJournal(context, directory, { persist, secrets = [] } = {}) { + let sequence = 0, halted = false + const knownSecrets = new Set(secrets.filter(Boolean)) + function discover(value) { + if (!value || typeof value !== 'object') return + for (const [key, child] of Object.entries(value)) { + if (sensitive.test(key) && typeof child === 'string' && child) knownSecrets.add(child) + else if (child && typeof child === 'object') discover(child) + } + } + function clean(value) { + discover(value) + if (typeof value === 'string') { + let result = value + for (const secret of knownSecrets) result = result.split(secret).join('[REDACTED]') + return result + } + if (Array.isArray(value)) return value.map(clean) + if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, sensitive.test(key) ? '[REDACTED]' : clean(child)])) + return value + } + const save = persist || (async (name, value) => { + const file = await open(resolve(directory, name), 'wx', 0o600) + try { await file.writeFile(`${JSON.stringify(value, null, 2)}\n`, 'utf8'); await file.sync() } finally { await file.close() } + }) + async function checkpoint(kind, value) { + const name = `${String(++sequence).padStart(5, '0')}-${kind}.json` + await save(name, clean({ marker: MARKER, at: new Date().toISOString(), ...value })) + return name + } + await checkpoint('claim', { expectedCommit: READY_COMMIT, scope: 'QUOTES_ONLY', status: 'CLAIMED', rerun: 'FORBIDDEN; reconcile original samples manually' }) + async function bodyEvidence(body) { + if (!(body instanceof FormData)) return body === undefined ? { encoding: 'absent' } : { encoding: 'json', value: structuredClone(body) } + const parts = [] + for (const [name, value] of body.entries()) { + if (typeof value === 'string') parts.push({ name, value }) + else { + const bytes = Buffer.from(await value.arrayBuffer()) + parts.push({ name, filename: value.name, contentType: value.type, size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex'), base64: bytes.toString('base64') }) + } + } + return { encoding: 'multipart', parts } + } + for (const name of ['request', 'requestWithBody', 'requestBinary']) { + const original = context[name].bind(context) + context[name] = async (method, path, ...rest) => { + method = String(method).toUpperCase() + const logout = method === 'POST' && path === '/api/auth/logout' + if (halted && !logout) throw new Error('Unknown request outcome: halted; original sample retained, no retry or cleanup') + const body = name === 'requestBinary' ? undefined : rest[0] + discover(body) + const request = { method, path, transport: name, body: await bodyEvidence(body), headers: name === 'requestBinary' ? {} : rest[2] || {}, idempotencyKey: body?.idempotencyKey ?? null } + let sample + try { sample = await checkpoint('before', request) } catch (error) { halted = true; context.quoteRequestOutcomeUnknown = true; throw error } + let response + try { response = await original(method, path, ...rest) } + catch { response = { http: 0, body: null, requestId: null, traceId: null } } + discover(response?.body) + const code = String(response?.body?.code ?? '') + const write = !['GET', 'HEAD'].includes(method) + const known = response?.http === 200 && (code === '200' || /^A04\d+$/.test(code)) || response?.http >= 400 && response?.http < 500 && response?.http !== 408 + const unknown = write && !known + if (unknown) { halted = true; context.quoteRequestOutcomeUnknown = true } + try { + await checkpoint('after', { sample, method, path, outcome: unknown ? 'UNKNOWN_STOP' : 'RECEIVED', http: response?.http, code, requestId: response?.requestId, traceId: response?.traceId, + // Only write responses belong to our isolated fixture. Do not persist unrelated directory records. + body: write ? response?.body : undefined, createdResources: context.createdResources, steps: context.steps }) + } catch (error) { halted = true; context.quoteRequestOutcomeUnknown = true; throw error } + if (unknown) throw new Error('Unknown write outcome; reconcile persisted original request before further business actions') + return response + } + } + const executeModule = context.executeModule.bind(context) + context.executeModule = async (name, handler) => { + await checkpoint('module-start', { module: name, createdResources: context.createdResources }) + try { return await executeModule(name, handler) } + finally { await checkpoint('module-end', { module: name, createdResources: context.createdResources, steps: context.steps, modules: context.modules }) } + } + return { checkpoint, unknown: () => halted } +} + +export async function execute(args = process.argv.slice(2), execArgs = process.execArgv) { + const file = requireTestProfile(args, execArgs) + if (!repository.replaceAll('\\', '/').endsWith('/chongchong-global/baiyue-firstleg')) throw new Error('Only registered TEST workspace is allowed') + const env = parseEnv(await readFile(file, 'utf8')) + if (env.CHONGCHONG_ENV !== 'test' || !env.CHONGCHONG_ADMIN_USERNAME || !env.CHONGCHONG_ADMIN_PASSWORD) throw new Error('TEST credentials missing') + const base = new URL(env.CHONGCHONG_ADMIN_API_BASE_URL) + if (!['http:', 'https:'].includes(base.protocol) || base.pathname.replace(/\/$/, '') !== '/api' || base.username || base.password || base.search || base.hash) throw new Error('Invalid unified TEST API base') + await mkdir(outputDirectory, { recursive: true }) + const context = new AcceptanceRun({ environment: 'test', gateway: base.origin, username: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD, quiet: true, modules: [] }) + const journal = await installQuoteJournal(context, outputDirectory, { secrets: [env.CHONGCHONG_ADMIN_USERNAME, env.CHONGCHONG_ADMIN_PASSWORD] }) + let report + try { report = await runQuoteSession(context, { marker: MARKER }) } + catch { report = { success: false, fatal: 'Session interrupted; inspect durable request checkpoints', operationsStatus: 'NOT_TESTED' } } + finally { context.token = ''; context.refreshToken = '' } + const success = Boolean(report.success) && !journal.unknown() + await journal.checkpoint('final', { ...report, success, outcome: journal.unknown() ? 'UNKNOWN_STOP' : success ? 'VERIFIED' : 'FAILED', createdResources: context.createdResources }) + return { outputDirectory, success, realExecution: true } +} +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + execute().then(result => { console.log(JSON.stringify(result)); process.exitCode = result.success ? 0 : 1 }) + .catch(() => { console.error('报价验收未启动或已中止;核对TEST配置、就绪标记和持久检查点,禁止自动重跑。'); process.exitCode = 1 }) +} diff --git a/scripts/fleet-same-route-cost-test-env.mjs b/scripts/fleet-same-route-cost-test-env.mjs new file mode 100644 index 000000000..59a212b29 --- /dev/null +++ b/scripts/fleet-same-route-cost-test-env.mjs @@ -0,0 +1,120 @@ +import fs from 'node:fs' +import {createHash} from 'node:crypto' +import {resolve} from 'node:path' +import {pathToFileURL} from 'node:url' +import {parseCrossCustomerArguments,runCrossCustomer} from './fleet-cross-customer-test-env.mjs' +import {controlledPalletMeasurements} from './admin-test-env-modules/fleet-trip-fulfillment.mjs' +import {verifyFleetExternalReceipt} from './admin-test-env-modules/fleet-external-receipt-acceptance.mjs' +import {verifyFleetExternalDispatch} from './admin-test-env-modules/fleet-external-dispatch-acceptance.mjs' +import {expectedAllocations} from './admin-test-env-modules/fleet-trip-cost-acceptance.mjs' + +const ROOT='/api/platform/v1/last-leg',PDA='/api/pda/v1/last-leg/dispatches',MODULE='fleet-same-route-cost' +const id=v=>String(v??''),demand=(value,message)=>{if(!value)throw Error(message)} +const sorted=v=>Array.isArray(v)?v.map(sorted):v&&typeof v==='object'?Object.fromEntries(Object.keys(v).sort().map(k=>[k,sorted(v[k])])):v +const same=(a,b)=>JSON.stringify(sorted(a))===JSON.stringify(sorted(b)) +const cents=v=>{demand(/^\d+(?:\.\d{1,2})?$/.test(id(v)),'INVALID_MONEY');const [a,b='']=id(v).split('.');return BigInt(a)*100n+BigInt(b.padEnd(2,'0'))} + +export function assertSameRouteBasis(departure,shares) { + const load=departure?.snapshot?.confirmedLoad,stops=departure?.snapshot?.trip?.stops + demand(departure?.action==='DEPART'&&load?.manifestHash,'ACTUAL_DEPART_HISTORY_REQUIRED') + demand(shares?.length===3&&shares.filter(x=>x.sourceType==='OWN').length===2&&shares.filter(x=>x.sourceType==='EXTERNAL').length===1,'THREE_EXACT_COST_SOURCES_REQUIRED') + demand(new Set(shares.map(x=>x.businessNo)).size===3&&new Set(shares.map(x=>x.dispatchNo)).size===3&&shares.every(x=>x.palletSpaces===1&&x.warehousePalletIds?.length===1),'THREE_INDEPENDENT_STANDARD_PALLETS_REQUIRED') + const ids=shares.flatMap(x=>x.warehousePalletIds).map(id) + demand(new Set(ids).size===3&&load?.pallets?.length===3&&same([...ids].sort(),load.pallets.map(x=>id(x.warehousePalletId)).sort()),'COMPLETE_DEPART_PALLET_SCOPE_REQUIRED') + demand(load.cargo?.length===3&&new Set(load.pallets.map(x=>x.deliveryIndex)).size===1&&load.pallets.every(x=>x.deliveryIndex>0&&stops?.[x.deliveryIndex]?.destinationKey),'SINGLE_REAL_UNLOADING_STOP_REQUIRED') + for(const share of shares){ + const pallet=load.pallets.find(x=>id(x.warehousePalletId)===id(share.warehousePalletIds[0])),cargo=load.cargo.filter(x=>id(x.warehousePalletId)===id(pallet.warehousePalletId)) + demand(Number(pallet.palletSpaces)===1&&cargo.length===1&&cargo[0].dispatchNo===share.dispatchNo&&id(cargo[0].customerId)===share.customerId&&Number(cargo[0].packages)===1&&cargo[0].destinationKey===stops[pallet.deliveryIndex].destinationKey,'SOURCE_AND_DESTINATION_MISMATCH') + } + const externals=(departure.snapshot.externalBookings||[]).filter(x=>!['DRAFT','CANCELLED'].includes(x.status)),external=shares.find(x=>x.sourceType==='EXTERNAL'),booking=externals[0] + demand(externals.length===1&&booking?.status==='BOUND'&&booking.bookingNo===external.businessNo&&booking.dispatchNo===external.dispatchNo&&id(booking.customerId)===external.customerId&&Number(booking.receipt?.source?.receivedPackages)===1&&booking.receipt.source.pallets?.length===1&&Number(booking.receipt.source.pallets[0].packages)===1&&id(booking.receipt.source.pallets[0].warehousePalletId)===id(external.warehousePalletIds[0]),'ACTUAL_EXTERNAL_RECEIPT_REQUIRED') + demand(shares.filter(x=>x.sourceType==='OWN').every(x=>x.businessNo===x.dispatchNo),'OWN_DISPATCH_IDENTITY_REQUIRED') + return load +} +export function assertSameRouteCost(summary,departure,shares,amount='1.00') { + const load=assertSameRouteBasis(departure,shares) + demand(summary?.allocationMethod==='SAME_ROUTE_PALLET_SPACES'&&summary.allocationBlockReason==null&&id(summary.departureVersion)===id(departure.recordVersion)&&summary.manifestHash===load.manifestHash,'IMMUTABLE_DEPART_BASIS_REQUIRED') + demand(summary.totals?.length===1&&summary.totals[0].currency==='USD','SINGLE_ORIGINAL_CURRENCY_REQUIRED') + const total=summary.totals[0],expected=expectedAllocations(amount,shares),actual=[...(total.allocations||[])].sort((a,b)=>a.businessNo.localeCompare(b.businessNo)).map(x=>({businessNo:x.businessNo,sourceType:x.sourceType,dispatchNo:x.dispatchNo,palletSpaces:Number(x.palletSpaces),cents:String(cents(x.amount))})) + demand(cents(total.directCost)===cents(amount)&&same(actual,expected),'EXACT_LARGEST_REMAINDER_ALLOCATION_REQUIRED') + demand(cents(total.ownCost)===expected.filter(x=>x.sourceType==='OWN').reduce((n,x)=>n+BigInt(x.cents),0n)&¢s(total.externalCost)===expected.filter(x=>x.sourceType==='EXTERNAL').reduce((n,x)=>n+BigInt(x.cents),0n)&¢s(total.ownCost)+cents(total.externalCost)===cents(amount),'OWN_EXTERNAL_CENT_CONSERVATION_REQUIRED') + return expected +} + +export async function verifySameRouteCost(context,fixture,{execute=false,readInventory}={}) { + if(!execute)return {scope:'SAME_ROUTE_COST_WITH_TWO_OWN_AND_ONE_EXTERNAL',businessSqlWrites:false} + demand(context?.marker&&context.marker===fixture?.marker&&fixture?.pdaToken&&fixture?.members?.length===2 + &&context.createdResources?.fleetTrips?.some(x=>x.tripNo===fixture.tripNo)&&typeof readInventory==='function','CURRENT_TWO_CUSTOMER_FIXTURE_REQUIRED') + demand(!context.createdResources.fleetSameRouteCost,'SAME_ROUTE_VERIFICATION_ALREADY_STARTED') + const proof=context.createdResources.fleetSameRouteCost={status:'CHECKING',tripNo:fixture.tripNo,marker:context.marker,scope:'SAME_ROUTE_COST_WITH_TWO_OWN_AND_ONE_EXTERNAL',snapshots:{},requests:[]} + const check=(name,condition)=>{context.assert(MODULE,name,null,Boolean(condition),'',{scenario:MODULE,deploymentBlocker:true});demand(condition,name)} + const request=async(method,path,body,token=context.token)=>{const r=await context.request(method,path,body,token);proof.requests.push({method,path,http:r.http,code:r.body?.code,requestId:r.requestId,traceId:r.traceId});check(`${method} ${path}`,r.http===200&&id(r.body?.code)==='200'&&r.requestId&&r.traceId);return r.body.data} + const trip=context.createdResources.fleetTrips.find(x=>x.tripNo===fixture.tripNo),base=`${ROOT}/fleet/trips/${fixture.tripNo}`,key=suffix=>`${context.marker}-SAME-${suffix}` + const inventory=async label=>{const rows=[];for(const customerId of [...new Set(fixture.members.map(x=>x.customerId))])rows.push(await readInventory({marker:context.marker,customerId,warehouseId:fixture.warehouseId}));proof.snapshots[label]=rows;return rows} + try{ + let detail=await request('GET',base),plan=await request('GET',`${base}/capacity-plan`) + check('Two owned sources have one real FBA unloading destination',plan.cargo?.length===2&&new Set(plan.cargo.map(x=>x.destinationKey)).size===1&&plan.cargo.every(x=>x.destinationKey?.startsWith('FBA:'))) + await request('POST',`${base}/capacity-plan`,{recordVersion:plan.recordVersion,manifestHash:plan.manifestHash,pallets:controlledPalletMeasurements(plan.cargo,detail.trip.stops),reason:context.marker}) + await verifyFleetExternalReceipt(context,MODULE,trip,fixture.pdaToken) + await verifyFleetExternalDispatch(context,MODULE,trip) + const receipt=trip.externalReceipt,external=trip.externalDispatch + const members=proof.members=[...fixture.members,{customerId:external.customerId,dispatchNo:external.dispatchNo,outboundNo:external.outboundNo,deliveryNo:external.deliveryNo,palletId:receipt.warehousePalletId,palletNo:receipt.palletNo,positionId:id(receipt.finalInventory.positions[0].id)}] + check('Only three distinct current-run dispatches and pallets',new Set(members.map(x=>x.dispatchNo)).size===3&&new Set(members.map(x=>x.palletId)).size===3) + for(const[index,m]of members.entries()){ + const task=await request('GET',`${PDA}/${m.dispatchNo}`,undefined,fixture.pdaToken),item=task.items?.[0] + check('PDA item is the original one-box pallet and source position',task.tripNo===fixture.tripNo&&task.items.length===1&&item.palletNo===m.palletNo&&Number(item.scheduledPackages)===1&&Number(item.pickedPackages)===0&&item.positions?.some(x=>id(x.id)===m.positionId&&Number(x.availablePackages)===1)) + const pick={positionId:m.positionId,packages:1,productQuantity:1,idempotencyKey:key(`PICK-${index}`),remark:context.marker} + await request('POST',`${PDA}/${m.dispatchNo}/items/${item.dispatchItemId}/pick`,pick,fixture.pdaToken) + await request('POST',`${PDA}/${m.dispatchNo}/complete`,{idempotencyKey:key(`PICK-COMPLETE-${index}`)},fixture.pdaToken) + } + const stock=await inventory('PICKED'),positions=stock.flatMap(x=>x.positions) + check('All three original physical positions remain with zero available stock',positions.length===3&&members.every(m=>positions.some(x=>id(x.id)===m.positionId&&Number(x.availablePackages)===0))) + const loading=await request('GET',`${base}/loading`);detail=await request('GET',base) + check('Three real pallets ready for loading',!loading.blockReason&&loading.cargo.length===3) + const measured=await request('POST',`${base}/loading`,{recordVersion:loading.recordVersion,manifestHash:loading.manifestHash,pallets:controlledPalletMeasurements(loading.cargo,detail.trip.stops),reason:context.marker}) + const approved=await request('POST',`${base}/loading-review`,{recordVersion:measured.recordVersion,decision:'APPROVED'}) + const depart={recordVersion:approved.trip.recordVersion,idempotencyKey:key('DEPART'),remark:context.marker},departPath=`${PDA}/${members[0].dispatchNo}/depart` + proof.departure=await request('POST',departPath,depart,fixture.pdaToken) + check('Shared actual departure contains all three customer executions',proof.departure.tripNo===fixture.tripNo&&proof.departure.status==='IN_TRANSIT'&&Number(proof.departure.shippedPackages)===3&&same(proof.departure.dispatches.map(x=>x.dispatchNo).sort(),members.map(x=>x.dispatchNo).sort())) + const departedTrip=proof.departedTrip=await request('GET',base),replay=await request('POST',departPath,depart,fixture.pdaToken) + check('Departure replay is explicit and does not change complete trip or stock',replay.replayed===true&&same(departedTrip,await request('GET',base))&&same(stock,await inventory('DEPARTED'))) + const history=await request('GET',`${base}/history?page=1&size=200`) + check('Complete trip history contains one actual departure',Number(history.total)===history.records.length&&history.records.filter(x=>x.action==='DEPART').length===1) + const departure=proof.departureHistory=history.records.find(x=>x.action==='DEPART') + const shares=proof.shares=members.map((m,i)=>({businessNo:i===2?receipt.bookingNo:m.dispatchNo,sourceType:i===2?'EXTERNAL':'OWN',dispatchNo:m.dispatchNo,customerId:m.customerId,palletSpaces:1,warehousePalletIds:[m.palletId]})) + assertSameRouteBasis(departure,shares) + const costPath=`${base}/costs`,before=await request('GET',`${costPath}?page=1&size=100`) + check('Current trip has no prior cost entries',Number(before.page.total)===0&&before.page.records.length===0&&before.summary.totals.length===0) + check('Empty cost query already uses exact original same-route departure',before.summary.allocationMethod==='SAME_ROUTE_PALLET_SPACES'&&before.summary.allocationBlockReason==null&&id(before.summary.departureVersion)===id(departure.recordVersion)&&before.summary.manifestHash===departure.snapshot.confirmedLoad.manifestHash) + proof.costRequest={idempotencyKey:key('COST-USD'),details:{category:'FUEL',currency:'USD',amount:'1.00',incurredAt:new Date().toISOString(),remark:`${context.marker} same-route synthetic cost`}} + const cost=proof.cost=await request('POST',costPath,proof.costRequest),result=proof.costResult=await request('GET',`${costPath}?page=1&size=100`) + check('Exactly one USD cost belongs to original trip',cost.tripNo===fixture.tripNo&&cost.costNo&&Number(cost.recordVersion)===0&&Number(result.page.total)===1&&result.page.records.length===1&&result.page.records[0].costNo===cost.costNo) + proof.expectedAllocations=assertSameRouteCost(result.summary,departure,shares) + check('Exact cost command replay returns same persisted cost and unchanged allocation',same(result.page.records[0],await request('POST',costPath,proof.costRequest))&&same(result,await request('GET',`${costPath}?page=1&size=100`))) + const costHistory=proof.costHistory=await request('GET',`${costPath}/${cost.costNo}/history?page=1&size=100`) + check('Only one immutable cost creation history',Number(costHistory.total)===1&&costHistory.records.length===1&&costHistory.records[0].action==='CREATE'&&costHistory.records[0].snapshot?.cost?.costNo===cost.costNo) + assertSameRouteCost(costHistory.records[0].snapshot.summary,departure,shares) + check('Cost writes preserve exact departure history and customer stock',same(history,await request('GET',`${base}/history?page=1&size=200`))&&same(stock,await inventory('COST_REPLAY'))) + for(const[index,m]of members.entries()){ + const path=`${ROOT}/delivery-orders/${m.deliveryNo}`,scope=`?customerId=${m.customerId}`,delivery=await request('GET',path+scope) + await request('PUT',`${path}/status${scope}`,{status:'DELIVERING',expectedVersion:delivery.order.version,remark:context.marker}) + const docs=await request('GET',`${path}/documents${scope}`),upload=await context.requestMultipart('POST',`${path}/documents/RECEIPT/files${scope}`,{name:`${context.marker}-${index}-receipt.txt`,type:'text/plain',content:new TextEncoder().encode(`${context.marker} synthetic receipt ${m.deliveryNo}`)}) + check('Actual receipt upload belongs to delivery',upload.http===200&&id(upload.body?.code)==='200'&&upload.body.data?.fileId) + const documents=(docs.categories||[]).filter(x=>x.category!=='RECEIPT').map(x=>({category:x.category,fileIds:(x.files||[]).map(f=>f.fileId)}));documents.push({category:'RECEIPT',fileIds:[id(upload.body.data.fileId)]}) + await request('PUT',`${path}/documents${scope}`,{expectedVersion:docs.version,idempotencyKey:key(`SIGN-${index}`),bolNo:docs.bolNo||null,documents}) + check('This actual delivery completed with saved receipt',(await request('GET',path+scope)).order.status==='DELIVERED') + } + check('Signing preserves all customer stock and original cost allocation',same(stock,await inventory('SIGNED'))&&same(result,await request('GET',`${costPath}?page=1&size=100`))) + proof.status='VERIFIED';return proof + }catch(error){proof.status='FAILED';proof.error=error.message;throw error} +} + +export async function runSameRouteCost(config,{env=process.env,...dependencies}={}) { + if(!config.execute)return {...config,scope:'TWO_FRESH_OWN_RECEIPTS_PLUS_ONE_EXTERNAL_SAME_ROUTE',cost:'USD 1.00, three independently proven pallet sources, largest remainder by business number',sqlWrites:false,deploy:false,cleanup:'Retain samples; disable own PDA and close own sessions'} + const files=['scripts/fleet-same-route-cost-test-env.mjs','scripts/admin-test-env-modules/fleet-external-receipt-acceptance.mjs','scripts/admin-test-env-modules/fleet-external-dispatch-acceptance.mjs','scripts/admin-test-env-modules/fleet-trip-cost-acceptance.mjs'],sources=files.map(path=>({path,sha256:createHash('sha256').update(fs.readFileSync(path)).digest('hex')})) + return runCrossCustomer(config,{...dependencies,env,verify:async(context,fixture,options)=>{ + fs.writeFileSync(resolve('reports',`fleet-cross-${config.runId}`,'same-route-source.json'),JSON.stringify({scope:'SAME_ROUTE_COST_WITH_TWO_OWN_AND_ONE_EXTERNAL',sources},null,2),{flag:'wx'}) + return verifySameRouteCost(context,fixture,options) + }}) +} +if(process.argv[1]&&import.meta.url===pathToFileURL(resolve(process.argv[1])).href){try{const config=parseCrossCustomerArguments(process.argv.slice(2)),result=await runSameRouteCost(config);console.log(JSON.stringify(config.execute?{success:result.success,error:result.error,summary:result.summary,requestCount:result.requestCount,goalComplete:false}:result,null,2));if(config.execute&&!result.success)process.exitCode=1}catch(error){console.error(error.message);process.exitCode=1}} diff --git a/scripts/fleet-same-route-cost-test-env.test.mjs b/scripts/fleet-same-route-cost-test-env.test.mjs new file mode 100644 index 000000000..d459a8e07 --- /dev/null +++ b/scripts/fleet-same-route-cost-test-env.test.mjs @@ -0,0 +1,40 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import {assertSameRouteCost,assertSameRouteBasis,runSameRouteCost,verifySameRouteCost} from './fleet-same-route-cost-test-env.mjs' +function sample(){ + const shares=[{businessNo:'DS-A',dispatchNo:'DS-A',sourceType:'OWN',customerId:'101',palletSpaces:1,warehousePalletIds:['1']},{businessNo:'DS-B',dispatchNo:'DS-B',sourceType:'OWN',customerId:'102',palletSpaces:1,warehousePalletIds:['2']},{businessNo:'BK-C',dispatchNo:'DS-C',sourceType:'EXTERNAL',customerId:'101',palletSpaces:1,warehousePalletIds:['3']}] + const departure={action:'DEPART',recordVersion:'7',snapshot:{trip:{stops:[{name:'Origin'},{destinationKey:'FBA:A'},{destinationKey:'FBA:B'}]},confirmedLoad:{manifestHash:'real-hash',pallets:shares.map(x=>({warehousePalletId:x.warehousePalletIds[0],palletSpaces:1,deliveryIndex:1})),cargo:shares.map(x=>({warehousePalletId:x.warehousePalletIds[0],dispatchNo:x.dispatchNo,customerId:x.customerId,packages:1,destinationKey:'FBA:A'}))},externalBookings:[{bookingNo:'BK-C',dispatchNo:'DS-C',customerId:'101',status:'BOUND',receipt:{source:{receivedPackages:1,pallets:[{warehousePalletId:'3',packages:1}]}}}]}} + // Three equal independent sources: the lexical first business number receives the residual cent. + const summary={departureVersion:'7',manifestHash:'real-hash',allocationMethod:'SAME_ROUTE_PALLET_SPACES',totals:[{currency:'USD',directCost:'1.00',ownCost:'0.66',externalCost:'0.34',allocations:shares.map(x=>({businessNo:x.businessNo,dispatchNo:x.dispatchNo,sourceType:x.sourceType,palletSpaces:1,amount:x.sourceType==='OWN'?'0.33':'0.34'}))}]} + return{shares,departure,summary} +} +test('offline entry does not call remote services and missing runtime scope is rejected',async()=>{ + assert.equal((await runSameRouteCost({execute:false},{env:{}})).deploy,false) + assert.equal((await verifySameRouteCost()).businessSqlWrites,false) + await assert.rejects(verifySameRouteCost({},null,{execute:true}),/FIXTURE/) +}) +test('exact three-source penny conservation follows real business-number tie break',()=>{ + const s=sample();assert.equal(assertSameRouteBasis(s.departure,s.shares).pallets.length,3) + assert.deepEqual(assertSameRouteCost(s.summary,s.departure,s.shares).map(x=>[x.businessNo,x.cents]),[['BK-C','34'],['DS-A','33'],['DS-B','33']]) +}) +for(const fault of ['wrong action','missing pallet','overlapping source','different destination','wrong customer','wrong dispatch','wrong receipt','unbound booking','uncovered external','stale version','stale hash','invented method','mixed currency','lost penny','wrong source type','wrong aggregate','wrong residual winner'])test(`rejects invalid allocation evidence: ${fault}`,()=>{ + const s=sample(),l=s.departure.snapshot.confirmedLoad,t=s.summary.totals[0],b=s.departure.snapshot.externalBookings[0] + if(fault==='wrong action')s.departure.action='PLAN' + if(fault==='missing pallet')l.pallets.pop() + if(fault==='overlapping source')s.shares[1].warehousePalletIds=['1'] + if(fault==='different destination')l.pallets[2].deliveryIndex=2 + if(fault==='wrong customer')l.cargo[0].customerId='FOREIGN' + if(fault==='wrong dispatch')l.cargo[0].dispatchNo='DS-FOREIGN' + if(fault==='wrong receipt')b.receipt.source.pallets[0].packages=2 + if(fault==='unbound booking')b.status='CONFIRMED' + if(fault==='uncovered external')s.departure.snapshot.externalBookings.push({...b,bookingNo:'BK-OTHER'}) + if(fault==='stale version')s.summary.departureVersion='8' + if(fault==='stale hash')s.summary.manifestHash='new-hash' + if(fault==='invented method')s.summary.allocationMethod='GUESS' + if(fault==='mixed currency')t.currency='CNY' + if(fault==='lost penny')t.allocations[0].amount='0.34' + if(fault==='wrong source type')t.allocations[2].sourceType='OWN' + if(fault==='wrong aggregate'){t.ownCost='0.67';t.externalCost='0.33'} + if(fault==='wrong residual winner'){t.allocations[0].amount='0.34';t.allocations[2].amount='0.33';t.ownCost='0.67';t.externalCost='0.33'} + assert.throws(()=>assertSameRouteCost(s.summary,s.departure,s.shares)) +}) diff --git a/scripts/fleet-test-env-acceptance.mjs b/scripts/fleet-test-env-acceptance.mjs new file mode 100644 index 000000000..79b8b54ff --- /dev/null +++ b/scripts/fleet-test-env-acceptance.mjs @@ -0,0 +1,1045 @@ +import { mkdirSync, writeFileSync, openSync, closeSync, unlinkSync, readFileSync, fsyncSync } from 'node:fs' +import { resolve, dirname, basename, relative, isAbsolute, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { createHash, randomUUID } from 'node:crypto' +import { AsyncLocalStorage } from 'node:async_hooks' +import { AcceptanceRun, parseArguments, validateExecutionOptions, buildAcceptanceSummary } from './admin-test-env-acceptance.mjs' +import { redactEvidence } from './fba-core-regression-test-env.mjs' +import { createTestEvidenceReaders, buildEvidenceRemoteScript, validateEvidenceConfig } from './fba-core-regression-evidence.mjs' +import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip, controlledPalletMeasurements } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' +import { verifyFleetBookingReservation } from './admin-test-env-modules/fleet-booking-reservation-acceptance.mjs' +import { verifyFleetFeeCoverage } from './admin-test-env-modules/fleet-fee-coverage-acceptance.mjs' +import { verifyFleetExternalReceipt } from './admin-test-env-modules/fleet-external-receipt-acceptance.mjs' +import { verifyFleetExternalDispatch } from './admin-test-env-modules/fleet-external-dispatch-acceptance.mjs' +import { verifyFleetHandover, cleanupFleetHandoverResources } from './admin-test-env-modules/fleet-handover-acceptance.mjs' +import { verifyFleetTripCosts } from './admin-test-env-modules/fleet-trip-cost-acceptance.mjs' +import { verifyFleetReturnRedispatch, waitForRedispatchSignatureAudit, selectReturnSources } from './admin-test-env-modules/fleet-return-redispatch-acceptance.mjs' +import { resolvePickerAccess, createPicker, disablePicker } from './admin-test-env-modules/phase4-overseas-pda.mjs' +import { pickAll, saveReceiptAndComplete } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const MODULE = 'fleet-specialized-acceptance' +const OPTIONS = { scenario: MODULE, deploymentBlocker: true } +const id = value => value == null ? '' : String(value) +const success = response => response?.http === 200 && id(response?.body?.code) === '200' +const key = () => `fleet-${randomUUID()}` + +/** Install before login/prerequisites. A caught unknown write must not permit another request. */ +export function guardFleetWrites(context, checkpoint, { guardFetch = false } = {}) { + let stopped = null, sequence = 0 + const scope = new AsyncLocalStorage(), originalFetch = globalThis.fetch + const direct = { async requestWithBody(method, path, body, token, headers, request) { + const nativeResponse = await originalFetch(request), copy = nativeResponse.clone() + const bytes = new Uint8Array(await copy.arrayBuffer()), contentType = nativeResponse.headers.get('content-type') || '' + const content = /json|text|xml|html/i.test(contentType) ? new TextDecoder().decode(bytes) : '' + let payload = null + try { payload = content ? JSON.parse(content) : null } catch { /* Keep bounded diagnostic only. */ } + return { nativeResponse, http: nativeResponse.status, body: payload, bytes, contentType, raw: payload || !content ? null : content.slice(0, 500), + requestId: payload?.requestId || nativeResponse.headers.get('x-request-id'), + traceId: payload?.traceId || nativeResponse.headers.get('x-trace-id') } + } } + const targets = ['request', 'requestWithBody', 'requestBinary'].map(name => [context, name]) + if (guardFetch) targets.push([direct, 'requestWithBody']) + for (const [owner, name] of targets) { + const original = owner[name] + if (typeof original !== 'function') continue + owner[name] = async function (method, path, body, token, headers) { + if (stopped) throw stopped + if (['GET', 'HEAD', 'OPTIONS'].includes(String(method).toUpperCase())) + return scope.run(true, () => original.apply(this, arguments)) + const entry = { sequence: ++sequence, method, path, status: 'SENDING', startedAt: new Date().toISOString(), + headers, body } + let session = owner === direct ? String(headers?.authorization || '').replace(/^Bearer\s+/i, '') + : token === undefined ? this.token : token + if (owner !== direct && this.pdaToken && /\/pda-loading\/tasks\/\d+/.test(String(path))) session = this.pdaToken + entry.actor = session && session === context.pdaToken ? { kind: 'PDA', source: 'CONTEXT_PDA_SESSION' } + : session && session === context.token && context.options?.username ? { kind: 'PLATFORM', source: 'CONTEXT_ADMIN_SESSION' } + : { kind: 'UNKNOWN', source: session ? 'UNRESOLVED_SESSION' : 'NO_SESSION' } + // Multipart delegates to requestWithBody, so it is journaled exactly once, after construction. + if (body instanceof FormData) { + entry.body = { multipart: [] } + for (const [field, value] of body.entries()) entry.body.multipart.push(typeof value === 'string' + ? { field, value } : { field, name: value.name, type: value.type, + base64: Buffer.from(await value.arrayBuffer()).toString('base64') }) + } + try { await checkpoint(entry) } catch (error) { stopped = error; throw error } + try { + const response = await scope.run(true, () => original.apply(this, arguments)) + const http = response?.http, payload = response?.body, code = id(payload?.code) + const expectsPdf = name === 'requestBinary' || owner === direct && /(?:^|,)\s*application\/pdf(?:\s*[;,]|$)/i.test(headers?.accept || '') + const binary = (name === 'requestBinary' || owner === direct) && response?.bytes instanceof Uint8Array + const pdf = expectsPdf && binary && http === 200 && /^application\/pdf(?:\s*;|$)/i.test(response.contentType || '') + && response.bytes.length > 4 && Buffer.from(response.bytes.subarray(0, 5)).toString('ascii') === '%PDF-' + const definite = Number.isInteger(http) && http >= 200 && http < 500 + && response.requestId && response.traceId + && (pdf || payload && typeof payload === 'object' && !Array.isArray(payload) + && (!expectsPdf && http === 200 && code === '200' || /^[A-Z]\d{4}$/.test(code) && (http === 200 || http >= 400))) + Object.assign(entry, { status: definite ? 'RESPONDED' : 'UNKNOWN_RESULT', http, code, + requestId: response?.requestId, traceId: response?.traceId, raw: boundedFleetDiagnostic(response?.raw), + responseBody: redactFleetWrite(response?.body), finishedAt: new Date().toISOString() }) + if (binary) entry.binary = { contentType: response.contentType, bytes: response.bytes.length, + sha256: createHash('sha256').update(response.bytes).digest('hex'), validPdf: Boolean(pdf) } + if (!definite) stopped = Error(`Unknown write outcome: ${method} ${path}; preserve original request, do not resend`) + await checkpoint(entry) + if (stopped) throw stopped + return response + } catch (error) { + stopped ||= Error(boundedFleetDiagnostic(error instanceof Error ? error.message : String(error)) || 'Fleet write failed') + if (entry.status === 'SENDING') { + entry.status = 'UNKNOWN_RESULT'; entry.finishedAt = new Date().toISOString() + entry.raw = boundedFleetDiagnostic(error instanceof Error ? error.message : String(error)) + await checkpoint(entry) + } + throw stopped + } + } + } + if (guardFetch) globalThis.fetch = async (input, init) => { + if (scope.getStore()) return originalFetch(input, init) + if (stopped) throw stopped + const request = new Request(input, init), url = new URL(request.url) + if (url.origin !== new URL(context.options.gateway).origin) return originalFetch(input, init) + if (['GET', 'HEAD', 'OPTIONS'].includes(request.method)) return originalFetch(input, init) + const type = request.headers.get('content-type') || '' + let body + if (request.body) body = type.includes('multipart/form-data') ? await request.clone().formData() + : type.includes('application/json') ? await request.clone().json() : await request.clone().text() + const result = await direct.requestWithBody(request.method, url.pathname + url.search, body, undefined, + Object.fromEntries(request.headers), request) + return result.nativeResponse + } + return () => { if (guardFetch) globalThis.fetch = originalFetch } +} + +function boundedFleetDiagnostic(value) { + return typeof value === 'string' ? value.replace(/Bearer\s+[^\s"'<>]+/gi, 'Bearer [REDACTED]') + .replace(/((?:password|accessToken|refreshToken|authorization)["']?\s*[=:]\s*["']?)[^\s,;"'<>]+/gi, '$1[REDACTED]') + .replace(/https?:\/\/[^\s"'<>]+/gi, '[REDACTED_URL]').slice(0, 500) : null +} + +export function redactFleetWrite(value, secrets = []) { + const sensitive = name => name !== 'draftToken' && /password|authorization|cookie|privateKey|api.?key|secret|token$|^(?:loginId|username)$/i.test(name) + if (Array.isArray(value)) return value.map(item => redactFleetWrite(item, secrets)) + if (value && typeof value === 'object' && typeof value.field === 'string' && sensitive(value.field)) + return { field: value.field, value: '[REDACTED]' } + if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([name, item]) => [name, + sensitive(name) + ? '[REDACTED]' : redactFleetWrite(item, secrets)])) + if (typeof value === 'string') return secrets.filter(secret => typeof secret === 'string' && secret.length) + .reduce((text, secret) => text.split(secret).join('[REDACTED]'), value) + return value +} + +export function parseFleetArguments(args) { + const config = { execute: false, environment: 'TEST', runId: 'plan', scenario: 'handover' } + let action = null + const seen = new Set() + for (let index = 0; index < args.length; index++) { + const name = args[index] + if (['--plan', '--execute'].includes(name)) { + if (action) throw Error('Choose exactly one plan or execute action') + action = name; config.execute = name === '--execute'; continue + } + if (!['--environment', '--run-id', '--output', '--resume-report', '--resume-stage', '--resume-sha256', '--scenario'].includes(name) || !args[index + 1] || args[index + 1].startsWith('--')) throw Error('Unsupported or missing argument') + if (seen.has(name)) throw Error('Duplicate argument') + seen.add(name) + const value = args[++index] + if (name === '--environment') config.environment = value + else if (name === '--run-id') config.runId = value + else if (name === '--output') config.outputDir = value + else if (name === '--resume-report') config.resumeReport = value + else if (name === '--resume-sha256') config.resumeSha256 = value + else if (name === '--scenario') config.scenario = value + else config.resumeStage = value + } + if (config.environment !== 'TEST' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId) + || (config.execute && (!args.includes('--environment') || config.runId === 'plan'))) throw Error('Explicit TEST and fresh run-id required') + if (Boolean(config.resumeReport) !== Boolean(config.resumeStage) || (config.resumeStage && !['FULL_COVERAGE', 'FULL_COVERAGE_PERSISTED', 'EXTERNAL_RECEIPT_READY', 'EXTERNAL_PALLETIZED', 'EXTERNAL_RECEIPT_CONFIRMED', 'TERMINAL_READONLY', 'DEPARTED_BEFORE_COSTS', 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER', 'DEPARTED_VEHICLE_CREATED', 'RETURN_SOURCE_SELECTION_BEFORE_RETURN'].includes(config.resumeStage))) throw Error('Explicit reviewed checkpoint required') + if (config.resumeStage?.startsWith('DEPARTED_') || config.resumeStage === 'RETURN_SOURCE_SELECTION_BEFORE_RETURN' ? !/^[a-f0-9]{64}$/.test(config.resumeSha256 || '') : config.resumeSha256) + throw Error('Departed recovery requires the exact reviewed report SHA256') + if (config.outputDir && !/^reports\/[A-Za-z0-9][A-Za-z0-9_-]{0,95}$/.test(config.outputDir)) throw Error('Output must be a fresh named directory directly under reports') + if (!['handover', 'return-redispatch'].includes(config.scenario) || (config.scenario === 'return-redispatch' && config.resumeReport && config.resumeStage !== 'RETURN_SOURCE_SELECTION_BEFORE_RETURN') + || (config.resumeStage === 'RETURN_SOURCE_SELECTION_BEFORE_RETURN' && config.scenario !== 'return-redispatch')) + throw Error('Return redispatch only accepts a fresh run, never a reviewed old checkpoint') + return config +} +export function fleetExecutionPlan(config) { + const afterCosts = ['DEPARTED_AFTER_COSTS_BEFORE_HANDOVER', 'DEPARTED_VEHICLE_CREATED'].includes(config.resumeStage) + return { environment: 'TEST', execution: config.execute ? 'EXECUTE' : 'PLAN', runId: config.runId, scenario: config.scenario || 'handover', + returnRedispatch: config.scenario === 'return-redispatch' ? { required: 'Two current-run domestic sources; A >=3 boxes; current departure covers A and B in full', + stages: ['A actual return 2', 'original remaining signatures; A incomplete and B complete', 'RETURN_RECEIPT draft lock and pre-arrange', 'new PDA picks and DEPART 2', 'redispatch signature; A complete', 'source event/ACK read-only evidence remains required'] } : null, + outputDir: config.outputDir || `reports/fleet-${config.runId}`, producer: config.resumeReport ? `Reviewed ${config.resumeStage} checkpoint in the same original fixture` : 'phase4-overseas-dispatch with its normal fresh prerequisites', + resumeReport: config.resumeReport || null, resumeStage: config.resumeStage || null, resumeSha256: config.resumeSha256 || null, + stages: config.resumeStage === 'RETURN_SOURCE_SELECTION_BEFORE_RETURN' + ? ['Verify exact source-selection-failure report SHA256 and three definitive delivery starts', 'Read-only original DEPART/handover/cost/cargo/resources and DELIVERING versions', + 'Recheck repaired A/B return candidates and complete source quantities before any write', 'Create fresh bound PDA session', + 'Original sample actual return and redispatch; no repeated original departure/handover/cost/start-delivery', + 'Wait for coordinator pre-signature A0/B1 event evidence gate', 'New signature and source ACK evidence; close new and original resources'] + : config.resumeStage?.startsWith('DEPARTED_') + ? ['Verify reviewed report SHA256', afterCosts + ? 'Read-only exact trip/version/DEPART/cargo, unchanged verified costs, empty handovers and unsigned deliveries' + : 'Read-only exact original trip/version/DEPART/cargo and empty costs/handovers/unsigned deliveries', + ...(config.resumeStage === 'DEPARTED_VEHICLE_CREATED' ? ['Verify exact successful vehicle creation, complete current vehicle snapshot and zero resource assignments', 'Explicitly reuse the created vehicle; skip its creation POST and continue from new driver'] : []), + afterCosts ? 'Create fresh PDA session; retain verified costs' : 'Create fresh bounded cost/PDA sessions', + afterCosts ? 'Match original verified cost snapshot; no cost commands' : 'Original trip operating costs with actual allocation availability', + 'Retain original DEPART; no picking or departure commands', + 'In-transit handover and PDA readback', 'Original delivery signatures and resource release', 'Close newly created test sessions'] + : ['FULL original coverage', 'formal cancel/unbind/re-arrange', 'PARTIAL original coverage', 'reservation lifecycle', + 'external actual receipt', 'external dispatch and agreed fee', 'Finance consumer persistence', 'each dispatch PDA picking', + 'atomic whole-trip departure', 'actual in-transit handover and PDA readback', 'same-fixture optional H5 checkpoint', + 'per-destination receipt and replacement-resource release', 'explicit handover-fixture cleanup'], + boundaries: 'TEST only; serial fresh fixtures; no SQL writes, deployment or configuration changes. Failure retains original requests/resources. No automatic rerun/resume.', + limits: 'Finance pool proves consumer persistence; producer ACK requires separate event evidence. UI and remaining fleet product scope are separate.' } +} + +export function reviewedFullCheckpoint(report, stage = 'FULL_COVERAGE') { + const resources = report?.createdResources, proof = resources?.fleetAcceptance, full = resources?.fleetFeeCoverageCases?.[0] + const trip = resources?.fleetTrips?.find(row => row.tripNo === proof?.tripNo) + if (stage === 'RETURN_SOURCE_SELECTION_BEFORE_RETURN') { + const attempt = trip?.returnRedispatch, originals = attempt?.originalDeliveries, writes = attempt?.requests?.filter(row => row.method !== 'GET') + const failures = report?.steps?.filter(row => row.passed === false) + if (report?.success !== false || proof?.stage !== 'RETURN_REDISPATCH' || proof.status !== 'CHECKING' + || proof.marker !== attempt?.marker || trip?.costAcceptance?.status !== 'VERIFIED' || trip.handover?.status !== 'VERIFIED' + || proof.departure?.status !== 'IN_TRANSIT' || proof.departure.tripNo !== trip.tripNo + || trip.closure || proof.closure || proof.receipts?.length || attempt.status !== 'FAILED' + || attempt.failure !== 'Fresh redispatch fixture requires source A with at least 3 boxes and a separate domestic source B' + || attempt.exceptionNo || attempt.sources || attempt.newTripNo || attempt.originalSignatures + || originals?.length !== 3 || new Set(originals.map(row => row.deliveryNo)).size !== 3 || writes?.length !== 3 + || originals.some(row => id(row.customerId) !== id(proof.customerId) || id(row.overseasWarehouseId) !== id(proof.warehouseId) + || !proof.dispatchNos.includes(row.dispatchNo) || row.status !== 'WAIT_DELIVERY' || row.signedAt || Number(row.signedPackages || 0)) + || attempt.requests.some(row => row.status !== 'RESPONDED' || row.http !== 200 || id(row.code) !== '200' || !row.requestId || !row.traceId) + || writes.some(row => { + const original = originals.find(item => item.deliveryNo === row.result?.deliveryNo) + return !original || row.method !== 'PUT' || row.path !== `/api/platform/v1/last-leg/delivery-orders/${original.deliveryNo}/status?customerId=${proof.customerId}` + || row.body?.status !== 'DELIVERING' || id(row.body.expectedVersion) !== id(original.version) + || row.result.deliveryStatus !== 'DELIVERING' || row.result.replayed !== false + }) || new Set(writes.map(row => row.result?.deliveryNo)).size !== 3 + || resources.fleetCostActors?.status !== 'DISABLED' || failures?.length !== 1 || failures[0].name !== '模块执行' + || failures[0].evidence !== attempt.failure) + throw Error('Return source recovery requires the exact known candidate failure and three definitive delivery starts, with no return/signature writes') + return { resources, marker: proof.marker, returnSourceSelection: true } + } + if (stage === 'DEPARTED_BEFORE_COSTS' || stage === 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER' || stage === 'DEPARTED_VEHICLE_CREATED') { + const vehicleCreated = stage === 'DEPARTED_VEHICLE_CREATED' + const afterCosts = stage !== 'DEPARTED_BEFORE_COSTS' + const costs = trip?.costAcceptance, failures = report?.steps?.filter(row => row.passed === false) + const failureName = vehicleCreated ? '接替车辆返回真实身份及车牌' : afterCosts ? '每张排车已发车且货量与客户起运仓一致' : '独立预期覆盖全部实物且没有重叠托盘' + const handover = trip?.handover + const creation = vehicleCreated ? handover?.requests?.filter(row => row.method !== 'GET') : null + if (vehicleCreated && (creation?.length !== 1 || creation[0].method !== 'POST' + || creation[0].path !== '/api/platform/v1/last-leg/master-data/vehicles' || creation[0].status !== 'RESPONDED' + || creation[0].http !== 200 || id(creation[0].code) !== '200' || !creation[0].requestId || !creation[0].traceId + || !/^[1-9]\d*$/.test(id(creation[0].result?.id)) || id(creation[0].result.recordVersion) !== '0' + || creation[0].result.plateNumber !== creation[0].body?.plateNumber?.toUpperCase() + || creation[0].result.status !== 'IN_SERVICE' || handover.replacement?.driverId)) + throw Error('Created vehicle recovery requires the exact definitive successful creation and no later handover write') + if (report?.success !== false || proof?.stage !== (afterCosts ? 'IN_TRANSIT_HANDOVER' : 'TRIP_OPERATING_COSTS') || proof.status !== 'CHECKING' + || proof.marker !== costs?.marker || proof.departure?.status !== 'IN_TRANSIT' || proof.departure.tripNo !== trip?.tripNo + || !proof.departureRequest?.idempotencyKey || !costs?.departure?.snapshot?.confirmedLoad?.manifestHash + || costs.status !== (afterCosts ? 'VERIFIED' : 'FAILED') || !Array.isArray(costs.costs) || costs.costs.length !== (afterCosts ? 2 : 0) || !costs.requests?.length + || costs.requests.some(row => row.status !== 'RESPONDED' || !row.requestId || !row.traceId + || (afterCosts ? ![200, 403].includes(row.http) || !['200', 'A0403', 'A0409'].includes(id(row.code)) : row.method !== 'GET' || row.http !== 200 || id(row.code) !== '200')) + || (afterCosts ? handover?.status !== 'FAILED' || !handover.requests?.length || handover.confirmRequest || handover.selection + || handover.requests.some(row => (row.method !== 'GET' && row !== creation?.[0]) || row.status !== 'RESPONDED' || row.http !== 200 || id(row.code) !== '200') + || !costs.final?.page || costs.final.page.records?.length !== 2 : trip.handover) + || trip.closure || proof.receipts || proof.closure || trip.returnRedispatch + || resources.fleetCostActors?.status !== 'DISABLED' || !proof.picker?.membershipId + || failures?.length !== 2 || !failures.some(row => row.name === failureName) + || failures.some(row => ![failureName, '模块执行'].includes(row.name))) + throw Error('Departed checkpoint must be the reviewed read-only cost assertion failure, with no later business writes') + return { resources, marker: proof.marker, departed: true, costsAlreadyVerified: afterCosts, + createdVehicle: vehicleCreated ? { snapshot: creation[0].result, creationRequest: creation[0] } : null } + } + if (stage === 'TERMINAL_READONLY') { + const failures = report?.steps?.filter(row => row.passed === false) + if (report?.success !== false || proof?.stage !== 'RECEIPTS_AND_RESOURCE_RELEASE' + || trip?.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' + || proof.closure?.status !== trip.closure.status || trip.externalReceipt?.status !== 'VERIFIED' + || trip.externalDispatch?.status !== 'VERIFIED' || trip.reassignment?.status !== 'VERIFIED' + || !proof.receipts?.length || proof.finance?.status !== 'CONSUMER_PERSISTED' + || !failures?.length || failures.some(row => !['本次车次货物结案及车辆司机释放完成', '模块执行'].includes(row.name))) + throw Error('Terminal reconciliation requires completed original business proofs and only the known aggregate assertion failure') + return { resources, marker: proof.marker, terminal: true } + } + if (stage === 'EXTERNAL_PALLETIZED' || stage === 'EXTERNAL_RECEIPT_CONFIRMED') { + const confirmed = stage === 'EXTERNAL_RECEIPT_CONFIRMED' + const partial = resources?.fleetFeeCoverageCases?.find(row => row.mode === 'PARTIAL'), receipt = trip?.externalReceipt + if (report?.success !== false || proof?.stage !== 'EXTERNAL_RECEIPT' || proof.status !== 'CHECKING' + || full?.proof?.status !== 'VERIFIED' || partial?.proof?.status !== 'VERIFIED' || partial.dispatchNo !== trip?.dispatchNo + || trip.reservation?.status !== 'VERIFIED' || receipt?.status !== 'CHECKING' || trip.externalDispatch + || (confirmed ? (!receipt.receiptRequest?.idempotencyKey || !receipt.requests?.some(row => row.label === '运营按实际1托位和新单价30确认实收' && row.method === 'POST' && row.http === 200 && String(row.code) === '200')) : receipt.receiptRequest) + || !receipt.palletizeRequest?.idempotencyKey || !receipt.warehousePalletId || !proof.picker?.membershipId || !proof.pickerMarker + || !receipt.requests?.some(row => row.label === 'PDA正式完成外接收货及库存入账' && row.method === 'POST' && row.http === 200 && String(row.code) === '200')) + throw Error('Palletized checkpoint requires the exact successful PDA command and no subsequent receipt confirmation') + return { resources, marker: proof.marker, receiptReady: true, palletized: !confirmed, receiptConfirmed: confirmed } + } + if (stage === 'EXTERNAL_RECEIPT_READY') { + const partial = resources?.fleetFeeCoverageCases?.find(row => row.mode === 'PARTIAL') + if (report?.success !== false || proof?.stage !== 'RESERVATIONS' || proof.status !== 'CHECKING' + || full?.proof?.status !== 'VERIFIED' || partial?.proof?.status !== 'VERIFIED' || partial.dispatchNo !== trip?.dispatchNo + || trip.reservation?.status !== 'VERIFIED' || trip.externalReceipt || trip.externalDispatch || proof.picker + || !report.steps?.some(row => row.name === '创建海外PDA仓库操作员71' && row.code === 'A0409' && row.message === '手机号已存在')) + throw Error('Receipt-ready checkpoint requires verified coverage/reservations and an explicitly rejected operator creation with no receipt writes') + return { resources, marker: proof.marker, receiptReady: true } + } + if (report?.success !== false || proof?.stage !== 'FULL_COVERAGE' || proof.status !== 'CHECKING' || full?.mode !== 'FULL' + || full.marker !== proof.marker || full.dispatchNo !== trip?.dispatchNo || full.proof?.status !== 'CHECKING' + || !full.proof.requests?.length + || resources.fleetFeeCoverageCases.length !== 1 || !proof.originalDispatch || proof.originalDispatch.dispatchNo !== full.dispatchNo) + throw Error('Only a reviewed read-only FULL_COVERAGE failure can resume; mutated/unknown outcomes require case-specific reconciliation') + const persisted = stage === 'FULL_COVERAGE_PERSISTED' + if (persisted ? (!full.proof.outboundNo || !full.proof.deliveryNos?.length || !full.proof.confirmationRequest?.idempotencyKey + || !full.proof.requests.some(row => row.label === '正式保存本次原费用服务覆盖并转派送' && row.method === 'POST' && row.http === 200 && String(row.code) === '200')) + : (full.proof.requests.some(row => row.method !== 'GET') || full.proof.outboundNo)) + throw Error('Mutated/unknown outcomes require the exact successful command and persisted document reconciliation') + return { resources, marker: proof.marker, persisted } +} + +export async function verifyFleetTerminalState(context) { + const proof = context.createdResources.fleetAcceptance + const trip = context.createdResources.fleetTrips.find(row => row.tripNo === proof.tripNo) + const partial = context.createdResources.fleetFeeCoverageCases.find(row => row.mode === 'PARTIAL') + const evidence = proof.terminalReadback = { status: 'CHECKING', requests: [], deliveries: [], dispatches: [] } + function check(label, passed, response) { + context.assert(MODULE, label, response, Boolean(passed), '', OPTIONS) + if (!passed) throw Error(label) + } + async function get(path) { + const result = await context.request('GET', path) + evidence.requests.push({ path, http: result.http, code: result.body?.code, requestId: result.requestId, traceId: result.traceId }) + check('终态只读回查成功', success(result) && result.requestId && result.traceId, result) + return result.body.data + } + check('结案助手已完成货物与资源核对', trip.closure?.status === 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') + const current = await get(`/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(trip.tripNo)}`) + check('当前车次已完成且两项资源结束事实与原证明一致', current.trip?.status === 'COMPLETED' + && current.trip.completedAt === trip.closure.finalTrip.completedAt + && id(current.trip.recordVersion) === id(trip.closure.finalTrip.recordVersion) + && JSON.stringify(current.trip.vehicleRelease) === JSON.stringify(trip.closure.finalTrip.vehicleRelease) + && JSON.stringify(current.trip.driverRelease) === JSON.stringify(trip.closure.finalTrip.driverRelease) + && current.trip.vehicleRelease && current.trip.driverRelease) + evidence.trip = current.trip + const expectedDispatches = [partial.dispatchNo, trip.externalDispatch.dispatchNo].sort() + check('终态车次仍关联全部原执行单', JSON.stringify(current.dispatches.map(row => row.dispatchNo).sort()) === JSON.stringify(expectedDispatches)) + for (const number of expectedDispatches) { + const dispatch = await get(`/api/platform/v1/last-leg/dispatches/${encodeURIComponent(number)}`) + check('原执行单已发车且拣货实发数量守恒', dispatch.dispatchNo === number && dispatch.status === 'DEPARTED' + && Number(dispatch.packageCount) === Number(dispatch.shippedPackageCount) + && Number(dispatch.pickedPackageCount) === Number(dispatch.shippedPackageCount)) + evidence.dispatches.push(dispatch) + } + check('整车最终箱数与原一次发车事实一致', evidence.dispatches.reduce((n, row) => n + Number(row.shippedPackageCount), 0) === Number(proof.departure.shippedPackages)) + const expectedDeliveries = [...partial.proof.deliveryNos, trip.externalDispatch.deliveryNo].sort() + check('签收证明覆盖全部目的地派送', JSON.stringify(proof.receipts.map(row => row.deliveryNo).sort()) === JSON.stringify(expectedDeliveries)) + for (const number of expectedDeliveries) { + const delivery = await get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(number)}?customerId=${encodeURIComponent(proof.customerId)}`) + check('每张原派送保留完成及签收时间', delivery.order?.deliveryNo === number && delivery.order.status === 'DELIVERED' + && delivery.order.signedAt === proof.receipts.find(row => row.deliveryNo === number).signedAt) + evidence.deliveries.push(delivery.order) + } + for (const probe of trip.closure.probes) { + const detail = await get(`/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(probe.tripNo)}`) + check('资源占用测试计划均已正式取消', detail.trip?.status === 'CANCELLED') + } + const pool = await get(`/api/platform/finance/v1/fee-pool?customerId=${encodeURIComponent(proof.customerId)}&direction=RECEIVABLE`) + const fees = pool.filter(row => id(row.sourceId) === id(proof.finance.source.sourceFeeId) && Number(row.sourceVersion) === Number(proof.finance.source.sourceVersion)) + check('原外接应收仍唯一且金额正确', fees.length === 1 && fees[0].amount === 30 && fees[0].currency === 'CNY' + && id(fees[0].customerId) === id(proof.customerId) && fees[0].sourceType === 'OVERSEAS_DELIVERY_FEE') + evidence.fees = fees; evidence.status = 'VERIFIED' + proof.status = 'HTTP_CHAIN_VERIFIED_WITH_ACK_GAP'; proof.stage = 'FINISHED' + return proof +} + +/** All business checks happen before creating replacement test sessions or issuing a business command. */ +export async function verifyDepartedRecovery(context, { costsAlreadyVerified = false, createdVehicle, returnSourceReady = false } = {}) { + const proof = context.createdResources.fleetAcceptance + const trip = context.createdResources.fleetTrips.find(row => row.tripNo === proof.tripNo) + const original = trip.costAcceptance.departure, snapshot = original.snapshot, load = snapshot.confirmedLoad + const expectedTrip = returnSourceReady ? trip.handover.finalTrip : snapshot.trip + const base = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(trip.tripNo)}` + const evidence = { status: 'CHECKING', tripNo: trip.tripNo, requests: [], dispatchDetails: [], deliveries: [] } + context.createdResources.fleetDepartureRecoveryVerification = evidence + const check = (ok, message) => { if (!ok) throw Error(`Departed recovery refused: ${message}`) } + const same = (a, b) => JSON.stringify(a) === JSON.stringify(b) + async function get(path) { + const r = await context.request('GET', path) + evidence.requests.push({ path, http: r.http, code: r.body?.code, requestId: r.requestId, traceId: r.traceId }) + check(success(r) && r.requestId && r.traceId, 'read-only response not definitive') + return r.body.data + } + const current = await get(base) + check(current.trip?.status === 'IN_TRANSIT' && id(current.trip.recordVersion) === id(expectedTrip.recordVersion) + && current.trip.departedAt === proof.departure.departedAt && id(current.trip.warehouseId) === id(proof.warehouseId) + && id(current.trip.vehicleId) === id(expectedTrip.vehicleId) && id(current.trip.driverId) === id(expectedTrip.driverId) + && !current.trip.vehicleRelease && !current.trip.driverRelease, 'trip version, resources or departure changed') + const history = await get(`${base}/history?page=1&size=100`) + const departures = history.records?.filter(row => row.action === 'DEPART') + check(Array.isArray(history.records) && Number(history.total) === history.records.length + && departures?.length === 1 && same(departures[0], original) + && history.records.every(row => BigInt(row.recordVersion) <= BigInt(expectedTrip.recordVersion)), 'immutable DEPART/history changed') + if (returnSourceReady) { + const expectedHistory = trip.handover.requests.filter(row => row.method === 'GET' && row.path.startsWith(`${base}/history?`)).at(-1)?.result + check(expectedHistory && same(history.records, expectedHistory.records), 'verified handover history changed') + check(typeof context.context?.readRecoveryTripResources === 'function', 'current resource occupancy reader missing') + const bindings = await context.context.readRecoveryTripResources(trip.tripNo) + check(bindings.length === 2 && bindings.every(row => row.tripNo === trip.tripNo) + && bindings.some(row => row.resourceKind === 'VEHICLE' && id(row.resourceId) === id(expectedTrip.vehicleId)) + && bindings.some(row => row.resourceKind === 'DRIVER' && id(row.resourceId) === id(expectedTrip.driverId)), 'current resources no longer belong to the original trip') + evidence.resourceBindings = bindings + const vehicleSnapshot = trip.handover.requests.find(row => row.method === 'POST' + && row.path === '/api/platform/v1/last-leg/master-data/vehicles' && id(row.result?.id) === id(expectedTrip.vehicleId))?.result + const driverSnapshot = trip.handover.requests.find(row => row.method === 'POST' + && row.path === `/api/platform/v1/last-leg/fleet/drivers/${expectedTrip.driverId}/review`)?.result + check(vehicleSnapshot && driverSnapshot, 'verified current resource snapshots missing') + const vehicle = await get(`/api/platform/v1/last-leg/master-data/vehicles/${expectedTrip.vehicleId}`) + const driver = await get(`/api/platform/v1/last-leg/fleet/drivers/${expectedTrip.driverId}`) + check(same(vehicle, vehicleSnapshot), 'current vehicle snapshot changed') + check(['id', 'recordVersion', 'active', 'reviewStatus', 'licenseFileId', 'details'].every(field => same(driver[field], driverSnapshot[field])), 'current driver identity/version/qualification facts changed') + evidence.currentVehicle = vehicle; evidence.currentDriver = driver + } + const costs = await get(`${base}/costs?page=1&size=100`), handovers = await get(`${base}/handovers`) + check((costsAlreadyVerified ? same(costs, trip.costAcceptance.final) + : costs.page?.records?.length === 0 && Number(costs.page.total) === 0 && costs.summary?.totals?.length === 0) + && costs.summary.manifestHash === load.manifestHash && id(costs.summary.departureVersion) === id(original.recordVersion), 'costs already written or cargo basis changed') + if (returnSourceReady) { + const expected = trip.handover.requests.filter(row => row.method === 'GET' && row.path.startsWith(`${base}/handovers?`)).at(-1)?.result + check(expected && Number(handovers.total) === 1 && same(handovers.records, expected.records), 'original verified handover changed') + } else check(handovers.records?.length === 0 && Number(handovers.total) === 0, 'handover already exists') + check(same(current.dispatches.map(row => row.dispatchNo).sort(), [...proof.dispatchNos].sort()) + && current.dispatches.every(row => id(row.customerId) === id(proof.customerId)), 'trip dispatch binding changed') + for (const number of proof.dispatchNos) { + const detail = await get(`/api/platform/v1/last-leg/dispatches/${encodeURIComponent(number)}`) + const cargo = load.cargo.filter(row => row.dispatchNo === number) + const facts = rows => rows.map(row => [id(row.id ?? row.dispatchItemId), id(row.warehousePalletId), Number(row.packageCount ?? row.packages)]) + .sort((a, b) => a[0].localeCompare(b[0])) + check(detail.dispatchNo === number && detail.status === 'DEPARTED' && id(detail.overseasWarehouseId) === id(proof.warehouseId) + && Number(detail.packageCount) === Number(detail.shippedPackageCount) && Number(detail.pickedPackageCount) === Number(detail.shippedPackageCount) + && same(facts(detail.items), facts(cargo)), 'dispatch shipped cargo changed') + evidence.dispatchDetails.push(detail) + } + const partial = context.createdResources.fleetFeeCoverageCases.find(row => row.mode === 'PARTIAL') + const deliveryNos = [...partial.proof.deliveryNos, trip.externalDispatch.deliveryNo] + check(new Set(deliveryNos).size === deliveryNos.length, 'duplicate delivery identities') + for (const number of deliveryNos) { + const detail = await get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(number)}?customerId=${encodeURIComponent(proof.customerId)}`) + const order = detail.order + const started = returnSourceReady ? trip.returnRedispatch.requests.find(row => row.method === 'PUT' && row.result?.deliveryNo === number) : null + check(order?.deliveryNo === number && id(order.customerId) === id(proof.customerId) && proof.dispatchNos.includes(order.dispatchNo) + && order.status === (returnSourceReady ? 'DELIVERING' : 'WAIT_DELIVERY') + && id(order.version) === (returnSourceReady ? String(Number(started?.body.expectedVersion) + 1) : '1') + && (!returnSourceReady || order.vehiclePlate === expectedTrip.vehiclePlate && order.driverName === expectedTrip.driverName && order.driverPhone === expectedTrip.driverPhone) + && !order.signedAt && !Number(order.signedPackages || 0) + && !order.completedAt && order.documentSummary?.categories?.every(row => Number(row.fileCount) === 0), 'delivery has started, changed or been signed') + evidence.deliveries.push(order) + } + check(evidence.deliveries.reduce((n, row) => n + Number(row.packageCount), 0) === Number(proof.departure.shippedPackages), 'delivery quantity mismatch') + if (createdVehicle) { + const vehicle = await get(`/api/platform/v1/last-leg/master-data/vehicles/${encodeURIComponent(createdVehicle.snapshot.id)}`) + const canonical = value => value && typeof value === 'object' && !Array.isArray(value) + ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value + check(same(canonical(vehicle), canonical(createdVehicle.snapshot)), 'created vehicle snapshot changed') + check(typeof context.context?.readReviewedVehicleOccupation === 'function', 'vehicle occupancy reader missing') + const occupation = await context.context.readReviewedVehicleOccupation(vehicle.id) + check(id(occupation?.vehicleId) === id(vehicle.id) && occupation.assignmentCount === 0, 'created vehicle is occupied') + evidence.reviewedVehicle = { ...createdVehicle, snapshot: vehicle, occupation } + } + Object.assign(evidence, { status: 'VERIFIED', tripVersion: current.trip.recordVersion, manifestHash: load.manifestHash, + departure: departures[0], costSummary: costs.summary }) + await context.checkpoint() + return { trip, dispatchDetails: evidence.dispatchDetails, reviewedVehicle: evidence.reviewedVehicle } +} + +export async function readReviewedVehicleOccupation(env, vehicleId, fetcher = globalThis.fetch) { + validateEvidenceConfig(env) + if (!/^[1-9]\d*$/.test(id(vehicleId))) throw Error('Exact numeric reviewed vehicle ID required') + const sql = ['SET NAMES utf8mb4 COLLATE utf8mb4_bin;', 'SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;', + 'START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;', + `SELECT JSON_OBJECT('vehicleId','${vehicleId}','assignmentCount',COUNT(*)) FROM ll_fleet_resource_assignment WHERE resource_kind='VEHICLE' AND resource_id=${vehicleId};`, 'COMMIT;'].join('\n') + const script = buildEvidenceRemoteScript('lastleg', sql, env.FBA_CORE_JENKINS_AGENT) + const response = await fetcher(new URL('scriptText', env.JENKINS_URL.replace(/\/?$/, '/')), { method: 'POST', + headers: { Authorization: 'Basic ' + Buffer.from(env.JENKINS_USER + ':' + env.JENKINS_API_TOKEN).toString('base64'), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ script }), signal: AbortSignal.timeout(55000) }) + if (!response.ok) throw Error('Reviewed vehicle occupancy HTTP failure') + const lines = (await response.text()).split(/\r?\n/).filter(line => line.startsWith('FBA_CORE_JSON=')) + if (lines.length !== 1) throw Error('Reviewed vehicle occupancy response ambiguous') + const result = JSON.parse(lines[0].slice('FBA_CORE_JSON='.length)) + if (result.ok !== true || result.rows?.length !== 1) throw Error('Reviewed vehicle occupancy query failed') + return result.rows[0] +} + +export async function readRecoveryTripResources(env, tripNo, fetcher = globalThis.fetch) { + validateEvidenceConfig(env) + if (!/^[A-Za-z0-9_-]{1,128}$/.test(tripNo)) throw Error('Exact recovery trip number required') + const sql = ['SET NAMES utf8mb4 COLLATE utf8mb4_bin;', 'SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;', + 'START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;', + `SELECT JSON_OBJECT('resourceKind',resource_kind,'resourceId',CAST(resource_id AS CHAR),'tripNo',trip_no) FROM ll_fleet_resource_assignment WHERE trip_no='${tripNo}' ORDER BY resource_kind;`, 'COMMIT;'].join('\n') + const script = buildEvidenceRemoteScript('lastleg', sql, env.FBA_CORE_JENKINS_AGENT) + const response = await fetcher(new URL('scriptText', env.JENKINS_URL.replace(/\/?$/, '/')), { method: 'POST', + headers: { Authorization: 'Basic ' + Buffer.from(env.JENKINS_USER + ':' + env.JENKINS_API_TOKEN).toString('base64'), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ script }), signal: AbortSignal.timeout(55000) }) + if (!response.ok) throw Error('Recovery resources readonly HTTP failure') + const lines = (await response.text()).split(/\r?\n/).filter(line => line.startsWith('FBA_CORE_JSON=')) + if (lines.length !== 1) throw Error('Recovery resource evidence ambiguous') + const result = JSON.parse(lines[0].slice('FBA_CORE_JSON='.length)) + if (result.ok !== true || !Array.isArray(result.rows)) throw Error('Recovery resource query failed') + return result.rows +} + +export async function verifyReturnSourceRecovery(context, { verifyOriginal = verifyDepartedRecovery } = {}) { + const original = await verifyOriginal(context, { costsAlreadyVerified: true, returnSourceReady: true }) + const run = context.createdResources.fleetAcceptance, rows = [] + const previous = original.trip.returnRedispatch + for (const delivery of previous.originalDeliveries) { + const response = await context.request('GET', `/api/platform/v1/last-leg/delivery-orders/${delivery.deliveryNo}/return-inbound-options?customerId=${run.customerId}`) + if (!success(response) || !response.requestId || !response.traceId || !Array.isArray(response.body.data)) throw Error('Repaired return candidates must be definitive') + for (const relation of response.body.data) { + const tail = await context.request('GET', `/api/platform/v1/last-leg/orders/${relation.lastMileOrderId}?customerId=${run.customerId}`) + if (!success(tail) || !tail.requestId || !tail.traceId || relation.deliveryNo !== delivery.deliveryNo + || relation.outboundNo !== delivery.outboundNo || id(tail.body.data?.order?.customerId) !== id(run.customerId) + || id(tail.body.data.order.overseasWarehouseId) !== id(run.warehouseId) + || id(tail.body.data.order.id) !== id(relation.lastMileOrderId)) throw Error('Repaired source owner or delivery identity mismatch') + rows.push({ relation, tail: tail.body.data.order, delivery }) + } + } + const selected = selectReturnSources(rows) + context.createdResources.fleetDepartureRecoveryVerification.returnSourceSelection = selected + await context.checkpoint() + return original +} + +export async function continueReviewedReturnSources(context, { verify = verifyReturnSourceRecovery, + resolveAccess = resolvePickerAccess, makePicker = createPicker, closePicker = disablePicker, + redispatch = verifyFleetReturnRedispatch, closeOriginal = finishFleetTrip, cleanup = cleanupFleetHandoverResources } = {}) { + const verified = await verify(context), trip = verified.trip, run = context.createdResources.fleetAcceptance + trip.previousSourceSelectionAttempts = [trip.returnRedispatch]; delete trip.returnRedispatch + run.previousPicker = run.picker; delete run.picker + run.pickerMarker = `${context.marker}-RETURN-RECOVERY-${randomUUID().slice(0, 8)}` + await context.checkpoint() + let picker + try { + const child = Object.create(context); child.marker = run.pickerMarker + const access = await resolveAccess(context) + picker = access ? await makePicker(child, 71, run.warehouseId, access) : null + if (!picker?.token || !picker.membershipId) throw Error('Source recovery requires a fresh bound PDA session') + run.picker = { membershipId: picker.membershipId, username: picker.username }; await context.checkpoint() + const partial = context.createdResources.fleetFeeCoverageCases.find(row => row.mode === 'PARTIAL') + const result = await redispatch(context, MODULE, trip, { customerId: run.customerId, warehouseId: run.warehouseId, + pickerToken: picker.token, dispatchNos: run.dispatchNos, deliveryNos: [...partial.proof.deliveryNos, trip.externalDispatch.deliveryNo], sourceSelectionRecovery: true }) + run.receipts = result.originalSignatures.map(row => ({ deliveryNo: row.deliveryNo, signedAt: row.signedAt })) + context.context.saveFleetReturnRedispatchFixture(result) + run.closure = await closeOriginal(context, MODULE, trip) + if (run.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') throw Error('Recovered original trip did not close') + await cleanup(context, MODULE, trip) + run.stage = 'FINISHED'; run.status = 'HTTP_CHAIN_VERIFIED_WITH_ACK_GAP'; await context.checkpoint() + return run + } finally { await closePicker(context, picker, 71) } +} + +export async function runFleetHandoverStage(context, trip, pickerToken, dispatchDetails, { verify = verifyFleetHandover, onReady, reviewedVehicle } = {}) { + const state = await verify(context, MODULE, trip, pickerToken, dispatchDetails, { reviewedVehicle }) + const run = context.createdResources.fleetAcceptance + if (state?.status !== 'VERIFIED' || state.marker !== context.marker || run?.departure?.tripNo !== trip.tripNo + || run.departure.status !== 'IN_TRANSIT' || !state.originalDeparture?.snapshot?.confirmedLoad || !state.browser + || trip.costAcceptance?.status !== 'VERIFIED' || trip.costAcceptance.marker !== context.marker) + throw Error('Only the current verified DEPART and handover can publish a shared fixture') + const fixture = { marker: context.marker, runId: context.context?.fleetRunId, tripNo: trip.tripNo, warehouseId: trip.warehouseId, dispatchNos: state.dispatchNos, + departure: run.departure, immutableDeparture: state.originalDeparture, handoverNo: state.handoverNo, + originalResources: state.previous, replacementResources: state.replacement, browser: state.browser, + costs: trip.costAcceptance, + allocationStatus: trip.costAcceptance.expected?.allocationStatus, + allocationBlockReason: trip.costAcceptance.expected?.allocationBlockReason, + status: 'DEPART_AND_HANDOVER_VERIFIED', requestCount: state.requestCount, pdaReadCount: state.pdaReadCount } + context.context?.saveFleetHandoverFixture?.(fixture) + if (onReady) await onReady(fixture) + return fixture +} + +export function fleetCostShares(trip, dispatchDetails) { + const external = trip.externalReceipt, externalDispatch = trip.externalDispatch + if (external?.status !== 'VERIFIED' || !external.bookingNo || !external.warehousePalletId || !externalDispatch?.dispatchNo) + throw Error('Cost expectations require this run\'s verified external receipt') + const shares = dispatchDetails.map(row => { + const pallets = [...new Set((row.items || []).map(item => id(item.warehousePalletId)))] + if (!row.dispatchNo || !pallets.length || pallets.some(value => !value)) throw Error('Cost expectations require actual dispatch pallets') + const isExternal = row.dispatchNo === externalDispatch.dispatchNo + if (isExternal && (pallets.length !== 1 || pallets[0] !== id(external.warehousePalletId))) throw Error('External cost source does not match actual receipt') + // The fixture's explicit measurement rule is one space per physical pallet; never derive this from cost totals. + return { businessNo: isExternal ? external.bookingNo : row.dispatchNo, sourceType: isExternal ? 'EXTERNAL' : 'OWN', + dispatchNo: row.dispatchNo, warehousePalletIds: pallets, palletSpaces: pallets.length } + }) + const pallets = shares.flatMap(row => row.warehousePalletIds) + if (shares.filter(row => row.sourceType === 'EXTERNAL').length !== 1 || !shares.some(row => row.sourceType === 'OWN') + || new Set(pallets).size !== pallets.length) throw Error('Cost fixture requires distinct own and external physical pallets') + return shares +} + +export function fleetCostActorJournalData(value) { + const clean = redactEvidence(value) + function removeCredentials(item) { + if (Array.isArray(item)) return item.map(removeCredentials) + if (!item || typeof item !== 'object') return item + const result = {}, forbidden = /^(?:password|newPassword|temporaryPassword|accessToken|refreshToken|authorization)$/i + let removed = false + for (const [name, field] of Object.entries(item)) { + if (forbidden.test(name)) removed = true + else result[name] = removeCredentials(field) + } + if (removed) result.credentialFieldsRemoved = true + return result + } + return removeCredentials(clean) +} + +export async function prepareFleetCostActors(context) { + if (!context.marker || context.createdResources.fleetCostActors || typeof context.checkpoint !== 'function') + throw Error('Cost actors require fresh current-run ownership and durable checkpoints') + const proof = context.createdResources.fleetCostActors = { marker: context.marker, status: 'CREATING', actors: [], requests: [] } + const base = '/api/platform/v1/iam', sessions = [] + async function request(method, path, body, token) { + const entry = { method, path, body: fleetCostActorJournalData(body), status: 'SENDING' } + proof.requests.push(entry); await context.checkpoint() + try { + const response = await context.request(method, path, body, token) + Object.assign(entry, { status: 'RESPONDED', http: response?.http, code: response?.body?.code, + requestId: response?.requestId, traceId: response?.traceId, result: fleetCostActorJournalData(response?.body?.data) }) + await context.checkpoint() + if (!success(response) || !response.requestId || !response.traceId) throw Error(`Cost actor request failed: ${method} ${path}`) + return response.body.data + } catch (error) { + if (entry.status === 'SENDING') { entry.status = 'UNKNOWN_RESULT'; await context.checkpoint() } + throw error + } + } + async function close() { + const errors = [] + for (const actor of proof.actors) { + try { + if (actor.membershipId && !actor.userDisabled) { + const result = await request('PUT', `${base}/users/${actor.membershipId}/status`, { status: 'DISABLED' }) + if (result?.status !== 'DISABLED') throw Error('Cost test account was not disabled') + actor.userDisabled = true + } + if (actor.roleId && !actor.roleDisabled) { + const result = await request('PUT', `${base}/roles/${actor.roleId}/status`, { status: 'DISABLED' }) + if (result?.status !== 'DISABLED') throw Error('Cost test role was not disabled') + actor.roleDisabled = true + } + } catch (error) { errors.push(error) } + } + proof.status = errors.length ? 'CLEANUP_FAILED' : 'DISABLED'; await context.checkpoint() + if (errors.length) throw new AggregateError(errors, 'Cost actor cleanup requires review; do not recreate accounts') + } + try { + const permissions = await request('GET', `${base}/permissions`) + const list = permissions.filter(row => row.permissionCode === 'platform:fleet:trip:list' && row.status === 'ACTIVE') + if (list.length !== 1 || !list[0].id) throw Error('Active trip list permission missing or ambiguous') + for (const kind of ['reader', 'denied']) { + const suffix = randomUUID().replaceAll('-', '').slice(0, 16), username = `fleet_${kind}_${suffix}` + const actor = { kind, username }; proof.actors.push(actor) + const roleCode = `FLEET_${kind.toUpperCase()}_${suffix.toUpperCase()}` + const role = await request('POST', `${base}/roles`, { roleCode, roleName: `${context.marker}-${kind}`, description: context.marker }) + if (!id(role?.id) || role.roleCode !== roleCode || role.roleType !== 'CUSTOM') throw Error('Fresh cost role ownership mismatch') + actor.roleId = id(role.id) + await request('PUT', `${base}/roles/permissions`, { roleId: actor.roleId, permissionIds: kind === 'reader' ? [id(list[0].id)] : [] }) + const password = `Fleet${randomUUID().replaceAll('-', '')}A9` + const user = await request('POST', `${base}/users`, { username, displayName: `${context.marker}-${kind}`, + phone: await findFleetActorPhone(context, suffix), email: `${username}@example.com`, tenantId: '0', + roleIds: [actor.roleId], password, status: 'ACTIVE' }) + if (!id(user?.membershipId) || user.username !== username || id(user.tenantId) !== '0' + || user.roleIds?.length !== 1 || id(user.roleIds[0]) !== actor.roleId) throw Error('Fresh cost account ownership mismatch') + actor.membershipId = id(user.membershipId) + const login = await request('POST', '/api/auth/login', { loginId: username, password }, '') + if (!login?.accessToken || login.accessToken === context.token || sessions.includes(login.accessToken)) throw Error('Independent cost session missing') + sessions.push(login.accessToken) + const actual = await request('GET', `${base}/authorization/permissions`, undefined, login.accessToken) + const expected = kind === 'reader' ? ['platform:fleet:trip:list'] : [] + if (!Array.isArray(actual) || JSON.stringify([...actual].sort()) !== JSON.stringify(expected)) throw Error('Cost actor effective permissions differ from exact scope') + } + proof.status = 'READY'; await context.checkpoint() + return { marker: context.marker, listOnlyToken: sessions[0], deniedToken: sessions[1], close } + } catch (error) { + proof.status = 'FAILED'; await context.checkpoint() + try { await close() } catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Cost actor creation and cleanup failed') } + throw error + } +} + +export async function findFleetActorPhone(context, actorSuffix) { + if (!context.marker || !/^[a-f0-9]{16}$/.test(actorSuffix)) throw Error('Fresh actor phone scope required') + for (let attempt = 0; attempt < 100; attempt++) { + const number = createHash('sha256').update(`${context.marker}:${actorSuffix}:${attempt}`).digest().readUInt32BE(0) + const phone = `18${String(number).padStart(9, '0').slice(-9)}` + const users = await context.request('GET', `/api/platform/v1/iam/users?${new URLSearchParams({ page: '1', size: '20', keyword: phone })}`) + if (!success(users) || !users.requestId || !users.traceId || !Array.isArray(users.body.data?.records) + || !Number.isSafeInteger(Number(users.body.data.total)) || Number(users.body.data.total) !== users.body.data.records.length) + throw Error('Cannot verify platform actor phone availability') + if (users.body.data.records.some(row => row.phone === phone)) continue + const customer = await context.request('GET', `/api/platform/v1/iam/tenants/administrator-phone-availability?phone=${encodeURIComponent(phone)}`) + if (!success(customer) || !customer.requestId || !customer.traceId) throw Error('Cannot verify customer administrator phone availability') + if (customer.body.data === true) return phone + } + throw Error('No available phone in the fresh actor scope; do not replay user creation') +} + +export async function runFleetScenario(context, { wait = ms => new Promise(resolve => setTimeout(resolve, ms)), onHandoverReady, + costActors, resumeFull = false, resumePersisted = false, resumeReceiptReady = false, resumePalletized = false, resumeReceiptConfirmed = false, terminalReadOnly = false } = {}) { + if (terminalReadOnly) return verifyFleetTerminalState(context) + if (costActors?.marker !== context.marker || !costActors.listOnlyToken || !costActors.deniedToken + || costActors.listOnlyToken === costActors.deniedToken || [costActors.listOnlyToken, costActors.deniedToken].includes(context.token)) + throw Error('Prepare two independent current-run platform cost permission sessions before starting the fleet scenario') + const proof = resumeFull || resumeReceiptReady ? context.createdResources.fleetAcceptance + : context.createdResources.fleetAcceptance = { marker: context.marker, status: 'CHECKING', stage: 'SOURCES', checkpoints: [] } + function stage(value) { proof.stage = value; proof.checkpoints.push({ stage: value, at: new Date().toISOString() }); console.log(`FLEET_STAGE=${value}`) } + function check(label, value, response) { context.assert(MODULE, label, response, Boolean(value), '', OPTIONS); if (!value) throw Error(label) } + async function request(method, path, body, label, token) { + const response = await context.request(method, path, body, token) + check(label, success(response), response); return response.body.data + } + const source = context.createdResources.phase4OverseasDispatch, receiving = context.createdResources.phase4OverseasPda + check('专项只承接本轮实际已收货及预排资源', source?.marker === context.marker && source.dispatchNo + && receiving?.marker === context.marker && receiving.finalStatus === 'COMPLETED' && receiving.customerId && receiving.overseasWarehouseId) + const customerId = id(receiving.customerId), warehouseId = id(receiving.overseasWarehouseId) + proof.customerId = customerId; proof.warehouseId = warehouseId + const dispatchBase = '/api/platform/v1/last-leg/dispatches' + let trip, partial + if (resumeReceiptReady) { + trip = context.createdResources.fleetTrips.find(row => row.tripNo === proof.tripNo) + partial = context.createdResources.fleetFeeCoverageCases.find(row => row.mode === 'PARTIAL') + const current = await request('GET', `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(trip.tripNo)}`, undefined, '恢复实收前核对原车次分配与客户执行单') + const dispatch = await request('GET', `${dispatchBase}/${encodeURIComponent(partial.dispatchNo)}`, undefined, '恢复实收前核对原排车尚未拣货或发车') + check('仍是原已核对车辆司机与未执行客户排车', current.trip?.status === 'PLANNED' + && id(current.trip.vehicleId) === id(trip.vehicleId) && id(current.trip.driverId) === id(trip.driverId) + && current.dispatches?.length === 1 && current.dispatches[0].dispatchNo === partial.dispatchNo + && id(current.dispatches[0].customerId) === customerId && dispatch.dispatchNo === partial.dispatchNo + && Number(dispatch.pickedPackageCount) === 0 && Number(dispatch.shippedPackageCount) === 0) + } else { + const original = await request('GET', `${dispatchBase}/${encodeURIComponent(source.dispatchNo)}`, undefined, '读取本轮原始预排实物') + if (resumeFull) check('原样本恢复前排车货物未变且未开始作业', JSON.stringify(original.items) === JSON.stringify(proof.originalDispatch.items) + && Number(original.pickedPackageCount) === 0 && Number(original.shippedPackageCount) === 0) + proof.originalDispatch = original + trip = resumeFull ? context.createdResources.fleetTrips.find(row => row.tripNo === proof.tripNo) + : await prepareFleetTrip(context, MODULE, source.dispatchNo, warehouseId) + proof.tripNo = trip.tripNo + const tripPath = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(trip.tripNo)}` + function transferBody(vehicle) { return { idempotencyKey: key(), plannedDeliveryAt: new Date(Date.now() + 21600000).toISOString().slice(0, 19), + deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '30托', + vehiclePlate: vehicle.vehiclePlate, driverName: vehicle.driverName, driverPhone: vehicle.driverPhone, appointmentAccount: 'SELF', + remark: `${context.marker} 原费用覆盖专用验证`, fees: [{ feeType: 'DELIVERY_FEE', feeName: '未覆盖货物派送费', direction: 'RECEIVABLE', currency: 'CNY', amount: 10 }] } } + let detail = await request('GET', tripPath, undefined, '读取当前派车事实供转派送使用') + check('当前车次资源及原客户排车关联仍一致', id(detail.trip.vehicleId) === id(trip.vehicleId) + && id(detail.trip.driverId) === id(trip.driverId) && detail.dispatches.some(row => row.dispatchNo === source.dispatchNo && id(row.customerId) === customerId)) + if (!resumeFull) context.createdResources.fleetFeeCoverageCases = [] + const full = resumeFull ? context.createdResources.fleetFeeCoverageCases[0] + : { marker: context.marker, mode: 'FULL', dispatchNo: source.dispatchNo, customerId, transferBody: transferBody(detail.trip) } + if (resumeFull && !resumePersisted) { + full.previousReadOnlyAttempts ||= []; full.previousReadOnlyAttempts.push(full.proof); delete full.proof + } else if (!resumeFull) context.createdResources.fleetFeeCoverageCases.push(full) + stage('FULL_COVERAGE'); await verifyFleetFeeCoverage(context, MODULE, full, { reconcilePersisted: resumePersisted }) + stage('CANCEL_FULL_AND_REARRANGE') + detail = await request('GET', tripPath, undefined, '读取取消未执行全覆盖用例前车次版本') + await request('POST', `${tripPath}/dispatches/unbind`, { recordVersion: detail.trip.recordVersion, dispatchNo: full.dispatchNo, + reason: '全覆盖用例验证完毕,正式取消未执行发运并以新排车验证部分覆盖' }, '先解除未执行全覆盖排车关联') + await request('POST', `/api/platform/v1/last-leg/outbound-orders/${encodeURIComponent(full.proof.outboundNo)}/cancel`, + { reason: '本轮全覆盖验证完毕,正式取消未执行出库后重新安排' }, '取消未执行全覆盖出库') + await request('DELETE', `${dispatchBase}/${encodeURIComponent(full.dispatchNo)}`, undefined, '正式取消原排车释放其托盘锁') + const draftToken = key(), planPalletIds = original.items.map(row => id(row.planPalletId)) + check('原排车保留可追溯计划托盘', planPalletIds.length > 0 && planPalletIds.every(value => /^[1-9]\d*$/.test(value))) + proof.rearrange = { originalDispatchNo: full.dispatchNo, draftToken, planPalletIds } + const container = await request('POST', `${dispatchBase}/drafts/containers`, { draftToken, capacity: 30 }, '建立同批当前库存的新排车草稿') + for (const planPalletId of planPalletIds) await request('POST', `${dispatchBase}/draft-locks/lock`, { planPalletId, draftToken, draftContainerId: id(container.id) }, '锁定已正式释放的本轮托盘') + const arranged = await request('POST', `${dispatchBase}/pre-arrange`, { planPalletIds, draftToken }, '以新排车重新安排本轮同批货物') + check('取消重排产生新排车且货量不变', arranged.dispatchNo && arranged.dispatchNo !== full.dispatchNo + && Number(arranged.packageCount) === Number(original.packageCount)) + proof.rearrange.dispatchNo = arranged.dispatchNo + detail = await request('GET', tripPath, undefined, '读取原车次当前可关联版本') + detail = await request('POST', `${tripPath}/dispatches/bind`, { recordVersion: detail.trip.recordVersion, dispatchNo: arranged.dispatchNo, + reason: '本轮取消后新排车重新关联原计划车次' }, '将新排车正式关联原车次') + trip.replacedDispatchNo = trip.dispatchNo; trip.dispatchNo = arranged.dispatchNo + partial = { marker: context.marker, mode: 'PARTIAL', dispatchNo: arranged.dispatchNo, customerId, transferBody: transferBody(detail.trip) } + context.createdResources.fleetFeeCoverageCases.push(partial) + stage('PARTIAL_COVERAGE'); await verifyFleetFeeCoverage(context, MODULE, partial) + stage('RESERVATIONS'); await verifyFleetBookingReservation(context, MODULE, trip, controlledPalletMeasurements) + } + const access = await resolvePickerAccess(context) + let picker = null + try { + if (resumePalletized || resumeReceiptConfirmed) { + const suffix = proof.pickerMarker.replace(/[^A-Za-z0-9]/g, '').slice(-16).toUpperCase() + const username = `pda_overseas_${suffix.toLowerCase()}_71` + check('仅复用本轮创建的临时PDA身份', proof.picker.username === username && access?.role?.id) + const users = await request('GET', `/api/platform/v1/iam/users?${new URLSearchParams({ page: '1', size: '200', keyword: username })}`, undefined, '核对原临时PDA账号绑定及角色') + const user = users?.records?.find(row => id(row.membershipId) === id(proof.picker.membershipId) && row.username === username) + check('原PDA仍仅有本仓作业员权限且不是受保护账号', user && id(user.tenantId) === '0' && !user.protectedAccount + && id(user.overseasWarehouseId) === warehouseId && user.roleIds?.length === 1 && id(user.roleIds[0]) === id(access.role.id)) + picker = { membershipId: user.membershipId, username, token: null } + const active = await request('PUT', `/api/platform/v1/iam/users/${encodeURIComponent(id(user.membershipId))}/status`, { status: 'ACTIVE' }, '为核对原PDA完成请求恢复本轮临时作业员') + check('原临时PDA已恢复启用', active?.status === 'ACTIVE') + const loggedIn = await request('POST', '/api/auth/login', { loginId: username, password: `Pda${suffix}A719` }, '以原生成凭据登录本轮临时PDA', '') + picker.token = loggedIn?.accessToken + } else { + const pickerContext = Object.create(context) + proof.pickerMarker ||= `${context.marker}-FPDA-${randomUUID().slice(0, 8)}` + pickerContext.marker = proof.pickerMarker + picker = access ? await createPicker(pickerContext, 71, warehouseId, access) : null + } + check('专项使用本轮绑定起运仓的PDA账号', picker?.token && picker.membershipId) + proof.picker = { membershipId: picker.membershipId, username: picker.username } + stage('EXTERNAL_RECEIPT'); await verifyFleetExternalReceipt(context, MODULE, trip, picker.token, { reconcilePalletized: resumePalletized, reconcileConfirmed: resumeReceiptConfirmed }) + stage('EXTERNAL_DISPATCH'); await verifyFleetExternalDispatch(context, MODULE, trip) + const external = trip.externalDispatch + stage('FINANCE_CONSUMER') + const sources = await request('GET', `/api/platform/v1/last-leg/finance-fee-sources?${new URLSearchParams({ customerId, businessType: 'DELIVERY_ORDER', businessNo: external.deliveryNo })}`, + undefined, '读取外接应收稳定来源编号和版本') + const target = sources?.filter(row => row.feeItemCode === 'DELIVERY_FEE' && row.direction === 'RECEIVABLE' && row.sourceStatus === 'ACTIVE') + check('外接应收来源只属于本次客户派送及实收', target?.length === 1 && target[0].businessNo === external.deliveryNo + && id(target[0].customerId) === customerId && Number(target[0].originalAmount) === 30 && target[0].originalCurrency === 'CNY') + let matched = [] + for (let attempt = 0; attempt < 30; attempt++) { + const pool = await request('GET', `/api/platform/finance/v1/fee-pool?customerId=${encodeURIComponent(customerId)}&direction=RECEIVABLE`, undefined, '等待Finance消费本次外接费用') + check('Finance费用池为当前客户应收列表', Array.isArray(pool) && pool.every(row => id(row.customerId) === customerId && row.direction === 'RECEIVABLE')) + matched = pool.filter(row => id(row.sourceId) === id(target[0].sourceFeeId) && Number(row.sourceVersion) === Number(target[0].sourceVersion)) + if (matched.length) break + await wait(1000) + } + check('Finance真实消费且仅一笔30CNY外接应收', matched.length === 1 && matched[0].sourceType === 'OVERSEAS_DELIVERY_FEE' + && Number(matched[0].amount) === 30 && matched[0].currency === 'CNY') + proof.finance = { status: 'CONSUMER_PERSISTED', source: target[0], pool: matched, producerAck: 'NOT_VERIFIED' } + external.financeAcknowledgement = 'CONSUMER_PERSISTED_PRODUCER_ACK_NOT_VERIFIED' + stage('PICK_EACH_DISPATCH') + const dispatchNos = [partial.dispatchNo, external.dispatchNo] + proof.dispatchNos = dispatchNos + const dispatchDetails = [] + for (const [index, dispatchNo] of dispatchNos.entries()) { + const pickingContext = Object.create(context); pickingContext.marker = `${context.marker}-FLEET-PICK-${index}` + check('逐客户正式PDA拣货及完成成功', await pickAll(pickingContext, picker.token, dispatchNo)) + dispatchDetails.push(await request('GET', `${dispatchBase}/${encodeURIComponent(dispatchNo)}`, undefined, '回查逐单拣货数量及未发车事实')) + } + check('每张客户排车实际拣足但尚未发车', dispatchDetails.every(row => row.status === 'READY_TO_DEPART' + && Number(row.pickedPackageCount) === Number(row.packageCount) && Number(row.shippedPackageCount) === 0)) + stage('WHOLE_TRIP_DEPARTURE') + const recordVersion = await confirmFleetLoading(context, MODULE, trip) + proof.departureRequest = { recordVersion, idempotencyKey: key(), remark: '本轮自有货与真实外接货统一发车' } + const departPath = `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(partial.dispatchNo)}/depart` + const departed = await request('POST', departPath, proof.departureRequest, 'PDA一次命令发出全车两张客户执行单', picker.token) + check('整车发车包含所有排车且箱数守恒', departed?.tripNo === trip.tripNo && departed.status === 'IN_TRANSIT' + && JSON.stringify(departed.dispatches.map(row => row.dispatchNo).sort()) === JSON.stringify([...dispatchNos].sort()) + && Number(departed.shippedPackages) === dispatchDetails.reduce((n, row) => n + Number(row.packageCount), 0)) + const departedReplay = await request('POST', departPath, proof.departureRequest, '整车发车同键重放', picker.token) + check('发车重放不改变整车实发数量', departedReplay?.replayed === true && Number(departedReplay.shippedPackages) === Number(departed.shippedPackages)) + proof.departure = departed + await completeDepartedFleetScenario(context, trip, picker, dispatchDetails, { costActors, onHandoverReady }) + } finally { await disablePicker(context, picker, 71) } + return proof +} + +export async function completeDepartedFleetScenario(context, trip, picker, dispatchDetails, { costActors, onHandoverReady, + costsAlreadyVerified = false, reviewedVehicle, + verifyCosts = verifyFleetTripCosts, handover = runFleetHandoverStage, completeDelivery = saveReceiptAndComplete, + closeTrip = finishFleetTrip, cleanup = cleanupFleetHandoverResources } = {}) { + const proof = context.createdResources.fleetAcceptance, customerId = proof.customerId, warehouseId = proof.warehouseId + const partial = context.createdResources.fleetFeeCoverageCases.find(row => row.mode === 'PARTIAL') + const external = trip.externalDispatch, dispatchNos = proof.dispatchNos + const stage = value => { proof.stage = value; proof.checkpoints.push({ stage: value, at: new Date().toISOString() }); console.log('FLEET_STAGE=' + value) } + const check = (label, ok) => { if (!ok) throw Error(label) } + async function request(method, path) { const r = await context.request(method, path); check('Continuation read failed', success(r)); return r.body.data } + if (!costsAlreadyVerified) { + stage('TRIP_OPERATING_COSTS') + await verifyCosts(context, MODULE, trip, { ...costActors, shares: fleetCostShares(trip, dispatchDetails) }) + } else check('Recovery cost proof must remain verified', trip.costAcceptance?.status === 'VERIFIED') + stage('IN_TRANSIT_HANDOVER') + await handover(context, trip, picker.token, dispatchDetails, { + reviewedVehicle, + onReady: onHandoverReady ? async fixture => { + stage('PDA_UI_CHECKPOINT') + await onHandoverReady({ fixture, picker }) + } : undefined, + }) + stage('RECEIPTS_AND_RESOURCE_RELEASE') + proof.receipts = [] + if (context.context.fleetScenario === 'return-redispatch') { + stage('RETURN_REDISPATCH') + const returned = await verifyFleetReturnRedispatch(context, MODULE, trip, { customerId, warehouseId, pickerToken: picker.token, + dispatchNos, deliveryNos: [...partial.proof.deliveryNos, external.deliveryNo] }) + proof.receipts = returned.originalSignatures.map(row => ({ deliveryNo: row.deliveryNo, signedAt: row.signedAt })) + context.context.saveFleetReturnRedispatchFixture(returned) + } else { + for (const [index, deliveryNo] of [...partial.proof.deliveryNos, external.deliveryNo].entries()) { + const delivery = await request('GET', `/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}?customerId=${encodeURIComponent(customerId)}`, + undefined, '签收前读取本次实际派送') + const completed = await completeDelivery(context, delivery.order, customerId, index + 71) + check('每个目的地真实保存签收资料后完成', completed?.order?.status === 'DELIVERED' && completed.order.signedAt) + proof.receipts.push({ deliveryNo, signedAt: completed.order.signedAt }) + } + } + proof.closure = await closeTrip(context, MODULE, trip) + check('本次车次货物结案及车辆司机释放完成', proof.closure?.status === 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') + stage('HANDOVER_RESOURCE_CLEANUP') + await cleanup(context, MODULE, trip) + proof.status = 'HTTP_CHAIN_VERIFIED_WITH_ACK_GAP'; stage('FINISHED') + return proof +} + +export async function continueReviewedDeparture(context, { verify = verifyDepartedRecovery, prepareActors = prepareFleetCostActors, + costsAlreadyVerified = false, createdVehicle, + resolveAccess = resolvePickerAccess, makePicker = createPicker, closePicker = disablePicker, + complete = completeDepartedFleetScenario } = {}) { + const verified = await verify(context, { costsAlreadyVerified, createdVehicle }) + const proof = context.createdResources.fleetAcceptance, trip = verified.trip + if (!costsAlreadyVerified) { + context.createdResources.previousFleetCostActors = context.createdResources.fleetCostActors + trip.previousReadOnlyCostAttempts = [trip.costAcceptance] + delete context.createdResources.fleetCostActors + delete trip.costAcceptance + } else { + trip.previousReadOnlyHandoverAttempts = [trip.handover] + delete trip.handover + } + proof.previousPicker = proof.picker + delete proof.picker + proof.pickerMarker = `${context.marker}-RECOVERY-PDA-${randomUUID().slice(0, 8)}` + await context.checkpoint() + let actors, picker + try { + if (!costsAlreadyVerified) { + actors = await prepareActors(context) + if (actors?.marker !== context.marker || !actors.listOnlyToken || !actors.deniedToken + || actors.listOnlyToken === actors.deniedToken || [actors.listOnlyToken, actors.deniedToken].includes(context.token)) + throw Error('Recovery requires fresh independent cost sessions') + } + const pickerContext = Object.create(context); pickerContext.marker = proof.pickerMarker + const access = await resolveAccess(context) + picker = access ? await makePicker(pickerContext, 71, proof.warehouseId, access) : null + if (!picker?.token || !picker.membershipId) throw Error('Recovery requires a new bound PDA session') + proof.picker = { membershipId: picker.membershipId, username: picker.username } + await context.checkpoint() + return await complete(context, trip, picker, verified.dispatchDetails, { costActors: actors, costsAlreadyVerified, reviewedVehicle: verified.reviewedVehicle }) + } finally { + try { await closePicker(context, picker, 71) } finally { await actors?.close() } + } +} + +export async function runFleetAcceptance(config, { env = process.env, readers, createContext, scenario = runFleetScenario, prepareCostActors = prepareFleetCostActors, root = ROOT } = {}) { + if (!config.execute || config.environment !== 'TEST' || env.CHONGCHONG_ENV?.toLowerCase() !== 'test' + || basename(root) !== 'baiyue-firstleg' || basename(dirname(root)) !== 'chongchong-global') throw Error('Explicit TEST workspace and configuration required') + if (scenario === runFleetScenario && config.resumeStage !== 'TERMINAL_READONLY' && typeof prepareCostActors !== 'function') + throw Error('Current-run cost actor preparation and lifecycle must be provided before any environment requests') + const base = new URL(env.CHONGCHONG_ADMIN_API_BASE_URL) + if (!['http:', 'https:'].includes(base.protocol) || base.username || base.password || base.search || base.hash + || /(?:^|[.-])(?:dev|prod|production)(?:[.-]|$)/i.test(base.hostname)) throw Error('Invalid TEST gateway') + for (const name of ['CHONGCHONG_ADMIN_USERNAME', 'CHONGCHONG_ADMIN_PASSWORD', 'ADMIN_PDA_USERNAME', 'ADMIN_PDA_PASSWORD']) if (!env[name]) throw Error(`Missing ${name}`) + if (config.outputDir && !/^reports\/[A-Za-z0-9][A-Za-z0-9_-]{0,95}$/.test(config.outputDir)) throw Error('Report scope invalid') + const output = resolve(root, config.outputDir || `reports/fleet-${config.runId}`), rel = relative(resolve(root, 'reports'), output) + if (!rel || rel.startsWith('..') || isAbsolute(rel)) throw Error('Report scope invalid') + mkdirSync(resolve(root, 'reports'), { recursive: true }) + const lockPath = resolve(root, 'reports/.fleet-acceptance.lock'), lock = openSync(lockPath, 'wx') + let context, initialized = false, report, restoreFetch + const secrets = Object.entries(env).filter(([name]) => /PASSWORD|TOKEN|USERNAME|JENKINS_USER/.test(name)).map(([,value]) => value) + const save = (name, value) => writeFileSync(join(output, name), JSON.stringify(redactEvidence(value, [...secrets, + context?.token, context?.pdaToken, context?.refreshToken, context?.context?.merchantQualificationFixture?.password]), null, 2) + '\n') + try { + writeFileSync(lock, JSON.stringify({ pid: process.pid, runId: config.runId, startedAt: new Date().toISOString() })) + mkdirSync(output, { recursive: false }); initialized = true; mkdirSync(join(output, 'evidence')) + save('manifest.json', fleetExecutionPlan(config)) + let resumed + if (config.resumeReport) { + const previous = resolve(root, config.resumeReport), previousRelative = relative(resolve(root, 'reports'), previous) + if (!previousRelative || previousRelative.startsWith('..') || isAbsolute(previousRelative)) throw Error('Resume report must be inside this TEST repository reports') + const bytes = readFileSync(previous) + if ((config.resumeStage?.startsWith('DEPARTED_') || config.resumeStage === 'RETURN_SOURCE_SELECTION_BEFORE_RETURN') && createHash('sha256').update(bytes).digest('hex') !== config.resumeSha256) + throw Error('Reviewed departure report SHA256 mismatch; no environment request sent') + resumed = reviewedFullCheckpoint(JSON.parse(bytes.toString('utf8')), config.resumeStage) + save('reviewed-checkpoint.json', { source: config.resumeReport, sha256: createHash('sha256').update(bytes).digest('hex'), stage: config.resumeStage }) + } else { + readers ||= createTestEvidenceReaders(env) + await readers.preflight() + } + const options = validateExecutionOptions({ ...parseArguments(['--modules', 'phase4-overseas-dispatch', '--quiet', 'true']), + gateway: base.origin, username: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD, + pdaUsername: env.ADMIN_PDA_USERNAME, pdaPassword: env.ADMIN_PDA_PASSWORD, merchantUsername: '', merchantPassword: '', + markerNamespace: createHash('sha256').update(config.runId).digest('hex').slice(0, 12) }) + context = createContext ? createContext(options) : new AcceptanceRun(options) + let writeCheckpoint = 0 + restoreFetch = guardFleetWrites(context, entry => { + const payload = JSON.stringify(redactFleetWrite({ marker: context.marker, runId: config.runId, request: entry }, + [...secrets, context.token, context.pdaToken, context.refreshToken]), null, 2) + '\n' + const descriptor = openSync(join(output, 'evidence', `write-${String(++writeCheckpoint).padStart(6, '0')}.json`), 'wx') + try { writeFileSync(descriptor, payload); fsyncSync(descriptor) } + finally { closeSync(descriptor) } + }, { guardFetch: true }) + context.context.fleetRunId = config.runId + context.context.fleetScenario = config.scenario || 'handover' + context.context.readReviewedVehicleOccupation = vehicleId => readReviewedVehicleOccupation(env, vehicleId) + context.context.readRecoveryTripResources = tripNo => readRecoveryTripResources(env, tripNo) + context.context.onBeforeRedispatchSignature = proof => waitForRedispatchSignatureAudit(context, proof, + { root, output, secrets: [...secrets, context.token, context.pdaToken, context.refreshToken] }) + context.context.saveFleetHandoverFixture = fixture => save('handover-fixture.json', fixture) + context.context.saveFleetReturnRedispatchFixture = fixture => save('return-redispatch-fixture.json', fixture) + let handoverCheckpoint = 0 + context.context.checkpointFleetHandover = proof => { + const payload = JSON.stringify(redactEvidence({ marker: context.marker, runId: config.runId, + proof, createdResources: context.createdResources }, [...secrets, context.token, context.pdaToken, context.refreshToken]), null, 2) + '\n' + // Never truncate the prior checkpoint: interruption or disk failure leaves the earlier request recoverable. + const descriptor = openSync(join(output, 'evidence', `handover-${String(++handoverCheckpoint).padStart(6, '0')}.json`), 'wx') + try { writeFileSync(descriptor, payload); fsyncSync(descriptor) } + finally { closeSync(descriptor) } + } + context.checkpoint = () => context.context.checkpointFleetHandover(context.createdResources.fleetAcceptance) + const fleetScenario = async (ctx, options = {}) => { + if (options.returnSourceSelection) return continueReviewedReturnSources(ctx) + if (options.resumeDeparted) return continueReviewedDeparture(ctx, { prepareActors: prepareCostActors, + costsAlreadyVerified: options.costsAlreadyVerified, createdVehicle: options.createdVehicle }) + const costActors = options.terminalReadOnly || scenario !== runFleetScenario ? undefined : await prepareCostActors?.(ctx) + try { return await scenario(ctx, { ...options, costActors }) } + finally { await costActors?.close() } + } + let evidenceNumber = 0 + context.context.phase4ReceivingEvidenceReader = async scope => { + const evidence = await readers.readReceiving(scope) + save(`evidence/receiving-${++evidenceNumber}.json`, { scope, evidence }); return evidence + } + const executeModule = context.executeModule.bind(context) + context.executeModule = async (name, handler) => { + await executeModule(name, handler) + save('progress.json', { stage: name, steps: context.steps, modules: context.modules, createdResources: context.createdResources }) + if (context.steps.some(step => step.passed === false) || context.modules.at(-1)?.success === false) throw Error(`Producer stopped: ${name}`) + if (name === 'phase4-overseas-dispatch') { + await executeModule(MODULE, () => fleetScenario(context)) + if (context.modules.at(-1)?.success === false) throw Error('Fleet case stopped at original fixture') + } + } + if (resumed) { + context.createdResources = resumed.resources; context.marker = resumed.marker + const login = await context.request('POST', '/api/auth/login', { loginId: options.username, password: options.password }, '') + if (!success(login) || !login.body?.data?.accessToken) throw Error('Resume administrator login failed') + context.token = login.body.data.accessToken; context.refreshToken = login.body.data.refreshToken || '' + await context.executeModule(MODULE, () => fleetScenario(context, { resumeFull: !resumed.receiptReady, + resumeDeparted: resumed.departed, + returnSourceSelection: resumed.returnSourceSelection, + costsAlreadyVerified: resumed.costsAlreadyVerified, + createdVehicle: resumed.createdVehicle, + resumePersisted: resumed.persisted, resumeReceiptReady: resumed.receiptReady, resumePalletized: resumed.palletized, resumeReceiptConfirmed: resumed.receiptConfirmed, terminalReadOnly: resumed.terminal })) + const summary = buildAcceptanceSummary(context.steps, context.modules, context.coverageGaps) + report = { success: summary.failed === 0, marker: context.marker, summary, steps: context.steps, modules: context.modules, + createdResources: context.createdResources, resumedFrom: config.resumeReport } + } else report = await context.run() + report.fleetExecution = fleetExecutionPlan(config) + // Keep the unresolved producer ACK explicit; a green consumer row cannot close the complete fleet goal. + report.fleetGoalComplete = false + } catch (error) { + report = { success: false, fatal: error instanceof Error ? error.message : 'FLEET_ACCEPTANCE_FAILED', + summary: context ? buildAcceptanceSummary(context.steps, context.modules, context.coverageGaps) : { failed: 1 }, + steps: context?.steps || [], modules: context?.modules || [], createdResources: context?.createdResources || {} } + } finally { + restoreFetch?.() + if (initialized) save('report.json', report || { success: false, fatal: 'RUN_INTERRUPTED' }) + closeSync(lock); unlinkSync(lockPath) + } + return { success: report?.success === true, report: join(output, 'report.json'), summary: report?.summary, + fleet: report?.createdResources?.fleetAcceptance?.status, fatal: report?.fatal } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { const config = parseFleetArguments(process.argv.slice(2)); const result = config.execute ? await runFleetAcceptance(config) : fleetExecutionPlan(config) + console.log(JSON.stringify(result, null, 2)); if (config.execute && !result.success) process.exitCode = 1 + } catch (error) { console.error(error instanceof Error ? error.message : 'Fleet acceptance failed'); process.exitCode = 1 } +} diff --git a/scripts/fleet-test-env-acceptance.test.mjs b/scripts/fleet-test-env-acceptance.test.mjs new file mode 100644 index 000000000..58682d9d8 --- /dev/null +++ b/scripts/fleet-test-env-acceptance.test.mjs @@ -0,0 +1,807 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, dirname, resolve } from 'node:path' +import { AcceptanceRun } from './admin-test-env-acceptance.mjs' +import { run as runInboundMeasurement } from './admin-test-env-modules/inbound-measurement.mjs' +import { verifyPalletLabels } from './admin-test-env-modules/phase3-loading-finalize.mjs' + +function departedRecoveryFixture() { + const snapshotTrip = { tripNo: 'TR-OWN', status: 'IN_TRANSIT', recordVersion: '27', vehicleId: 'V1', driverId: 'D1', warehouseId: 'W1', departedAt: '2026-10-03T11:00:00Z' } + const cargo = [{ dispatchNo: 'OWN', dispatchItemId: 'I1', warehousePalletId: 'P1', packages: 2 }, { dispatchNo: 'EXT', dispatchItemId: 'I2', warehousePalletId: 'P2', packages: 1 }] + const departure = { action: 'DEPART', recordVersion: '27', snapshot: { trip: snapshotTrip, confirmedLoad: { manifestHash: 'a'.repeat(64), cargo } } } + const proof = { marker: 'RUN', tripNo: 'TR-OWN', warehouseId: 'W1', customerId: 'C1', status: 'CHECKING', stage: 'TRIP_OPERATING_COSTS', checkpoints: [], + dispatchNos: ['OWN', 'EXT'], picker: { membershipId: 'OLD-PDA' }, departureRequest: { idempotencyKey: 'old-key' }, + departure: { tripNo: 'TR-OWN', status: 'IN_TRANSIT', departedAt: snapshotTrip.departedAt, shippedPackages: 3 } } + const trip = { marker: 'RUN', tripNo: 'TR-OWN', costAcceptance: { marker: 'RUN', status: 'FAILED', costs: [], departure, + requests: [{ method: 'GET', status: 'RESPONDED', http: 200, code: '200', requestId: 'r', traceId: 't' }] }, + externalReceipt: { status: 'VERIFIED', bookingNo: 'BK', warehousePalletId: 'P2' }, externalDispatch: { dispatchNo: 'EXT', deliveryNo: 'DL-EXT' } } + const report = { success: false, steps: [{ name: '独立预期覆盖全部实物且没有重叠托盘', passed: false }, { name: '模块执行', passed: false }], + createdResources: { fleetAcceptance: proof, fleetTrips: [trip], fleetCostActors: { status: 'DISABLED' }, fleetFeeCoverageCases: [{ mode: 'PARTIAL', proof: { deliveryNos: ['DL-OWN'] } }] } } + const base = '/api/platform/v1/last-leg/fleet/trips/TR-OWN' + const responses = new Map([[base, { trip: structuredClone(snapshotTrip), dispatches: [{ dispatchNo: 'OWN', customerId: 'C1' }, { dispatchNo: 'EXT', customerId: 'C1' }] }], + [base + '/history?page=1&size=100', { total: 1, records: [structuredClone(departure)] }], + [base + '/costs?page=1&size=100', { page: { records: [], total: 0 }, summary: { manifestHash: 'a'.repeat(64), departureVersion: '27', totals: [] } }], + [base + '/handovers', { records: [], total: 0 }]]) + for (const row of cargo) { + responses.set('/api/platform/v1/last-leg/dispatches/' + row.dispatchNo, { dispatchNo: row.dispatchNo, status: 'DEPARTED', overseasWarehouseId: 'W1', + packageCount: row.packages, pickedPackageCount: row.packages, shippedPackageCount: row.packages, + items: [{ id: row.dispatchItemId, warehousePalletId: row.warehousePalletId, packageCount: row.packages }] }) + responses.set('/api/platform/v1/last-leg/delivery-orders/DL-' + row.dispatchNo + '?customerId=C1', { order: { deliveryNo: 'DL-' + row.dispatchNo, + customerId: 'C1', dispatchNo: row.dispatchNo, status: 'WAIT_DELIVERY', version: '1', packageCount: row.packages, + documentSummary: { categories: [{ fileCount: 0 }] } } }) + } + const calls = [], ctx = { marker: 'RUN', token: 'writer', context: {}, createdResources: report.createdResources, async checkpoint() { calls.push('checkpoint') }, + async request(method, path) { assert.equal(method, 'GET', 'recovery verification must never write'); calls.push(path); assert.ok(responses.has(path), path) + return { http: 200, body: { code: '200', data: responses.get(path) }, requestId: 'r', traceId: 't' } } } + return { report, ctx, calls, responses, base, trip } +} + +function createdVehicleRecoveryFixture() { + const f = afterCostsRecoveryFixture(), snapshot = { id: '2106360046045675522', plateNumber: 'HO-24255909-3BC', recordVersion: '0', + status: 'IN_SERVICE', ownership: 'OWNED', region: 'OVERSEAS', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90 } + f.report.steps[0].name = '接替车辆返回真实身份及车牌' + const request = { method: 'POST', path: '/api/platform/v1/last-leg/master-data/vehicles', body: { plateNumber: 'HO-24255909-3bc' }, + status: 'RESPONDED', http: 200, code: '200', requestId: 'created-r', traceId: 'created-t', result: snapshot } + f.trip.handover.requests.push(request) + f.responses.set('/api/platform/v1/last-leg/master-data/vehicles/' + snapshot.id, structuredClone(snapshot)) + f.ctx.context.readReviewedVehicleOccupation = async vehicleId => ({ vehicleId, assignmentCount: 0 }) + return { ...f, snapshot, request } +} + +function returnSourceRecoveryFixture() { + const f = afterCostsRecoveryFixture(), { report, trip, responses, base, ctx } = f, run = report.createdResources.fleetAcceptance + run.stage = 'RETURN_REDISPATCH'; run.receipts = []; run.departure.shippedPackages = 5 + report.steps = [{ name: '模块执行', passed: false, evidence: 'Fresh redispatch fixture requires source A with at least 3 boxes and a separate domestic source B' }] + const current = { ...responses.get(base).trip, recordVersion: '30', vehicleId: 'V2', driverId: 'D2', vehiclePlate: 'NEW', driverName: 'NEW', driverPhone: 'NEW' } + responses.get(base).trip = current + const original = trip.costAcceptance.departure + original.snapshot.confirmedLoad.cargo = [{ dispatchNo: 'OWN', dispatchItemId: 'IA', warehousePalletId: 'PA', packages: 3 }, + { dispatchNo: 'OWN', dispatchItemId: 'IB', warehousePalletId: 'PB', packages: 1 }, { dispatchNo: 'EXT', dispatchItemId: 'IX', warehousePalletId: 'PX', packages: 1 }] + const history = { records: [structuredClone(original), { action: 'HANDOVER', recordVersion: '28' }, + { action: 'RELEASE', recordVersion: '29' }, { action: 'RELEASE', recordVersion: '30' }], total: 4 } + responses.set(base + '/history?page=1&size=100', history) + const handover = { records: [{ handoverNo: 'HO-CURRENT', recordVersion: '2' }], total: 1 } + const vehicle = { id: 'V2', recordVersion: '0', plateNumber: 'NEW', status: 'IN_SERVICE' } + const driver = { id: 'D2', recordVersion: '2', active: true, reviewStatus: 'APPROVED', licenseFileId: 'FILE', details: {} } + trip.handover = { status: 'VERIFIED', finalTrip: structuredClone(current), handoverNo: 'HO-CURRENT', requests: [ + { method: 'GET', path: base + '/history?page=1&size=100', result: structuredClone(history) }, + { method: 'GET', path: base + '/handovers?page=1&size=10', result: structuredClone(handover) }, + { method: 'POST', path: '/api/platform/v1/last-leg/master-data/vehicles', result: structuredClone(vehicle) }, + { method: 'POST', path: '/api/platform/v1/last-leg/fleet/drivers/D2/review', result: structuredClone(driver) }] } + responses.set(base + '/handovers', handover); responses.set('/api/platform/v1/last-leg/master-data/vehicles/V2', vehicle) + responses.set('/api/platform/v1/last-leg/fleet/drivers/D2', driver) + ctx.context.readRecoveryTripResources = async () => [{ resourceKind: 'VEHICLE', resourceId: 'V2', tripNo: 'TR-OWN' }, { resourceKind: 'DRIVER', resourceId: 'D2', tripNo: 'TR-OWN' }] + const rows = [['A', 3, 'OWN'], ['B', 1, 'OWN'], ['EXT', 1, 'EXT']] + const originals = rows.map(([label, packages, dispatchNo]) => ({ deliveryNo: 'DL-' + label, outboundNo: 'OUT-' + label, dispatchNo, + customerId: 'C1', overseasWarehouseId: 'W1', status: 'WAIT_DELIVERY', version: '2', packageCount: packages, + documentSummary: { categories: [{ fileCount: 0 }] } })) + trip.returnRedispatch = { marker: 'RUN', status: 'FAILED', failure: report.steps[0].evidence, originalDeliveries: originals, + requests: originals.map(order => ({ method: 'PUT', path: `/api/platform/v1/last-leg/delivery-orders/${order.deliveryNo}/status?customerId=C1`, + body: { status: 'DELIVERING', expectedVersion: 2 }, status: 'RESPONDED', http: 200, code: '200', requestId: 'r', traceId: 't', + result: { deliveryNo: order.deliveryNo, deliveryStatus: 'DELIVERING', replayed: false } })) } + report.createdResources.fleetFeeCoverageCases[0].proof.deliveryNos = ['DL-A', 'DL-B'] + for (const [label, packages, dispatchNo] of rows) { + const order = originals.find(row => row.deliveryNo === 'DL-' + label) + responses.set(`/api/platform/v1/last-leg/delivery-orders/${order.deliveryNo}?customerId=C1`, { order: { ...structuredClone(order), + status: 'DELIVERING', version: '3', vehiclePlate: 'NEW', driverName: 'NEW', driverPhone: 'NEW' } }) + const relation = { lastMileOrderId: label, inboundCode: 'IN-' + label, deliveryNo: order.deliveryNo, outboundNo: order.outboundNo, + returnSources: [{ maxReturnPackages: packages }] } + responses.set(`/api/platform/v1/last-leg/delivery-orders/${order.deliveryNo}/return-inbound-options?customerId=C1`, [relation]) + responses.set(`/api/platform/v1/last-leg/orders/${label}?customerId=C1`, { order: { id: label, customerId: 'C1', overseasWarehouseId: 'W1', + orderType: label === 'EXT' ? 'WAREHOUSE_TRANSFER' : 'FIRST_LEG', sourceInboundId: 'SOURCE-' + label, inboundCode: relation.inboundCode, packageCount: packages } }) + } + for (const no of ['OWN', 'EXT']) { + const cargo = original.snapshot.confirmedLoad.cargo.filter(row => row.dispatchNo === no), total = cargo.reduce((n,row)=>n+row.packages,0) + responses.set('/api/platform/v1/last-leg/dispatches/' + no, { dispatchNo: no, status: 'DEPARTED', overseasWarehouseId: 'W1', + packageCount: total, pickedPackageCount: total, shippedPackageCount: total, + items: cargo.map(row=>({id:row.dispatchItemId,warehousePalletId:row.warehousePalletId,packageCount:row.packages})) }) + } + return f +} + +test('source-selection recovery only accepts original three explicit starts and rejects any actual return or unsigned result', () => { + const f = returnSourceRecoveryFixture() + assert.equal(reviewedFullCheckpoint(f.report, 'RETURN_SOURCE_SELECTION_BEFORE_RETURN').returnSourceSelection, true) + for (const change of [x=>x.trip.returnRedispatch.requests.push({ method:'POST',path:'/mark-return' }), + x=>x.trip.returnRedispatch.requests[0].status='UNKNOWN_RESULT',x=>x.trip.returnRedispatch.requests[0].result.replayed=true]) { + const mutated = returnSourceRecoveryFixture(); change(mutated) + assert.throws(()=>reviewedFullCheckpoint(mutated.report,'RETURN_SOURCE_SELECTION_BEFORE_RETURN'), /source recovery/) + } +}) + +for (const fault of ['none','candidate-empty','delivery-waiting','resource-wrong','cost-changed','handover-changed']) + test(`source recovery validates current business facts and repaired candidates before PDA write: ${fault}`,async()=>{ + const f=returnSourceRecoveryFixture() + if(fault==='candidate-empty')f.responses.get('/api/platform/v1/last-leg/delivery-orders/DL-A/return-inbound-options?customerId=C1')[0].returnSources=[] + if(fault==='delivery-waiting')f.responses.get('/api/platform/v1/last-leg/delivery-orders/DL-A?customerId=C1').order.status='WAIT_DELIVERY' + if(fault==='resource-wrong')f.ctx.context.readRecoveryTripResources=async()=>[] + if(fault==='cost-changed')f.responses.get(f.base+'/costs?page=1&size=100').summary.totals[0].directCost='99.00' + if(fault==='handover-changed')f.responses.get(f.base+'/handovers').records[0].recordVersion='3' + if(fault==='none'){await verifyReturnSourceRecovery(f.ctx);assert.equal(f.ctx.createdResources.fleetDepartureRecoveryVerification.returnSourceSelection.a.tail.packageCount,3)} + else await assert.rejects(continueReviewedReturnSources(f.ctx,{makePicker:()=>assert.fail('no PDA write before verification')})) + }) + +test('source recovery preserves original costs/handover and invokes only actual return continuation and closure',async()=>{ + const f=returnSourceRecoveryFixture(), operations=[], costs=f.trip.costAcceptance,hand=f.trip.handover + f.ctx.context.saveFleetReturnRedispatchFixture=()=>operations.push('save-return-proof') + await continueReviewedReturnSources(f.ctx,{resolveAccess:async()=>({}),makePicker:async()=>({token:'new-pda',membershipId:'NEW'}),closePicker:async()=>operations.push('close-pda'), + redispatch:async(_ctx,_module,trip,input)=>{assert.equal(input.sourceSelectionRecovery,true);assert.equal(trip.costAcceptance,costs);assert.equal(trip.handover,hand);operations.push('return-candidates');return {originalSignatures:[{deliveryNo:'DL-A',signedAt:'signed'}]}}, + closeOriginal:async()=>{operations.push('close-original');return {status:'CARGO_COMPLETED_AND_RESOURCES_RELEASED'}},cleanup:async()=>operations.push('cleanup')}) + assert.deepEqual(operations,['return-candidates','save-return-proof','close-original','cleanup','close-pda']) +}) + +test('created-vehicle checkpoint requires exact successful uppercase response and no subsequent writes', () => { + const { report } = createdVehicleRecoveryFixture() + const accepted = reviewedFullCheckpoint(report, 'DEPARTED_VEHICLE_CREATED') + assert.equal(accepted.createdVehicle.snapshot.plateNumber, 'HO-24255909-3BC') + assert.equal(accepted.createdVehicle.creationRequest.body.plateNumber, 'HO-24255909-3bc') + for (const mutation of [f => f.request.http = 0, f => f.request.status = 'UNKNOWN_RESULT', f => f.request.code = 'A0409', + f => f.request.traceId = null, f => f.trip.handover.requests.push({ method: 'POST', path: '/drivers' })]) { + const f = createdVehicleRecoveryFixture(); mutation(f) + assert.throws(() => reviewedFullCheckpoint(f.report, 'DEPARTED_VEHICLE_CREATED'), /Created vehicle recovery/) + } + assert.throws(() => reviewedFullCheckpoint(report, 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER'), /checkpoint/) +}) + +for (const fault of ['none', 'changed', 'occupied', 'missing-reader']) test(`created vehicle exact snapshot and no occupation mandatory: ${fault}`, async () => { + const f = createdVehicleRecoveryFixture(), accepted = reviewedFullCheckpoint(f.report, 'DEPARTED_VEHICLE_CREATED') + if (fault === 'changed') f.responses.get('/api/platform/v1/last-leg/master-data/vehicles/' + f.snapshot.id).recordVersion = '1' + if (fault === 'occupied') f.ctx.context.readReviewedVehicleOccupation = async vehicleId => ({ vehicleId, assignmentCount: 1 }) + if (fault === 'missing-reader') delete f.ctx.context.readReviewedVehicleOccupation + const options = { costsAlreadyVerified: true, createdVehicle: accepted.createdVehicle } + if (fault === 'none') { const r = await verifyDepartedRecovery(f.ctx, options); assert.equal(r.reviewedVehicle.snapshot.id, f.snapshot.id) } + else await assert.rejects(continueReviewedDeparture(f.ctx, { ...options, makePicker: () => assert.fail('no session before verification') }), /recovery refused/) +}) + +test('occupancy reader is scoped to exact numeric vehicle and readonly evidence transport', async () => { + const env = { JENKINS_URL: 'https://test.invalid/', JENKINS_USER: 'reader', JENKINS_API_TOKEN: 'secret', FBA_CORE_JENKINS_AGENT: 'java-test-agent' } + const result = await readReviewedVehicleOccupation(env, '123', async (url, init) => { + assert.equal(url.pathname, '/scriptText'); assert.equal(init.method, 'POST'); assert.ok(init.body.get('script').includes('executeGroovy')) + return new Response('FBA_CORE_JSON={"ok":true,"rows":[{"vehicleId":"123","assignmentCount":0}]}') + }) + assert.deepEqual(result, { vehicleId: '123', assignmentCount: 0 }) + await assert.rejects(readReviewedVehicleOccupation(env, '123; DELETE', () => assert.fail('invalid identity')), /numeric/) +}) + +function afterCostsRecoveryFixture() { + const fixture = departedRecoveryFixture(), { report, trip, responses, base } = fixture + report.createdResources.fleetAcceptance.stage = 'IN_TRANSIT_HANDOVER' + report.steps[0].name = '每张排车已发车且货量与客户起运仓一致' + trip.costAcceptance.status = 'VERIFIED' + trip.costAcceptance.costs = [{ costNo: 'CNY-COST' }, { costNo: 'USD-COST' }] + trip.costAcceptance.final = { page: { records: structuredClone(trip.costAcceptance.costs), total: 2 }, + summary: { manifestHash: 'a'.repeat(64), departureVersion: '27', totals: [{ currency: 'CNY', directCost: '100.03' }, { currency: 'USD', directCost: '13.07' }], allocationBlockReason: 'MULTI_STOP' } } + trip.handover = { status: 'FAILED', requests: [{ method: 'GET', status: 'RESPONDED', http: 200, code: '200' }] } + responses.set(base + '/costs?page=1&size=100', structuredClone(trip.costAcceptance.final)) + return fixture +} + +test('after-cost recovery requires its exact known read-only handover failure and rejects any handover write', () => { + const { report } = afterCostsRecoveryFixture() + assert.equal(reviewedFullCheckpoint(report, 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER').costsAlreadyVerified, true) + report.createdResources.fleetTrips[0].handover.requests.push({ method: 'POST', status: 'RESPONDED', http: 200, code: '200' }) + assert.throws(() => reviewedFullCheckpoint(report, 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER'), /checkpoint/) +}) + +test('after-cost recovery checks original cost contents and refuses a changed amount before any write', async () => { + const { ctx, responses, base } = afterCostsRecoveryFixture() + responses.get(base + '/costs?page=1&size=100').summary.totals[0].directCost = '100.04' + await assert.rejects(continueReviewedDeparture(ctx, { costsAlreadyVerified: true, + prepareActors: () => assert.fail('no new actors'), makePicker: () => assert.fail('no new PDA before verification') }), /costs already written or cargo basis changed/) +}) + +test('after-cost recovery retains verified costs and only enters handover, signatures and resource closure', async () => { + const { ctx, trip } = afterCostsRecoveryFixture(), costs = trip.costAcceptance, oldHandover = trip.handover, operations = [] + await continueReviewedDeparture(ctx, { costsAlreadyVerified: true, prepareActors: () => assert.fail('verified costs need no new cost actors'), + resolveAccess: async () => ({}), makePicker: async () => ({ token: 'new-pda', membershipId: 'NEW-PDA' }), closePicker: async () => operations.push('close-pda'), + complete: (context, trip, picker, details, options) => completeDepartedFleetScenario(context, trip, picker, details, { ...options, + verifyCosts: () => assert.fail('costs must never be recreated'), handover: async () => operations.push('handover'), + completeDelivery: async (_ctx, order) => { operations.push('sign:' + order.deliveryNo); return { order: { status: 'DELIVERED', signedAt: 'signed' } } }, + closeTrip: async () => { operations.push('release'); return { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' } }, cleanup: async () => operations.push('cleanup'), + }) }) + assert.equal(trip.costAcceptance, costs); assert.equal(trip.previousReadOnlyHandoverAttempts[0], oldHandover) + assert.deepEqual(operations, ['handover', 'sign:DL-OWN', 'sign:DL-EXT', 'release', 'cleanup', 'close-pda']) +}) + +test('departed recovery requires reviewed SHA and exactly the original read-only cost failure', () => { + assert.throws(() => parseFleetArguments(['--resume-report', 'reports/r5/report.json', '--resume-stage', 'DEPARTED_BEFORE_COSTS']), /SHA256/) + const args = ['--resume-report', 'reports/r5/report.json', '--resume-stage', 'DEPARTED_BEFORE_COSTS', '--resume-sha256', 'a'.repeat(64)] + assert.equal(parseFleetArguments(args).resumeSha256, 'a'.repeat(64)) + assert.throws(() => parseFleetArguments([...args, '--scenario', 'return-redispatch']), /fresh run/) + assert.equal(reviewedFullCheckpoint(departedRecoveryFixture().report, 'DEPARTED_BEFORE_COSTS').departed, true) + for (const change of [r => r.createdResources.fleetTrips[0].costAcceptance.requests.push({ method: 'POST' }), + r => r.createdResources.fleetTrips[0].handover = {}, r => r.createdResources.fleetAcceptance.receipts = [], + r => r.createdResources.fleetCostActors.status = 'READY', r => r.steps[0].name = 'unknown write']) { + const { report } = departedRecoveryFixture(); change(report) + assert.throws(() => reviewedFullCheckpoint(report, 'DEPARTED_BEFORE_COSTS'), /checkpoint/) + } +}) + +for (const mutation of ['none', 'version', 'history', 'cost', 'handover', 'cargo', 'signature', 'document']) + test(`recovery rechecks actual state before any session/business write: ${mutation}`, async () => { + const { ctx, responses, base } = departedRecoveryFixture() + if (mutation === 'version') responses.get(base).trip.recordVersion = '28' + if (mutation === 'history') responses.get(base + '/history?page=1&size=100').records[0].snapshot.confirmedLoad.manifestHash = 'changed' + if (mutation === 'cost') responses.get(base + '/costs?page=1&size=100').page.records.push({ costNo: 'COST' }) + if (mutation === 'handover') responses.get(base + '/handovers').records.push({ handoverNo: 'H' }) + if (mutation === 'cargo') responses.get('/api/platform/v1/last-leg/dispatches/OWN').items[0].packageCount = 3 + const delivery = responses.get('/api/platform/v1/last-leg/delivery-orders/DL-OWN?customerId=C1').order + if (mutation === 'signature') delivery.signedAt = 'now' + if (mutation === 'document') delivery.documentSummary.categories[0].fileCount = 1 + if (mutation === 'none') { await verifyDepartedRecovery(ctx); assert.equal(ctx.createdResources.fleetDepartureRecoveryVerification.status, 'VERIFIED') } + else await assert.rejects(continueReviewedDeparture(ctx, { prepareActors: () => assert.fail('no new actors before validation') }), /recovery refused/) + }) + +test('reviewed recovery archives old evidence and creates fresh sessions before shared post-departure flow only', async () => { + const { ctx, trip, calls } = departedRecoveryFixture(); const oldCost = trip.costAcceptance + await continueReviewedDeparture(ctx, { + prepareActors: async () => { assert.equal(ctx.createdResources.fleetDepartureRecoveryVerification.status, 'VERIFIED'); calls.push('new-actors'); return { marker: 'RUN', listOnlyToken: 'reader', deniedToken: 'denied', async close() { calls.push('close-actors') } } }, + resolveAccess: async () => ({}), makePicker: async child => { assert.match(child.marker, /RECOVERY-PDA/); calls.push('new-picker'); return { membershipId: 'NEW', token: 'new-pda' } }, + closePicker: async () => calls.push('close-picker'), + complete: async (context, actualTrip, picker, dispatches) => { + assert.equal(actualTrip, trip); assert.equal(trip.costAcceptance, undefined); assert.equal(picker.membershipId, 'NEW'); assert.equal(dispatches.length, 2) + assert.equal(trip.previousReadOnlyCostAttempts[0], oldCost); assert.equal(context.createdResources.fleetAcceptance.previousPicker.membershipId, 'OLD-PDA') + calls.push('continue-from-costs') + }, + }) + assert.deepEqual(calls.filter(x => !x.startsWith('/') && x !== 'checkpoint'), ['new-actors', 'new-picker', 'continue-from-costs', 'close-picker', 'close-actors']) +}) + +test('shared continuation starts at costs, retains unavailable allocation and never enters picking or DEPART', async () => { + const { ctx, trip, calls } = departedRecoveryFixture() + const verified = await verifyDepartedRecovery(ctx), operations = [] + await completeDepartedFleetScenario(ctx, trip, { token: 'pda' }, verified.dispatchDetails, { + costActors: { marker: 'RUN' }, + verifyCosts: async () => { operations.push('costs'); trip.costAcceptance = { status: 'VERIFIED', expected: { allocationStatus: 'UNAVAILABLE' } } }, + handover: async () => { assert.equal(trip.costAcceptance.expected.allocationStatus, 'UNAVAILABLE'); operations.push('handover') }, + completeDelivery: async (context, order) => { operations.push('signature:' + order.deliveryNo); return { order: { status: 'DELIVERED', signedAt: 'signed' } } }, + closeTrip: async () => { operations.push('close-trip'); return { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' } }, + cleanup: async () => operations.push('cleanup'), + }) + assert.deepEqual(operations, ['costs', 'handover', 'signature:DL-OWN', 'signature:DL-EXT', 'close-trip', 'cleanup']) + assert.equal(ctx.createdResources.fleetAcceptance.stage, 'FINISHED') + assert.ok(!calls.some(x => /\/depart$|\/pick|\/loading/.test(x))) +}) + +test('changed source report SHA blocks recovery before creating context or logging in', async () => { + const temporary = mkdtempSync(join(tmpdir(), 'fleet-recovery-sha-')) + const root = join(temporary, 'chongchong-global', 'baiyue-firstleg'); mkdirSync(join(root, 'reports', 'original'), { recursive: true }) + writeFileSync(join(root, 'reports', 'original', 'report.json'), JSON.stringify(departedRecoveryFixture().report)) + try { + const result = await runFleetAcceptance({ execute: true, environment: 'TEST', runId: 'reviewed', resumeStage: 'DEPARTED_BEFORE_COSTS', + resumeReport: 'reports/original/report.json', resumeSha256: 'b'.repeat(64) }, { root, + env: { CHONGCHONG_ENV: 'test', CHONGCHONG_ADMIN_API_BASE_URL: 'https://test.invalid', CHONGCHONG_ADMIN_USERNAME: 'admin', + CHONGCHONG_ADMIN_PASSWORD: 'secret', ADMIN_PDA_USERNAME: 'pda', ADMIN_PDA_PASSWORD: 'secret' }, + createContext: () => assert.fail('SHA mismatch must precede context/network'), + }) + assert.equal(result.success, false); assert.match(result.fatal, /SHA256 mismatch/) + } finally { + assert.equal(dirname(resolve(temporary)), resolve(tmpdir())) + assert.ok(resolve(temporary).startsWith(join(resolve(tmpdir()), 'fleet-recovery-sha-'))) + rmSync(temporary, { recursive: true, force: true }) + } +}) +import { parseFleetArguments, fleetExecutionPlan, runFleetAcceptance, reviewedFullCheckpoint, verifyFleetTerminalState, runFleetHandoverStage, fleetCostShares, prepareFleetCostActors, findFleetActorPhone, guardFleetWrites, redactFleetWrite, verifyDepartedRecovery, continueReviewedDeparture, completeDepartedFleetScenario, readReviewedVehicleOccupation, verifyReturnSourceRecovery, continueReviewedReturnSources } from './fleet-test-env-acceptance.mjs' + +for (const response of [ + { http: 0 }, { http: 503, body: { code: 'A0500' }, requestId: 'r', traceId: 't' }, + { http: 200, body: null }, { http: 200, body: { code: '200' } }, + { http: 302, body: { code: '200' }, requestId: 'r', traceId: 't' }, +]) test(`unknown prerequisite write stops every subsequent request: ${JSON.stringify(response)}`, async () => { + const saved = [], calls = [] + const ctx = { async request(method, path, body) { + assert.equal(saved.at(-1).status, 'SENDING') + assert.deepEqual(saved.at(-1).body, body) + calls.push(path); return response + } } + guardFleetWrites(ctx, entry => saved.push(structuredClone(entry))) + const body = { expectedVersion: 6, idempotencyKey: 'original-key', internalNotes: ['test remark'] } + await assert.rejects(ctx.request('PUT', '/orders/1/remarks?tenantId=2', body), /Unknown write outcome/) + body.expectedVersion = 99 + assert.equal(saved[0].body.expectedVersion, 6) + assert.equal(saved[1].status, 'UNKNOWN_RESULT') + for (const method of ['POST', 'GET', 'DELETE']) await assert.rejects(ctx.request(method, '/must-not-run'), /Unknown write outcome/) + assert.deepEqual(calls, ['/orders/1/remarks?tenantId=2']) +}) + +test('formal correlated rejection is recorded and remains available for expected negative assertions', async () => { + const saved = [], response = { http: 200, body: { code: 'A0409' }, requestId: 'r', traceId: 't' } + const ctx = { async request() { return response } } + guardFleetWrites(ctx, entry => saved.push(structuredClone(entry))) + assert.equal(await ctx.request('POST', '/negative', { recordVersion: 7 }), response) + assert.equal(saved[1].status, 'RESPONDED') + await ctx.request('GET', '/readback'); assert.equal(saved.length, 2) +}) + +test('response evidence preserves business messages and returned resource IDs with explicit session provenance and no tokens', async () => { + const saved = [], ctx = { token: 'admin-session', pdaToken: 'pda-session', options: { username: 'test-admin' }, + async request() { return { http: 200, body: { code: '200', message: 'created', data: { + id: '123', businessNo: 'FIXTURE-123', accessToken: 'new-secret', refreshToken: 'refresh-secret' } }, requestId: 'r', traceId: 't' } } } + guardFleetWrites(ctx, entry => saved.push(redactFleetWrite(structuredClone(entry)))) + await ctx.request('POST', '/create', { expectedVersion: 3 }) + assert.deepEqual(saved[0].actor, { kind: 'PLATFORM', source: 'CONTEXT_ADMIN_SESSION' }) + assert.equal(saved[1].responseBody.message, 'created'); assert.equal(saved[1].responseBody.data.id, '123') + await ctx.request('POST', '/pda', {}, 'pda-session'); assert.equal(saved[2].actor.kind, 'PDA') + await ctx.request('POST', '/other', {}, 'unresolved-session'); assert.equal(saved[4].actor.kind, 'UNKNOWN') + await ctx.request('POST', '/login', {}, ''); assert.equal(saved[6].actor.source, 'NO_SESSION') + for (const secret of ['admin-session', 'pda-session', 'new-secret', 'refresh-secret', 'unresolved-session']) + assert.ok(!JSON.stringify(saved).includes(secret)) +}) + +test('POST carton-label PDF is a definite binary response with durable hash and permits same-fixture readback', async () => { + const saved = [], calls = [], response = { http: 200, body: null, bytes: Buffer.from('%PDF-1.7\nfixture'), + contentType: 'application/pdf', requestId: 'r', traceId: 't' } + const ctx = { async requestBinary(method, path) { + assert.equal(saved.at(-1).status, 'SENDING'); calls.push(path); return response + }, async request() { calls.push('readback') } } + guardFleetWrites(ctx, entry => saved.push(structuredClone(entry))) + const path = '/api/platform/inbounds/1/carton-label-pdf?tenantId=2&cartonIndex=1' + assert.equal(await ctx.requestBinary('POST', path), response) + assert.equal(saved[0].path, path); assert.equal(saved[1].status, 'RESPONDED') + assert.equal(saved[1].binary.bytes, response.bytes.length); assert.match(saved[1].binary.sha256, /^[a-f0-9]{64}$/) + assert.equal(saved[1].binary.validPdf, true) + await ctx.request('GET', '/api/platform/inbounds/1'); assert.deepEqual(calls, [path, 'readback']) +}) + +test('real inbound prerequisite and AcceptanceRun binary transport accept three PDF writes then stop raw multipart retries', async t => { + const calls = [], saved = [], summary = { id: '1', tenantId: '2', processStatus: 'WAIT_MEASURE', inboundCode: 'FIXTURE-INBOUND', totalPackageCount: 2 } + const native = t.mock.method(globalThis, 'fetch', async (input, init) => { + const req = new Request(input, init), path = new URL(req.url).pathname + calls.push({ method: req.method, path }) + const headers = { 'x-request-id': 'r', 'x-trace-id': 't' } + if (req.method === 'GET') return new Response(JSON.stringify({ code: '200', data: { summary } }), { headers: { ...headers, 'content-type': 'application/json' } }) + assert.equal(saved.at(-1).status, 'SENDING') + if (path.endsWith('/carton-label-pdf')) return new Response('%PDF-1.7\nfixture', { headers: { ...headers, 'content-type': 'application/pdf' } }) + assert.ok(path.endsWith('/measurement/carton-scans')) + throw Error('transport failure Bearer sensitive-session https://example.invalid/private') + }) + const context = new AcceptanceRun({ gateway: 'https://test.invalid' }) + context.createdResources.orders = { marker: context.marker, inboundId: '1', tenantId: '2' } + const restore = guardFleetWrites(context, entry => saved.push(redactFleetWrite(structuredClone(entry))), { guardFetch: true }) + try { + await runInboundMeasurement(context) + assert.equal(calls.filter(row => row.path.endsWith('/carton-label-pdf')).length, 3) + assert.equal(calls.filter(row => row.path.endsWith('/measurement/carton-scans')).length, 1, 'raw helper retries must not reach transport') + const pdfs = saved.filter(row => row.binary?.validPdf) + assert.equal(pdfs.length, 3); assert.ok(pdfs.every(row => row.status === 'RESPONDED')) + assert.equal(context.steps.find(row => row.name === '生成箱唛 PDF 并冻结稳定箱码').passed, true) + const last = saved.at(-1); assert.equal(last.status, 'UNKNOWN_RESULT') + assert.ok(last.body.multipart.some(row => row.field === 'data' && row.base64)) + assert.ok(last.raw.includes('transport failure')); assert.ok(!last.raw.includes('sensitive-session')); assert.ok(!last.raw.includes('example.invalid')) + await assert.rejects(context.request('POST', '/subsequent'), /transport failure/) + } finally { restore(); assert.equal(globalThis.fetch, native); t.mock.restoreAll() } +}) + +test('raw JSON prerequisite writes retain headers, original version and bounded diagnostics without duplicate journaling', async t => { + let calls = 0 + t.mock.method(globalThis, 'fetch', async () => { calls++; return new Response('x'.repeat(600), { status: 502 }) }) + const saved = [], context = new AcceptanceRun({ gateway: 'https://test.invalid' }) + const restore = guardFleetWrites(context, entry => saved.push(redactFleetWrite(structuredClone(entry))), { guardFetch: true }) + try { + await assert.rejects(fetch('https://test.invalid/approve', { method: 'PUT', headers: { 'content-type': 'application/json', + Authorization: 'Bearer secret', 'Idempotency-Key': 'same-key' }, body: JSON.stringify({ expectedVersion: 6 }) }), /Unknown write outcome/) + assert.equal(saved.length, 2); assert.equal(saved[0].body.expectedVersion, 6) + assert.equal(saved[0].headers['idempotency-key'], 'same-key'); assert.equal(saved[0].headers.authorization, '[REDACTED]') + assert.equal(saved[1].raw.length, 500) + await assert.rejects(fetch('https://test.invalid/retry', { method: 'POST' }), /Unknown write outcome/) + assert.equal(calls, 1) + } finally { restore(); t.mock.restoreAll() } +}) + +for (const invalidPdf of [false, true, 'zip']) test(`real loading finalize prints both PDFs through guarded direct fetch; invalid=${invalidPdf}`, async t => { + const saved = [], calls = [], fixture = { destinationType: 'FBA', warehouseId: '1', containerNo: 'CONTAINER', cabinetNo: 'CABINET' } + const manifest = { containerNo: fixture.containerNo, totalCount: 1, groups: [{ destinationWarehouseCode: 'FBA', labels: [{ + containerNo: fixture.containerNo, destinationWarehouseInternalCode: 'F01', palletIndex: 1, palletCode: 'CABINET-F01-001' }] }] } + t.mock.method(globalThis, 'fetch', async (input, init) => { + const req = new Request(input, init); calls.push(req.url) + assert.equal(saved.at(-1).status, 'SENDING') + const headers = { 'x-request-id': 'r', 'x-trace-id': 't' } + return req.url.includes('/prepare?') + ? new Response(JSON.stringify({ code: '200', data: manifest }), { headers: { ...headers, 'content-type': 'application/json' } }) + : new Response(invalidPdf === true ? 'proxy failure' : '%PDF-1.6\nfixture', + { headers: { ...headers, 'content-type': invalidPdf === 'zip' ? 'application/zip' : 'application/pdf' } }) + }) + const ctx = new AcceptanceRun({ gateway: 'https://test.invalid' }) + const restore = guardFleetWrites(ctx, entry => saved.push(redactFleetWrite(structuredClone(entry))), { guardFetch: true }) + try { + await verifyPalletLabels(ctx, fixture, '2') + const prints = saved.filter(row => row.path.includes('/print?')) + assert.equal(calls.filter(url => url.includes('/print?')).length, invalidPdf ? 1 : 2) + assert.equal(prints.at(-1).status, invalidPdf ? 'UNKNOWN_RESULT' : 'RESPONDED') + assert.equal(prints.at(-1).binary.validPdf, !invalidPdf) + assert.equal(prints.at(-1).raw, null, 'binary response bytes must not enter text evidence') + assert.equal(prints[0].body.destinationWarehouseCode, null) + if (!invalidPdf) { assert.equal(prints[2].body.destinationWarehouseCode, 'FBA'); assert.ok(ctx.steps.every(row => row.passed)) } + else await assert.rejects(ctx.request('POST', '/no-retry'), /Unknown write outcome/) + } finally { restore(); t.mock.restoreAll() } +}) + +for (const override of [{ http: 0 }, { http: 503 }, { bytes: Buffer.from('proxy') }, + { bytes: new Uint8Array() }, { contentType: 'text/html' }, { traceId: null }, + { contentType: 'application/json', bytes: Buffer.from('{"code":"200"}'), body: { code: '200' } }]) + test(`binary write still stops on unknown or invalid response: ${JSON.stringify(override)}`, async () => { + const saved = [], ctx = { async requestBinary() { return { http: 200, body: null, bytes: Buffer.from('%PDF-1.7'), + contentType: 'application/pdf', requestId: 'r', traceId: 't', ...override } }, async request() { assert.fail('must stop') } } + guardFleetWrites(ctx, entry => saved.push(structuredClone(entry))) + await assert.rejects(ctx.requestBinary('POST', '/carton-label-pdf'), /Unknown write outcome/) + assert.equal(saved[1].status, 'UNKNOWN_RESULT') + await assert.rejects(ctx.request('GET', '/readback'), /Unknown write outcome/) + }) + +test('checkpoint failure stops before dispatch; transport throw stops even after caller catches', async () => { + let calls = 0 + const ctx = { async request() { calls++; throw Error('transport failure') } } + guardFleetWrites(ctx, () => { throw Error('disk full') }) + await assert.rejects(ctx.request('POST', '/write'), /disk full/) + await assert.rejects(ctx.request('GET', '/later'), /disk full/); assert.equal(calls, 0) + const saved = [], other = { async request() { calls++; throw Error('transport failure') } } + guardFleetWrites(other, entry => saved.push(structuredClone(entry))) + await assert.rejects(other.request('PUT', '/write'), /transport failure/) + await assert.rejects(other.request('POST', '/cleanup'), /transport failure/) + assert.equal(calls, 1); assert.equal(saved[1].status, 'UNKNOWN_RESULT') +}) + +test('multipart delegates once and retains recoverable fixture bytes, business keys and versions without credentials', async () => { + const saved = [], ctx = { + async requestMultipart(method, path, form) { return this.requestWithBody(method, path, form) }, + async requestWithBody() { return { http: 200, body: { code: '200' }, requestId: 'r', traceId: 't' } }, + } + guardFleetWrites(ctx, entry => saved.push(redactFleetWrite(structuredClone(entry)))) + const form = new FormData(); form.append('file', new Blob(['fixture']), 'proof.txt'); form.append('password', 'hidden') + await ctx.requestMultipart('POST', '/upload', form) + assert.equal(saved.length, 2); assert.equal(Buffer.from(saved[0].body.multipart[0].base64, 'base64').toString(), 'fixture') + assert.ok(!JSON.stringify(saved).includes('hidden')) + const clean = redactFleetWrite({ draftToken: 'business-draft', idempotencyKey: 'command-key', expectedVersion: 6, + headers: { Authorization: 'Bearer hidden', 'X-Access-Token': 'hidden' }, body: { password: 'hidden', accessToken: 'hidden' } }) + assert.equal(clean.draftToken, 'business-draft'); assert.equal(clean.idempotencyKey, 'command-key') + assert.equal(clean.expectedVersion, 6); assert.ok(!JSON.stringify(clean).includes('hidden')) +}) + +test('fleet entry defaults to a no-side-effect plan and requires explicit TEST and unique run ID', () => { + const p = parseFleetArguments([]) + assert.equal(p.execute, false); assert.equal(fleetExecutionPlan(p).execution, 'PLAN') + for (const args of [['--execute'], ['--execute', '--run-id', 'new'], ['--execute', '--environment', 'prod', '--run-id', 'new'], + ['--execute', '--environment', 'TEST', '--run-id', '../old'], ['--plan', '--execute'], ['--force'], ['--run-id', 'a', '--run-id', 'b']]) { + assert.throws(() => parseFleetArguments(args)) + } + assert.equal(parseFleetArguments(['--execute', '--environment', 'TEST', '--run-id', 'new']).execute, true) + const handover = parseFleetArguments(['--plan', '--environment', 'TEST', '--run-id', 'fleet-handover-20261003', '--output', 'reports/fleet-handover-live-20261003']) + assert.equal(fleetExecutionPlan(handover).outputDir, 'reports/fleet-handover-live-20261003') + for (const output of ['reports/../outside', 'C:/temp/output', 'reports', 'reports/nested/path']) assert.throws(() => parseFleetArguments(['--output', output])) +}) + +test('return redispatch is explicit, fresh-only and declares source-event proof separately', () => { + const config = parseFleetArguments(['--plan', '--environment', 'TEST', '--run-id', 'return-new', '--scenario', 'return-redispatch']) + const plan = fleetExecutionPlan(config) + assert.equal(plan.scenario, 'return-redispatch'); assert.ok(plan.returnRedispatch.required.includes('Two current-run domestic sources')) + assert.throws(() => parseFleetArguments(['--scenario', 'return-redispatch', '--resume-report', 'reports/old/report.json', '--resume-stage', 'FULL_COVERAGE']), /fresh run/) + assert.throws(() => parseFleetArguments(['--scenario', 'unknown'])) +}) + +test('handover stage exports only a verified same-run departure fixture before the optional UI checkpoint', async () => { + const order = [], departure = { tripNo: 'TR-NEW', status: 'IN_TRANSIT' } + const context = { marker: 'CURRENT', context: { saveFleetHandoverFixture(value) { order.push('save'); assert.equal(value.marker, 'CURRENT') } }, + createdResources: { fleetAcceptance: { departure } } } + const trip = { tripNo: 'TR-NEW', warehouseId: '92', costAcceptance: { status: 'VERIFIED', marker: 'CURRENT', expected: { finalCny: '100.03', usd: '13.07' } } }, details = [{ dispatchNo: 'PC-NEW' }] + const verified = { status: 'VERIFIED', marker: 'CURRENT', originalDeparture: { snapshot: { confirmedLoad: { pallets: [] } } }, + browser: { tripNo: 'TR-NEW' }, handoverNo: 'HO-NEW' } + const result = await runFleetHandoverStage(context, trip, 'secret-picker-token', details, { + verify: async (ctx, module, proof, token, dispatches) => { order.push('verify'); assert.equal(ctx, context); assert.equal(proof, trip); assert.equal(token, 'secret-picker-token'); assert.equal(dispatches, details); return verified }, + onReady: async fixture => { order.push('ui'); assert.equal(fixture.status, 'DEPART_AND_HANDOVER_VERIFIED'); assert.equal(fixture.departure, departure) }, + }) + assert.deepEqual(order, ['verify', 'save', 'ui']); assert.ok(!JSON.stringify(result).includes('secret-picker-token')) + assert.deepEqual(result.costs, trip.costAcceptance) + for (const invalid of [{ ...verified, status: 'FAILED' }, { ...verified, marker: 'OLD' }, { ...verified, originalDeparture: null }]) { + await assert.rejects(runFleetHandoverStage(context, trip, 'token', details, { verify: async () => invalid, + onReady: async () => assert.fail('invalid evidence must not reach the browser checkpoint') }), /current verified/) + } +}) + +test('cost shares use controlled physical pallets and the original external booking, never statistical totals', () => { + const trip = { externalReceipt: { status: 'VERIFIED', bookingNo: 'BK-ONE', warehousePalletId: '3' }, externalDispatch: { dispatchNo: 'EXT' } } + const details = [{ dispatchNo: 'OWN', items: [{ warehousePalletId: '1' }, { warehousePalletId: '1' }, { warehousePalletId: '2' }] }, + { dispatchNo: 'EXT', items: [{ warehousePalletId: '3' }] }] + assert.deepEqual(fleetCostShares(trip, details), [ + { businessNo: 'OWN', sourceType: 'OWN', dispatchNo: 'OWN', warehousePalletIds: ['1', '2'], palletSpaces: 2 }, + { businessNo: 'BK-ONE', sourceType: 'EXTERNAL', dispatchNo: 'EXT', warehousePalletIds: ['3'], palletSpaces: 1 }, + ]) + assert.throws(() => fleetCostShares(trip, [{ ...details[0], items: [{ warehousePalletId: '3' }] }, details[1]]), /distinct/) + assert.throws(() => fleetCostShares(trip, [details[0], { ...details[1], items: [{ warehousePalletId: '4' }] }]), /receipt/) +}) + +test('missing isolated cost actor preparation rejects before readers or fixture creation', async () => { + let touched = false + await assert.rejects(runFleetAcceptance({ execute: true, environment: 'TEST' }, { + env: { CHONGCHONG_ENV: 'test' }, prepareCostActors: null, readers: { preflight() { touched = true } }, + }), /actor preparation/) + assert.equal(touched, false) +}) + +test('cost actor journal removes credential keys while retaining IDs, roles and boolean removal evidence', async () => { + const { fleetCostActorJournalData } = await import('./fleet-test-env-acceptance.mjs') + const source = { membershipId: '12', roleIds: ['7'], password: 'actual-secret', + nested: { accessToken: 'actual-session', refreshToken: 'actual-refresh', status: 'ACTIVE' } } + const result = fleetCostActorJournalData(source) + assert.deepEqual(result, { membershipId: '12', roleIds: ['7'], credentialFieldsRemoved: true, + nested: { status: 'ACTIVE', credentialFieldsRemoved: true } }) + assert.doesNotMatch(JSON.stringify(result), /"(?:password|accessToken|refreshToken)"\s*:/) + assert.equal(source.nested.accessToken, 'actual-session') +}) + +for (const failLogin of [false, true]) test(`cost actors create exact isolated roles and disable only owned accounts; login failure=${failLogin}`, async () => { + const calls = [], saved = [], roles = [], users = [] + const context = { marker: 'RUN', token: 'admin-secret', createdResources: {}, async findAvailablePhone() { return '19900000000' }, + async checkpoint() { saved.push(structuredClone(context.createdResources)) }, + async request(method, path, body, token) { + if (method === 'GET' && (path.startsWith('/api/platform/v1/iam/users?') || path.includes('/administrator-phone-availability?'))) + return { http: 200, body: { code: '200', data: path.includes('/administrator-phone-availability?') ? true : { records: [], total: 0 } }, requestId: 'r', traceId: 't' } + const pending = saved.at(-1).fleetCostActors.requests.at(-1) + assert.equal(pending.status, 'SENDING'); assert.equal(pending.path, path) + calls.push({ method, path, body, token }) + let data + if (path.endsWith('/authorization/permissions')) data = token === 'session-1' ? ['platform:fleet:trip:list'] : [] + else if (path.endsWith('/permissions') && method === 'GET') data = [{ id: '7', permissionCode: 'platform:fleet:trip:list', status: 'ACTIVE' }] + else if (path.endsWith('/roles') && method === 'POST') { data = { ...body, id: String(roles.length + 1), roleType: 'CUSTOM' }; roles.push(data) } + else if (path.endsWith('/roles/permissions')) data = body + else if (path.endsWith('/users') && method === 'POST') { data = { ...body, membershipId: String(users.length + 10) }; users.push(data) } + else if (path === '/api/auth/login') { if (failLogin) throw Error('login unavailable'); data = { accessToken: `session-${users.length}` } } + else if (path.endsWith('/status')) data = body + else assert.fail(path) + return { http: 200, body: { code: '200', data }, requestId: 'request', traceId: 'trace' } + } } + if (failLogin) await assert.rejects(prepareFleetCostActors(context), /login unavailable/) + else { + const actors = await prepareFleetCostActors(context) + assert.equal(actors.listOnlyToken, 'session-1'); assert.equal(actors.deniedToken, 'session-2') + assert.equal(calls.filter(row => row.path.endsWith('/roles/permissions'))[1].body.permissionIds.length, 0) + await actors.close() + const count = calls.length; await actors.close(); assert.equal(calls.length, count) + } + assert.equal(context.createdResources.fleetCostActors.status, 'DISABLED') + assert.equal(calls.filter(row => /\/users\/\d+\/status$/.test(row.path)).length, users.length) + assert.equal(calls.filter(row => /\/roles\/\d+\/status$/.test(row.path)).length, roles.length) + const evidence = JSON.stringify(saved) + assert.ok(!evidence.includes('session-1')); assert.ok(!evidence.includes('admin-secret')) + for (const user of users) assert.ok(!evidence.includes(user.password)) + assert.ok(!calls.some(row => row.method === 'DELETE')) +}) + +test('fresh cost actor phones avoid existing platform users and use an independent namespace under the same original marker', async () => { + const phones = [], ctx = { marker: 'ORIGINAL-RUN', async request(method, path) { + assert.equal(method, 'GET') + let data + if (path.includes('/users?')) { + const phone = new URL('https://test.invalid' + path).searchParams.get('keyword'); phones.push(phone) + data = { records: phones.length === 1 ? [{ phone }] : [], total: phones.length === 1 ? 1 : 0 } + } else data = true + return { http: 200, body: { code: '200', data }, requestId: 'r', traceId: 't' } + } } + const first = await findFleetActorPhone(ctx, 'a'.repeat(16)) + assert.notEqual(first, phones[0]); assert.match(first, /^18\d{9}$/) + const second = await findFleetActorPhone(ctx, 'b'.repeat(16)) + assert.notEqual(first, second) +}) + +test('phone lookup failure stops without any user creation or guessing', async () => { + let calls = 0 + await assert.rejects(findFleetActorPhone({ marker: 'RUN', async request(method) { + assert.equal(method, 'GET'); calls++; return { http: 0 } + } }, 'a'.repeat(16)), /Cannot verify/) + assert.equal(calls, 1) +}) + +test('main chain performs handover after actual DEPART and before receipts, and cleanup follows current-resource closure', async () => { + const fullSource = readFileSync(new URL('./fleet-test-env-acceptance.mjs', import.meta.url), 'utf8') + const source = fullSource.slice(fullSource.indexOf('export async function runFleetScenario')) + const savedDeparture = source.indexOf('proof.departure = departed'), handover = source.indexOf("stage('IN_TRANSIT_HANDOVER')") + const receipt = source.indexOf("stage('RECEIPTS_AND_RESOURCE_RELEASE')"), closure = source.indexOf('proof.closure = await closeTrip') + const cleanup = source.indexOf('await cleanup(context, MODULE, trip)') + assert.ok(savedDeparture > 0 && savedDeparture < handover && handover < receipt && receipt < closure && closure < cleanup) + assert.match(source, /finally \{ await disablePicker\(context, picker, 71\) \}/) +}) + +async function executeFixture(failProducer = false) { + const temporary = mkdtempSync(join(tmpdir(), 'fleet-entry-contract-')) + const root = join(temporary, 'chongchong-global', 'baiyue-firstleg'); mkdirSync(root, { recursive: true }) + const calls = [], secrets = 'never-print-this-secret' + const env = { CHONGCHONG_ENV: 'test', CHONGCHONG_ADMIN_API_BASE_URL: 'https://test.invalid', CHONGCHONG_ADMIN_USERNAME: 'fixture-admin', + CHONGCHONG_ADMIN_PASSWORD: secrets, ADMIN_PDA_USERNAME: 'fixture-pda', ADMIN_PDA_PASSWORD: secrets } + const config = parseFleetArguments(['--execute', '--environment', 'TEST', '--run-id', 'fixture']) + const createContext = options => ({ options, context: {}, createdResources: {}, steps: [], modules: [], coverageGaps: [], token: secrets, + async request(method, path, body) { + const pending = JSON.parse(readFileSync(join(root, 'reports/fleet-fixture/evidence/write-000001.json'), 'utf8')) + assert.equal(pending.request.status, 'SENDING'); assert.deepEqual(pending.request.body, body) + assert.equal(pending.request.path, path); calls.push('unknown-write'); return { http: 0 } + }, + async executeModule(name, handler) { + try { await handler(); this.modules.push({ name, success: true }) } + catch { this.modules.push({ name, success: false }); this.steps.push({ name: 'producer failed', passed: false }) } + }, + async run() { + await this.executeModule('producer', async () => { + calls.push('producer') + if (failProducer === 'unknown') { + try { await this.request('PUT', '/orders/1/remarks?tenantId=2', { expectedVersion: 6, idempotencyKey: 'original-key', internalNotes: ['fixture'] }) } catch { /* Simulate a prerequisite swallowing an error. */ } + await this.request('POST', '/loading/must-not-run', {}) + } + if (failProducer) throw Error('fixture failure') + }) + await this.executeModule('phase4-overseas-dispatch', async () => { calls.push('dispatch'); this.createdResources.current = { accessToken: secrets } }) + return { success: true, steps: this.steps, modules: this.modules, createdResources: this.createdResources, summary: { failed: 0 } } + }, + }) + try { + const options = { root, env, createContext, readers: { async preflight() { calls.push('preflight') }, async readReceiving() { throw Error('not used') } }, + scenario: async context => { + calls.push('fleet'); context.createdResources.fleetAcceptance = { status: 'HTTP_CHAIN_VERIFIED_WITH_ACK_GAP' } + const command = { requests: [{ method: 'POST', status: 'SENDING', body: { idempotencyKey: 'original-key' }, accessToken: secrets }] } + await context.context.checkpointFleetHandover(command) + const firstPath = join(root, 'reports/fleet-fixture/evidence/handover-000001.json') + const before = readFileSync(firstPath, 'utf8'), saved = JSON.parse(before) + assert.equal(saved.proof.requests[0].body.idempotencyKey, 'original-key') + assert.equal(saved.proof.requests[0].status, 'SENDING'); assert.ok(!before.includes(secrets)) + command.requests[0].status = 'RESPONDED' + await context.context.checkpointFleetHandover(command) + assert.equal(readFileSync(firstPath, 'utf8'), before) + assert.equal(JSON.parse(readFileSync(join(root, 'reports/fleet-fixture/evidence/handover-000002.json'), 'utf8')).proof.requests[0].status, 'RESPONDED') + } } + const result = await runFleetAcceptance(config, options) + const report = JSON.parse(readFileSync(result.report, 'utf8')) + if (failProducer === 'unknown') { + const unknown = JSON.parse(readFileSync(join(root, 'reports/fleet-fixture/evidence/write-000002.json'), 'utf8')) + assert.equal(unknown.request.status, 'UNKNOWN_RESULT'); assert.equal(unknown.request.body.expectedVersion, 6) + assert.equal(unknown.request.body.idempotencyKey, 'original-key') + } + assert.ok(!readFileSync(result.report, 'utf8').includes(secrets)) + assert.equal(existsSync(join(root, 'reports/.fleet-acceptance.lock')), false) + const oldReport = readFileSync(result.report, 'utf8') + const repeated = await runFleetAcceptance(config, options) + assert.equal(repeated.success, false) + assert.equal(readFileSync(result.report, 'utf8'), oldReport, 'existing run is never overwritten or resumed') + return { result, report, calls } + } finally { + assert.equal(dirname(resolve(temporary)), resolve(tmpdir())) + assert.ok(resolve(temporary).startsWith(join(resolve(tmpdir()), 'fleet-entry-contract-'))) + rmSync(temporary, { recursive: true, force: true }) + } +} + +test('fresh entry runs dedicated fleet scenario after prerequisites without altering the default F40 chain', async () => { + const { result, report, calls } = await executeFixture() + assert.equal(result.success, true) + assert.deepEqual(calls, ['preflight', 'producer', 'dispatch', 'fleet']) + assert.equal(report.fleetGoalComplete, false) + assert.equal(report.createdResources.current.accessToken, '[REDACTED]') +}) + +test('failed prerequisite stops before further fixture creation and retains the failed report', async () => { + const { result, report, calls } = await executeFixture(true) + assert.equal(result.success, false) + assert.deepEqual(calls, ['preflight', 'producer']) + assert.equal(report.fatal, 'Producer stopped: producer') +}) + +test('real fleet entry journals prerequisite writes before sending and stops loading even if producer catches unknown outcome', async () => { + const { result, report, calls } = await executeFixture('unknown') + assert.equal(result.success, false) + assert.deepEqual(calls, ['preflight', 'producer', 'unknown-write']) + assert.equal(report.fatal, 'Producer stopped: producer') +}) + +test('environment mismatch rejects before invoking any readers or contexts', async () => { + let touched = false + await assert.rejects(() => runFleetAcceptance({ execute: true, environment: 'TEST', runId: 'new' }, { + env: { CHONGCHONG_ENV: 'production' }, readers: { preflight() { touched = true } }, + }), /TEST/) + assert.equal(touched, false) +}) + +test('reviewed resume requires explicit paired checkpoint options', () => { + assert.throws(() => parseFleetArguments(['--resume-report', 'reports/old/report.json'])) + assert.throws(() => parseFleetArguments(['--resume-report', 'reports/old/report.json', '--resume-stage', 'PICK_EACH_DISPATCH'])) + const c = parseFleetArguments(['--execute', '--environment', 'TEST', '--run-id', 'reviewed', '--resume-report', 'reports/old/report.json', '--resume-stage', 'FULL_COVERAGE']) + assert.equal(c.resumeStage, 'FULL_COVERAGE') +}) + +test('checkpoint guard forbids repeating commands whose write outcome may be unknown', () => { + const report = { success: false, createdResources: { + fleetAcceptance: { marker: 'M', stage: 'FULL_COVERAGE', status: 'CHECKING', tripNo: 'T', originalDispatch: { dispatchNo: 'D' } }, + fleetTrips: [{ tripNo: 'T', dispatchNo: 'D' }], + fleetFeeCoverageCases: [{ mode: 'FULL', marker: 'M', dispatchNo: 'D', proof: { status: 'CHECKING', requests: [{ method: 'GET' }] } }], + } } + assert.equal(reviewedFullCheckpoint(report).marker, 'M') + const mutated = structuredClone(report) + mutated.createdResources.fleetFeeCoverageCases[0].proof.requests.push({ method: 'POST' }) + assert.throws(() => reviewedFullCheckpoint(mutated), /unknown outcomes/) + mutated.createdResources.fleetFeeCoverageCases[0].proof.requests.pop() + mutated.createdResources.fleetFeeCoverageCases[0].proof.outboundNo = 'OUT-1' + assert.throws(() => reviewedFullCheckpoint(mutated), /unknown outcomes/) + assert.throws(() => reviewedFullCheckpoint(mutated, 'FULL_COVERAGE_PERSISTED'), /exact successful command/) + Object.assign(mutated.createdResources.fleetFeeCoverageCases[0].proof, { deliveryNos: ['DEL-1'], confirmationRequest: { idempotencyKey: 'original-key' } }) + mutated.createdResources.fleetFeeCoverageCases[0].proof.requests.push({ label: '正式保存本次原费用服务覆盖并转派送', method: 'POST', http: 200, code: '200' }) + assert.equal(reviewedFullCheckpoint(mutated, 'FULL_COVERAGE_PERSISTED').persisted, true) +}) + +test('receipt-ready reconciliation cannot repeat a receipt that has already started', () => { + const report = { success: false, steps: [{ name: '创建海外PDA仓库操作员71', code: 'A0409', message: '手机号已存在' }], createdResources: { + fleetAcceptance: { marker: 'M', stage: 'RESERVATIONS', status: 'CHECKING', tripNo: 'T' }, + fleetTrips: [{ tripNo: 'T', dispatchNo: 'D2', reservation: { status: 'VERIFIED' } }], + fleetFeeCoverageCases: [{ mode: 'FULL', proof: { status: 'VERIFIED' } }, { mode: 'PARTIAL', dispatchNo: 'D2', proof: { status: 'VERIFIED' } }], + } } + assert.equal(reviewedFullCheckpoint(report, 'EXTERNAL_RECEIPT_READY').receiptReady, true) + report.createdResources.fleetTrips[0].externalReceipt = { status: 'CHECKING' } + assert.throws(() => reviewedFullCheckpoint(report, 'EXTERNAL_RECEIPT_READY'), /no receipt writes/) +}) + +test('palletized checkpoint requires a saved successful command, owned operator, and no later confirmation', () => { + const report = { success: false, createdResources: { + fleetAcceptance: { marker: 'M', stage: 'EXTERNAL_RECEIPT', status: 'CHECKING', tripNo: 'T', picker: { membershipId: '71' }, pickerMarker: 'M-PDA' }, + fleetTrips: [{ tripNo: 'T', dispatchNo: 'D2', reservation: { status: 'VERIFIED' }, externalReceipt: { status: 'CHECKING', + warehousePalletId: 'P1', palletizeRequest: { idempotencyKey: 'original' }, requests: [ + { label: 'PDA正式完成外接收货及库存入账', method: 'POST', http: 200, code: '200' }, + ] } }], + fleetFeeCoverageCases: [{ mode: 'FULL', proof: { status: 'VERIFIED' } }, { mode: 'PARTIAL', dispatchNo: 'D2', proof: { status: 'VERIFIED' } }], + } } + assert.equal(reviewedFullCheckpoint(report, 'EXTERNAL_PALLETIZED').palletized, true) + assert.throws(() => reviewedFullCheckpoint(report, 'EXTERNAL_RECEIPT_CONFIRMED'), /exact successful PDA command/) + const confirmed = structuredClone(report) + confirmed.createdResources.fleetTrips[0].externalReceipt.receiptRequest = { idempotencyKey: 'confirm-original' } + assert.throws(() => reviewedFullCheckpoint(confirmed, 'EXTERNAL_RECEIPT_CONFIRMED'), /exact successful PDA command/) + confirmed.createdResources.fleetTrips[0].externalReceipt.requests.push({ label: '运营按实际1托位和新单价30确认实收', method: 'POST', http: 200, code: '200' }) + assert.equal(reviewedFullCheckpoint(confirmed, 'EXTERNAL_RECEIPT_CONFIRMED').receiptConfirmed, true) + assert.throws(() => reviewedFullCheckpoint(confirmed, 'EXTERNAL_PALLETIZED'), /exact successful PDA command/) + for (const mutate of [ + r => { r.fleetTrips[0].externalReceipt.requests[0].code = 'A0409' }, + r => { delete r.fleetTrips[0].externalReceipt.palletizeRequest.idempotencyKey }, + r => { delete r.fleetAcceptance.picker }, + r => { r.fleetTrips[0].externalReceipt.receiptRequest = { idempotencyKey: 'later' } }, + r => { r.fleetTrips[0].externalDispatch = { status: 'CHECKING' } }, + ]) { + const invalid = structuredClone(report); mutate(invalid.createdResources) + assert.throws(() => reviewedFullCheckpoint(invalid, 'EXTERNAL_PALLETIZED'), /exact successful PDA command/) + } +}) + +function terminalFixture(fault = '') { + const finalTrip = { status: 'COMPLETED', completedAt: '2026-10-02T22:01:00Z', recordVersion: '30', vehicleRelease: { endLocation: 'ONT8' }, driverRelease: { endLocation: 'ONT8' } } + const closure = { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED', finalTrip, probes: [{ tripNo: 'PROBE' }] } + const trip = { tripNo: 'T', closure, externalReceipt: { status: 'VERIFIED' }, reassignment: { status: 'VERIFIED' }, externalDispatch: { status: 'VERIFIED', dispatchNo: 'EXT', deliveryNo: 'DEL-EXT' } } + const proof = { marker: 'M', tripNo: 'T', customerId: '92', stage: 'RECEIPTS_AND_RESOURCE_RELEASE', closure, + receipts: ['DEL-OWN', 'DEL-EXT'].map(deliveryNo => ({ deliveryNo, signedAt: '2026-10-02T22:00:00' })), + departure: { shippedPackages: 6 }, finance: { status: 'CONSUMER_PERSISTED', source: { sourceFeeId: 'F1', sourceVersion: 1 } } } + const resources = { fleetAcceptance: proof, fleetTrips: [trip], fleetFeeCoverageCases: [{ mode: 'PARTIAL', dispatchNo: 'OWN', proof: { deliveryNos: ['DEL-OWN'] } }] } + const calls = [] + const context = { createdResources: resources, assert() {}, async request(method, path) { + calls.push({ method, path }); assert.equal(method, 'GET') + let data + if (path.endsWith('/trips/T')) data = { trip: finalTrip, dispatches: ['OWN', 'EXT'].map(dispatchNo => ({ dispatchNo })) } + else if (path.endsWith('/trips/PROBE')) data = { trip: { status: 'CANCELLED' } } + else if (path.includes('/dispatches/')) { const number = path.split('/').at(-1), count = number === 'OWN' ? 5 : 1 + data = { dispatchNo: number, status: 'DEPARTED', packageCount: count, pickedPackageCount: count, shippedPackageCount: fault === 'quantity' ? 0 : count } } + else if (path.includes('/delivery-orders/')) data = { order: { deliveryNo: path.split('/').at(-1).split('?')[0], status: fault === 'unsigned' ? 'WAIT_DELIVERY' : 'DELIVERED', signedAt: '2026-10-02T22:00:00' } } + else if (path.includes('/fee-pool?')) data = [{ customerId: '92', sourceId: 'F1', sourceVersion: fault === 'wrong-fee-version' ? 2 : 1, amount: 30, currency: 'CNY', sourceType: 'OVERSEAS_DELIVERY_FEE' }] + else throw Error('unexpected path') + return { http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'r', traceId: 't' } + } } + return { context, calls, report: { success: false, createdResources: resources, steps: [{ name: '本次车次货物结案及车辆司机释放完成', passed: false }] } } +} + +test('terminal reconciliation reads actual completion, every dispatch/delivery and exact fee without replaying writes', async () => { + const f = terminalFixture() + assert.equal(reviewedFullCheckpoint(f.report, 'TERMINAL_READONLY').terminal, true) + const result = await verifyFleetTerminalState(f.context) + assert.equal(result.terminalReadback.status, 'VERIFIED') + assert.equal(f.calls.length, 7) + assert.ok(f.calls.every(row => row.method === 'GET')) + const invalid = terminalFixture().report + invalid.steps.push({ name: '真实业务失败', passed: false }) + assert.throws(() => reviewedFullCheckpoint(invalid, 'TERMINAL_READONLY'), /known aggregate assertion/) +}) + +for (const fault of ['quantity', 'unsigned', 'wrong-fee-version']) test(`terminal readback rejects ${fault}`, async () => { + const f = terminalFixture(fault) + await assert.rejects(() => verifyFleetTerminalState(f.context)) + assert.equal(f.context.createdResources.fleetAcceptance.terminalReadback.status, 'CHECKING') +}) diff --git a/scripts/fleet-trip-rollback-test-env.mjs b/scripts/fleet-trip-rollback-test-env.mjs new file mode 100644 index 000000000..51dce2e60 --- /dev/null +++ b/scripts/fleet-trip-rollback-test-env.mjs @@ -0,0 +1,142 @@ +// Explicit TEST-only row-lock fault; importing this file performs no I/O. +import fs from 'node:fs' +import { createHash } from 'node:crypto' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { parseCrossCustomerArguments, runCrossCustomer } from './fleet-cross-customer-test-env.mjs' +import { verifyFleetCrossCustomer } from './admin-test-env-modules/fleet-cross-customer-acceptance.mjs' + +const demand=(v,message)=>{if(!v)throw Error(message)} +const literal=v=>`CONVERT(0x${Buffer.from(v).toString('hex')} USING utf8mb4) COLLATE utf8mb4_bin` +const encoded=v=>'['+Buffer.from(v).toString('base64').match(/.{1,4000}/g).map(x=>`'${x}'`).join(',')+"].join('')" +const id=v=>String(v??'') +const rr=['SET NAMES utf8mb4 COLLATE utf8mb4_bin;','SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;','START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;'] +const pause=ms=>new Promise(done=>setTimeout(done,ms)) +const canonical=v=>JSON.stringify(sortFields(v)) +function sortFields(v){return Array.isArray(v)?v.map(sortFields):v&&typeof v==='object'?Object.fromEntries(Object.keys(v).sort().map(k=>[k,sortFields(v[k])])):v} +export function validateRollbackScope(s) { + demand(s&&/^[A-Za-z0-9_-]{1,64}$/.test(s.marker||'')&&/^[A-Za-z0-9_-]{1,64}$/.test(s.tripNo||''),'ROLLBACK_SCOPE_REQUIRED') + for(const key of ['firstCustomerId','secondCustomerId','firstDispatchId','secondDispatchId','firstOutboundId','secondPalletId']) + demand(typeof s[key]==='string'&&/^[1-9][0-9]{0,19}$/.test(s[key]),'ROLLBACK_IDENTIFIER_INVALID') + demand(s.firstCustomerId!==s.secondCustomerId&&s.firstDispatchId!==s.secondDispatchId&&/^sha256:[a-f0-9]{64}$/.test(s.expectedImage||''),'ROLLBACK_DISTINCT_CUSTOMERS_AND_RUNTIME_REQUIRED') + demand(/^[A-Za-z0-9_-]{1,64}$/.test(s.firstDispatchNo||''),'ROLLBACK_DISPATCH_NUMBER_INVALID') + return s +} +export function rollbackSql(scope,mode) { + const s=validateRollbackScope(scope),second=`customer_id=${literal(s.secondCustomerId)} AND dispatch_order_id=${s.secondDispatchId} AND warehouse_pallet_id=${s.secondPalletId} AND status='ACTIVE'` + if(mode==='LEASE')return `SET SESSION innodb_lock_wait_timeout=5;START TRANSACTION;SELECT JSON_OBJECT('lockId',CAST(id AS CHAR),'customerId',customer_id,'connectionId',CONNECTION_ID()) FROM ll_dispatch_pallet_lock WHERE ${second} FOR UPDATE;SELECT JSON_OBJECT('marker','ROLLBACK_${s.marker}','elapsed',SLEEP(65));ROLLBACK;SELECT JSON_OBJECT('released',JSON_EXTRACT('true','$'));` + const lease=`SELECT JSON_OBJECT('collection','lease','connectionId',t.trx_mysql_thread_id,'lockId',l.LOCK_DATA) FROM performance_schema.data_locks l JOIN information_schema.innodb_trx t ON t.trx_id=l.ENGINE_TRANSACTION_ID WHERE l.OBJECT_SCHEMA=DATABASE() AND l.OBJECT_NAME='ll_dispatch_pallet_lock' AND l.INDEX_NAME='PRIMARY' AND l.LOCK_STATUS='GRANTED' AND l.LOCK_DATA IN (SELECT CAST(id AS CHAR) FROM ll_dispatch_pallet_lock WHERE ${second}) AND t.trx_query LIKE ${literal('%ROLLBACK_'+s.marker+'%')};` + const waits=`SELECT JSON_OBJECT('collection','wait','requester',r.trx_mysql_thread_id,'blocker',b.trx_mysql_thread_id,'lockId',l.LOCK_DATA) FROM performance_schema.data_lock_waits w JOIN performance_schema.data_locks l ON l.ENGINE_LOCK_ID=w.REQUESTING_ENGINE_LOCK_ID JOIN information_schema.innodb_trx r ON r.trx_id=w.REQUESTING_ENGINE_TRANSACTION_ID JOIN information_schema.innodb_trx b ON b.trx_id=w.BLOCKING_ENGINE_TRANSACTION_ID WHERE l.OBJECT_SCHEMA=DATABASE() AND l.OBJECT_NAME='ll_dispatch_pallet_lock' AND l.INDEX_NAME='PRIMARY' AND l.LOCK_DATA IN (SELECT CAST(id AS CHAR) FROM ll_dispatch_pallet_lock WHERE ${second});` + if(mode==='LOCKS')return [...rr,lease,waits,'COMMIT;'].join('\n') + if(mode==='OBSERVE')return ['SET NAMES utf8mb4 COLLATE utf8mb4_bin;','SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;','START TRANSACTION READ ONLY;',lease,waits, + `SELECT JSON_OBJECT('collection','firstDispatch','id',CAST(id AS CHAR),'status',status,'shipped',shipped_package_count) FROM ll_dispatch_order WHERE id=${s.firstDispatchId} AND customer_id=${literal(s.firstCustomerId)};`, + `SELECT JSON_OBJECT('collection','firstOutbound','id',CAST(id AS CHAR),'status',status) FROM ll_outbound_order WHERE id=${s.firstOutboundId} AND customer_id=${literal(s.firstCustomerId)};`, + `SELECT JSON_OBJECT('collection','firstReplay','count',COUNT(*)) FROM ll_dispatch_pda_operation WHERE customer_id=${literal(s.firstCustomerId)} AND operation_type='DEPART' AND idempotency_key=${literal(s.tripNo+':'+s.firstDispatchNo)};`,'COMMIT;'].join('\n') + if(mode==='RELEASED')return [...rr,lease,waits,'COMMIT;'].join('\n') + throw Error('ROLLBACK_MODE_INVALID') +} +export function buildRollbackRemoteScript(scope,mode,agent) { + demand(['RUNTIME','LEASE','LOCKS','OBSERVE','RELEASED'].includes(mode)&&/^[A-Za-z0-9_.-]{1,128}$/.test(agent||''),'ROLLBACK_REMOTE_MODE_REQUIRED') + const sql=mode==='RUNTIME'?"SELECT JSON_OBJECT('lockWaitSeconds',@@GLOBAL.innodb_lock_wait_timeout);":rollbackSql(scope,mode) + const imageGuard=mode==='RUNTIME'?'':` or c['Image']!='${scope.expectedImage}'` + const python=`import json,os,subprocess,urllib.parse,re +try: + c=json.loads(subprocess.check_output(['docker','inspect','firstleg-test-lastleg'],text=True,stderr=subprocess.DEVNULL))[0] + e=dict(x.split('=',1) for x in c['Config']['Env']) + if c['Name']!='/firstleg-test-lastleg' or c['Config'].get('Labels',{}).get('com.docker.compose.project')!='firstleg-test' or e.get('DEPLOY_ENV')!='test' or c['State'].get('Health',{}).get('Status')!='healthy'${imageGuard}:raise RuntimeError('TEST_RUNTIME_CHANGED') + u=urllib.parse.urlsplit(e['LASTLEG_DB_URL'].removeprefix('jdbc:'));host='127.0.0.1' if u.hostname=='host.docker.internal' else u.hostname + q=subprocess.run(['mysql','--host='+host,'--port='+str(u.port or 3306),'--user='+e['LASTLEG_DB_USERNAME'],'--batch','--raw','--skip-column-names',u.path.lstrip('/')],input=${JSON.stringify(sql)},capture_output=True,text=True,env=dict(os.environ,MYSQL_PWD=e['LASTLEG_DB_PASSWORD']),timeout=${mode==='LEASE'?75:25}) + if q.returncode: + code=re.search(r'ERROR ([0-9]{3,5})',q.stderr);print('TRIP_ROLLBACK_JSON='+json.dumps({'ok':False,'error':'MYSQL_'+(code.group(1) if code else 'UNKNOWN')})) + else:print('TRIP_ROLLBACK_JSON='+json.dumps({'ok':True,'imageId':c['Image'],'rows':[json.loads(x) for x in q.stdout.splitlines() if x.strip()]})) +except Exception as error: + print('TRIP_ROLLBACK_JSON='+json.dumps({'ok':False,'error':str(error) if re.fullmatch('[A-Z_]{1,64}',str(error)) else 'REMOTE_ROLLBACK_FAILED'})) +` + const groovy=`def p=new ProcessBuilder(['python3','-c',new String(java.util.Base64.decoder.decode(${encoded(python)}),'UTF-8')]).redirectErrorStream(true).start();def out=p.inputStream.getText('UTF-8');p.waitFor();print(out)` + return `def c=jenkins.model.Jenkins.instance.getComputer('${agent}');if(c==null||c.channel==null)throw new RuntimeException('TEST agent unavailable');print(hudson.util.RemotingDiagnostics.executeGroovy(new String(java.util.Base64.decoder.decode(${encoded(groovy)}),'UTF-8'),c.channel))` +} +export function assertMidTransactionObservation(rows) { + const one=name=>rows.filter(x=>x.collection===name) + const lease=one('lease'),wait=one('wait'),dispatch=one('firstDispatch'),outbound=one('firstOutbound'),replay=one('firstReplay') + demand(lease.length===1&&wait.length===1&&wait[0].blocker===lease[0].connectionId&&wait[0].lockId===lease[0].lockId&&wait[0].requester!==wait[0].blocker,'OWNED_LATE_ROW_WAIT_NOT_PROVED') + demand(dispatch.length===1&&dispatch[0].status==='DEPARTED'&&dispatch[0].shipped===1&&outbound.length===1&&outbound[0].status==='OUTBOUNDED'&&replay.length===1&&replay[0].count===1,'FIRST_CUSTOMER_UNCOMMITTED_WRITE_NOT_PROVED') + return {leaseConnectionId:lease[0].connectionId,requestingConnectionId:wait[0].requester,lockId:lease[0].lockId,dirtyObservationOnly:true} +} +export function requestRollbackDeparture(context,path,body,token) { + // The normal JSON request stops at 30 seconds. This deliberate 50-second + // database timeout needs the existing 60-second, single-attempt request path. + return context.requestWithBody('POST',path,body,token) +} +export function assertDatabaseLockConflict(response) { + demand(response?.http===200&&String(response.body?.code)==='A0409' + &&response.body?.message==='资源正在处理中,请稍后重试' + &&response.requestId&&response.traceId,'EXPECTED_DATABASE_LOCK_CONFLICT_REQUIRED') +} +async function remote(env,scope,mode) { + const script=buildRollbackRemoteScript(scope,mode,env.FBA_CORE_JENKINS_AGENT) + const response=await fetch(new URL('scriptText',env.JENKINS_URL.replace(/\/?$/,'/')),{method:'POST',redirect:'error',signal:AbortSignal.timeout(mode==='LEASE'?95000:45000),headers:{Authorization:'Basic '+Buffer.from(env.JENKINS_USER+':'+env.JENKINS_API_TOKEN).toString('base64'),'Content-Type':'application/x-www-form-urlencoded'},body:new URLSearchParams({script})}) + demand(response.ok,'ROLLBACK_EVIDENCE_HTTP_FAILED') + const lines=(await response.text()).split(/\r?\n/).filter(x=>x.startsWith('TRIP_ROLLBACK_JSON='));demand(lines.length===1,'ROLLBACK_REMOTE_RESULT_AMBIGUOUS') + const result=JSON.parse(lines[0].slice('TRIP_ROLLBACK_JSON='.length));demand(result.ok===true,/^[A-Z_0-9]+$/.test(result.error||'')?result.error:'ROLLBACK_REMOTE_FAILED') + return result +} +export function createDepartureRollbackVerifier(env,config,runtime) { + return async({context,fixture,ready,departPath,departBody,snapshot})=>{ + const output=resolve('reports',`fleet-cross-${config.runId}`,'rollback.json'),fd=fs.openSync(output,'wx',0o600) + const report={verified:false,scope:'OWNED_TWO_CUSTOMER_DEPARTURE_ROLLBACK',startedAt:new Date().toISOString(),samples:[],command:{path:departPath,body:departBody},runtime} + const save=()=>{fs.ftruncateSync(fd,0);fs.writeSync(fd,JSON.stringify(report,null,2),0,'utf8');fs.fsyncSync(fd)} + let leasePromise,pending + try { + demand(ready.trip.dispatches?.length===2&&fixture.members.length===2,'TWO_CUSTOMER_READY_SCOPE_REQUIRED') + const [first,second]=ready.trip.dispatches.map(binding=>fixture.members.find(x=>x.dispatchNo===binding.dispatchNo)) + demand(first&&second&&first.customerId!==second.customerId&&first.dispatchNo===fixture.members[0].dispatchNo,'CURRENT_DEPARTURE_ORDER_REQUIRED') + const firstState=ready.members[fixture.members.indexOf(first)],secondState=ready.members[fixture.members.indexOf(second)] + const scope=report.target=validateRollbackScope({marker:context.marker,tripNo:fixture.tripNo,expectedImage:runtime.imageId, + firstCustomerId:first.customerId,secondCustomerId:second.customerId,firstDispatchId:id(firstState.dispatch.id),secondDispatchId:id(secondState.dispatch.id), + firstOutboundId:id(firstState.outbound.outbound.id),secondPalletId:second.palletId,firstDispatchNo:first.dispatchNo}) + save();leasePromise=remote(env,scope,'LEASE').then(value=>({value}),error=>({error:error.message})) + for(let i=0;i<12;i++) { + const rows=(await remote(env,scope,'LOCKS')).rows;report.samples.push({phase:'LEASE',rows});save() + if(rows.filter(x=>x.collection==='lease').length===1)break + demand(i!==11,'LIVE_LEASE_NOT_OBSERVED');await pause(250) + } + pending=requestRollbackDeparture(context,departPath,departBody,fixture.pdaToken).then(value=>({value}),error=>({error:error.message})) + for(let i=0;i<20;i++) { + const rows=(await remote(env,scope,'OBSERVE')).rows;report.samples.push({phase:'MID_TRANSACTION',rows});save() + if(rows.some(x=>x.collection==='wait')) {report.observation=assertMidTransactionObservation(rows);save();break} + demand(i!==19,'MID_TRANSACTION_NOT_OBSERVED');await pause(250) + } + report.response=await pending;save() + report.lease=await leasePromise;save() + demand(!report.lease.error&&report.lease.value.rows.at(-1)?.released===true,'LEASE_RELEASE_NOT_PROVED') + const released=report.released=(await remote(env,scope,'RELEASED')).rows + demand(released.length===0,'OWNED_ROW_LOCK_OR_WAITER_REMAINS') + const restored=await snapshot('ROLLBACK_AFTER_FAULT') + report.consistentSnapshot={beforeSha256:createHash('sha256').update(canonical(ready)).digest('hex'),afterSha256:createHash('sha256').update(canonical(restored)).digest('hex')} + demand(report.consistentSnapshot.beforeSha256===report.consistentSnapshot.afterSha256,'FULL_TWO_CUSTOMER_ROLLBACK_SNAPSHOT_CHANGED') + demand(!report.response.error&&report.response.value?.http!==0,'DEPARTURE_OUTCOME_UNKNOWN_RETAIN_ORIGINAL_COMMAND') + assertDatabaseLockConflict(report.response.value) + // The caller also checks restoration before sending the same original command. + report.verified=true;return report + }catch(error){report.error=error.message;throw error} + finally { + if(pending&&!report.response)report.response=await pending + if(leasePromise&&!report.lease)report.lease=await leasePromise + report.finishedAt=new Date().toISOString();save();fs.closeSync(fd) + } + } +} +export async function runTripRollback(config,{env=process.env}={}) { + if(!config.execute)return {...config,scope:'OWNED_TWO_CUSTOMER_DEPARTURE_ROLLBACK',fault:'65-second SELECT FOR UPDATE lease on the second customer pallet-lock row; first customer writes observed as uncommitted, default 50-second lock timeout rolls back the entire original request; then same original command and full normal chain',businessSqlWrites:false,deployment:false} + demand(env.CHONGCHONG_ENV==='test'&&['JENKINS_URL','JENKINS_USER','JENKINS_API_TOKEN','FBA_CORE_JENKINS_AGENT'].every(key=>env[key]),'EXPLICIT_TEST_EVIDENCE_PROFILE_REQUIRED') + const runtime=await remote(env,{},'RUNTIME');demand(runtime.rows.length===1&&runtime.rows[0].lockWaitSeconds===50,'DEFAULT_50_SECOND_LOCK_TIMEOUT_REQUIRED') + const sources=['scripts/fleet-trip-rollback-test-env.mjs','scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs'].map(path=>({path,sha256:createHash('sha256').update(fs.readFileSync(path)).digest('hex')})) + return runCrossCustomer(config,{env,verify:async(context,fixture,options)=>{ + fs.writeFileSync(resolve('reports',`fleet-cross-${config.runId}`,'rollback-source.json'),JSON.stringify({runtime,sources},null,2),{flag:'wx'}) + return verifyFleetCrossCustomer(context,fixture,{...options,verifyDepartureRollback:createDepartureRollbackVerifier(env,config,runtime)}) + }}) +} +if(process.argv[1]&&import.meta.url===pathToFileURL(resolve(process.argv[1])).href) { + try {const config=parseCrossCustomerArguments(process.argv.slice(2)),result=await runTripRollback(config);console.log(JSON.stringify(config.execute?{success:result.success,error:result.error,summary:result.summary,requests:result.requestCount,goalComplete:false}:result,null,2));if(config.execute&&!result.success)process.exitCode=1} + catch(error){console.error(/^[A-Z_0-9]+$/.test(error.message)?error.message:'ROLLBACK_ENTRY_FAILED');process.exitCode=1} +} diff --git a/scripts/fleet-trip-rollback-test-env.test.mjs b/scripts/fleet-trip-rollback-test-env.test.mjs new file mode 100644 index 000000000..f8f76d297 --- /dev/null +++ b/scripts/fleet-trip-rollback-test-env.test.mjs @@ -0,0 +1,51 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { validateRollbackScope, rollbackSql, buildRollbackRemoteScript, assertMidTransactionObservation, runTripRollback, requestRollbackDeparture, assertDatabaseLockConflict } from './fleet-trip-rollback-test-env.mjs' +const scope={marker:'OWN_RUN',tripNo:'TR-OWN',firstCustomerId:'101',secondCustomerId:'102',firstDispatchId:'201',secondDispatchId:'202',firstOutboundId:'301',secondPalletId:'401',firstDispatchNo:'DS-OWN',expectedImage:'sha256:'+'a'.repeat(64)} +const rows=()=>[{collection:'lease',connectionId:11,lockId:'99'},{collection:'wait',requester:12,blocker:11,lockId:'99'}, + {collection:'firstDispatch',id:'201',status:'DEPARTED',shipped:1},{collection:'firstOutbound',id:'301',status:'OUTBOUNDED'},{collection:'firstReplay',count:1}] +test('fault request allows database timeout and never retries an unknown write',async()=>{ + const body={recordVersion:'11',idempotencyKey:'ORIGINAL'},calls=[] + const context={request:()=>{throw Error('30_SECOND_CHANNEL')},requestWithBody:async(...args)=>{calls.push(args);return {http:0,body:null}}} + assert.equal((await requestRollbackDeparture(context,'/owned/depart',body,'owned-token')).http,0) + assert.deepEqual(calls,[['POST','/owned/depart',body,'owned-token']]) +}) +test('database conflict requires exact handler response and trace, not arbitrary rejection',()=>{ + const response={http:200,body:{code:'A0409',message:'资源正在处理中,请稍后重试'},requestId:'request',traceId:'trace'} + assert.doesNotThrow(()=>assertDatabaseLockConflict(response)) + for(const wrong of [{http:0},{http:500},{body:{code:'S0500',message:'error'}},{body:{code:'A0409',message:'请完成拣货'}},{traceId:null}]) + assert.throws(()=>assertDatabaseLockConflict({...response,...wrong})) +}) +test('plan and import are network-free; execution rejects missing TEST profile before any network',async()=>{ + const plan=await runTripRollback({execute:false},{env:{}}) + assert.equal(plan.businessSqlWrites,false);assert.equal(plan.deployment,false) + await assert.rejects(runTripRollback({execute:true},{env:{CHONGCHONG_ENV:'prod'}}),/TEST/) +}) +test('scope rejects reused customers, injection and unpinned runtime',()=>{ + assert.equal(validateRollbackScope(scope),scope) + for(const changed of [{secondCustomerId:'101'},{secondDispatchId:'201'},{firstDispatchId:'1 OR 1=1'},{marker:"RUN';DELETE"},{expectedImage:'latest'}])assert.throws(()=>validateRollbackScope({...scope,...changed})) +}) +test('lease contains only owned locking reads, fixed duration and rollback, without mutation or schema change',()=>{ + const sql=rollbackSql(scope,'LEASE') + assert.match(sql,/dispatch_order_id=202/);assert.match(sql,/warehouse_pallet_id=401/);assert.match(sql,/FOR UPDATE;.*SLEEP\(65\)\);ROLLBACK;/) + assert.doesNotMatch(sql,/\b(?:INSERT|DELETE|ALTER|DROP|TRUNCATE|REPLACE|KILL|SET GLOBAL)\b/) + assert.equal((sql.match(/UPDATE/g)||[]).length,1) + assert.throws(()=>rollbackSql(scope,'CUSTOM_SQL')) + assert.throws(()=>buildRollbackRemoteScript(scope,'LEASE',"agent');evil()")) +}) +test('dirty observation is separate from final consistent cleanup evidence',()=>{ + assert.match(rollbackSql(scope,'OBSERVE'),/READ UNCOMMITTED/) + assert.match(rollbackSql(scope,'RELEASED'),/REPEATABLE READ/) + assert.match(rollbackSql(scope,'RELEASED'),/READ ONLY/) + assert.equal(assertMidTransactionObservation(rows()).dirtyObservationOnly,true) +}) +for(const fault of ['foreign blocker','no wait','two waiters','not yet written','no outbound write','no replay write'])test(`refuses indirect mid-transaction evidence: ${fault}`,()=>{ + const value=rows() + if(fault==='foreign blocker')value[1].blocker=77 + if(fault==='no wait')value.splice(1,1) + if(fault==='two waiters')value.push({...value[1],requester:13}) + if(fault==='not yet written')value[2].status='READY_TO_DEPART' + if(fault==='no outbound write')value[3].status='WAIT_OUTBOUND' + if(fault==='no replay write')value[4].count=0 + assert.throws(()=>assertMidTransactionObservation(value)) +}) diff --git a/scripts/full-regression-entry-contract.test.mjs b/scripts/full-regression-entry-contract.test.mjs index 74939ae8a..7f7ffb1f0 100644 --- a/scripts/full-regression-entry-contract.test.mjs +++ b/scripts/full-regression-entry-contract.test.mjs @@ -20,8 +20,12 @@ test('frontend full regression includes domestic and overseas PDA node tests by assert.match(frontendScript, /tests\/\*\.test\.mjs/) }) -test('backend full regression includes file reactor and critical file/container coverage', () => { - assert.match(backendScript, /-pl["',\s\S]*order,firstleg,iam,gateway,file/) +test('backend full regression uses the entire root reactor and preserves critical file/container coverage', () => { + const args = backendScript.match(/\$mavenArgs\s*=\s*@\(([^\r\n]+)\)/)?.[1] + assert.equal(args, '"-f", $pom, "test"') + assert.match(backendScript, /\$pom = Join-Path \$repoRoot "pom.xml"/) + assert.doesNotMatch(args, /-pl|-rf|-N|skipTests|maven.test.skip/) + assert.match(backendScript, /complete root POM reactor/) for (const required of [ 'file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java', 'file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java', @@ -36,6 +40,7 @@ test('backend full regression includes file reactor and critical file/container test('backend full regression rejects Mapper registration drift before Maven', () => { assert.match(backendScript, /scripts\/mapper-scan-contract\.mjs/) + assert.match(backendScript, /& node --test[^\r\n]*gateway-route-contract\.test\.mjs[\s\S]*& node \$mapperScanContract/) assert.match(backendScript, /& node \$mapperScanContract[\s\S]*& \$MavenCommand @mavenArgs/) }) diff --git a/scripts/gateway-route-contract.test.mjs b/scripts/gateway-route-contract.test.mjs index 75b40ca37..73470f076 100644 --- a/scripts/gateway-route-contract.test.mjs +++ b/scripts/gateway-route-contract.test.mjs @@ -80,7 +80,6 @@ function verify(path, service, table, file) { } const domestic = routes(read('gateway/src/main/resources/application.yml')) -const overseas = routes(read('gateway-overseas/src/main/resources/application-overseas.yml')) const modules = [...read('pom.xml').matchAll(/([^<]+)<\/module>/g)].map(m => m[1]) test('local regression and root CI require route checks before Maven/dispatch', () => { @@ -101,8 +100,7 @@ test('Controller prefixes route to their owning service; internal prefixes stay if (!existsSync(resolve(root, app))) continue const service = read(app).match(/application:\s*\r?\n\s+name:\s*([\w-]+)/)?.[1] assert.ok(service, `Missing application name: ${module}`) - const tables = ['lastleg-service', 'warehouse-overseas-service'].includes(module) - ? [overseas] : ['file', 'print'].includes(module) ? [domestic, overseas] : [domestic] + const tables = [domestic] for (const file of javaFiles(resolve(root, module, 'src/main/java'))) { for (const path of controllerPaths(readFileSync(file, 'utf8'), file)) { // PDA release metadata lives in the domestic File control plane. Both diff --git a/scripts/http-script-environment-contract.test.mjs b/scripts/http-script-environment-contract.test.mjs index 2e534e6ad..8caabd465 100644 --- a/scripts/http-script-environment-contract.test.mjs +++ b/scripts/http-script-environment-contract.test.mjs @@ -38,12 +38,13 @@ test('active scripts no longer carry the retired variables, fixed Gateway, or so assert.doesNotMatch(pendingOrders, /PASSWORD:\s*'[^']+'/) }) -test('documentation names both external environment files and requires explicit selection', () => { +test('TEST documentation requires explicit selection and keeps development configuration in its own instructions', () => { for (const source of [readme, agents]) { - assert.match(source, /chongchong-global-dev-acceptance\.env/) assert.match(source, /chongchong-global-test-acceptance\.env/) assert.match(source, /--env-file/) } + assert.match(agents, /chongchong-global-dev-acceptance\.env/) assert.match(readme, /不会自行查找或加载上述文件/) assert.match(readme, /ADMIN_OVERSEAS_GATEWAY/) + assert.match(readme, /移除环境文件中的 `ADMIN_OVERSEAS_GATEWAY`/) }) diff --git a/scripts/inbound-plan-resource-fault-executor.mjs b/scripts/inbound-plan-resource-fault-executor.mjs new file mode 100644 index 000000000..3769e0f97 --- /dev/null +++ b/scripts/inbound-plan-resource-fault-executor.mjs @@ -0,0 +1,90 @@ +// Explicit one-container TEST runtime actions. Imports and default plan are offline. +import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +const need = (ok, message) => { if (!ok) throw Error(message); }; +const encode = value => Buffer.from(value).toString('base64'); +const source = name => readFileSync(new URL(name, import.meta.url)); +const runtime = source('./inbound-plan-resource-fault-runtime.py'); +const baseSource = encode(source('./order-submit-dependency-fault-runtime.py')); +const commonSource = encode(source('./test-event-fault-runtime.py')); +const proxySource = encode(source('./inbound-plan-resource-fault-proxy.py')); +const httpProxySource = encode(source('./order-submit-dependency-fault-proxy.py')); +export const actions = ['plan','preflight','prepare','activate','status','health','mode','records','restore','reconcile','verify-restored','finish']; +export const batchPrefixHash = booking => createHash('sha256').update(JSON.stringify(booking)).digest('hex'); +export function scopeFor(plan) { + need(plan?.environment === 'TEST' && plan.runtimeChangesExecuted === false, 'EXPLICIT_UNEXECUTED_TEST_PLAN_REQUIRED'); + need(/^[A-Za-z0-9_-]{1,80}$/.test(plan.marker || ''), 'INVALID_MARKER'); + for(const key of ['containerBookingId','warehouseId'])need(/^[1-9]\d{0,19}$/.test(plan[key] || ''), 'SINGLE_CONTAINER_WAREHOUSE_REQUIRED'); + need(plan.batchPrefixHash === batchPrefixHash(plan.containerBookingId), 'JSON_STRING_BATCH_HASH_REQUIRED'); + need(Object.keys(plan).every(key=>['environment','marker','containerBookingId','warehouseId','batchPrefixHash','runtimeChangesExecuted'].includes(key)), 'PLAN_SCHEMA_INVALID'); + return {environment:'TEST',marker:plan.marker,containerBookingId:plan.containerBookingId,warehouseId:plan.warehouseId, + batchPrefixHash:plan.batchPrefixHash,planSha256:createHash('sha256').update(JSON.stringify(plan)).digest('hex')}; +} +function routingFor(action, extra) { + if(!['preflight','prepare'].includes(action))return; + const r=extra.routing; + need(r?.configurationReviewed===true && Object.keys(r).every(k=>['configurationReviewed','sourceSha256','upstreams','nacos'].includes(k)) + && r.upstreams && Object.keys(r.upstreams).join(',')==='WAREHOUSE' && r.nacos + && Object.keys(r.nacos).sort().join(',')==='baseUrl,configPath,loginPath' + && r.nacos.loginPath==='/nacos/v3/auth/user/login' && r.nacos.configPath==='/nacos/v3/admin/cs/config','REVIEWED_ROUTING_REQUIRED'); + for(const value of [r.nacos.baseUrl,r.upstreams.WAREHOUSE]) { + const url=new URL(value);need(['http:','https:'].includes(url.protocol)&&!url.username&&!url.password&&!url.search&&!url.hash&&['','/'].includes(url.pathname),'ORIGIN_ONLY_NO_CREDENTIALS'); + } + if(action==='prepare')need(/^sha256:[a-f0-9]{64}$/.test(extra.expectedImageId||'')&&/^[a-f0-9]{64}$/.test(r.sourceSha256||''),'FROZEN_IMAGE_AND_NACOS_HASH_REQUIRED'); +} +export function renderInboundResourceAction(action, scope, agent, extra={}) { + need(actions.includes(action)&&action!=='plan','UNKNOWN_RUNTIME_ACTION'); + need(/^[A-Za-z0-9_.-]{1,128}$/.test(agent||''),'INVALID_AGENT'); + need(scope.environment==='TEST'&&/^[A-Za-z0-9_-]{1,80}$/.test(scope.marker||'')&&scope.batchPrefixHash===batchPrefixHash(scope.containerBookingId) + && /^[1-9]\d{0,19}$/.test(scope.containerBookingId||'')&&/^[1-9]\d{0,19}$/.test(scope.warehouseId||'')&&/^[a-f0-9]{64}$/.test(scope.planSha256||'') + && Object.keys(scope).sort().join(',')==='batchPrefixHash,containerBookingId,environment,marker,planSha256,warehouseId','INVALID_SCOPE'); + need(Object.keys(extra).every(k=>['routing','expectedImageId','mode','ttl'].includes(k)), 'RUNTIME_ARGUMENT_SCHEMA_INVALID'); + routingFor(action,extra); + if(action==='mode')need(['PASS','CONFIRM_ONCE'].includes(extra.mode)&&(extra.mode==='PASS'||Number.isInteger(extra.ttl)&&extra.ttl>=1&&extra.ttl<=60),'INVALID_MODE_OR_TTL'); + const config={...extra,action,scope,baseSource,commonSource,...(action==='prepare'?{proxySource,httpProxySource}:{})}; + const configJson=JSON.stringify(config); + need(Buffer.byteLength(configJson,'utf8')<=1024*1024,'RUNTIME_STDIN_CONFIG_TOO_LARGE'); + need(runtime.length<65536,'RUNTIME_CODE_ARG_TOO_LARGE'); + const literal=text=>'['+encode(text).match(/.{1,4000}/g).map(part=>`'${part}'`).join(',')+"].join('')"; + const safeResult=(code,stage)=>`print('INBOUND_RESOURCE_JSON='+groovy.json.JsonOutput.toJson([ok:false,error:'${code}',diagnostic:[stage:'${stage}',errorType:'IOException']]))`; + const groovy=`def p;try{p=new ProcessBuilder(['python3','-c',new String(java.util.Base64.decoder.decode(${literal(runtime)}),'UTF-8')]).redirectErrorStream(true).start()}catch(java.io.IOException e){${safeResult('REMOTE_PROCESS_START_FAILED','REMOTE_PROCESS_START')};return};try{p.outputStream.withCloseable{s->s.write(java.util.Base64.decoder.decode(${literal(configJson)}));s.flush()}}catch(java.io.IOException e){${safeResult('RUNTIME_STDIN_OUTCOME_UNKNOWN_USE_STATUS','REMOTE_PROCESS_STDIN')};return};def out=p.inputStream.getText('UTF-8');p.waitFor();def rows=out.readLines().findAll{it.startsWith('INBOUND_RESOURCE_JSON=')};if(rows.size()==1){print(rows[0])}else{print('INBOUND_RESOURCE_JSON='+groovy.json.JsonOutput.toJson([ok:false,error:'AMBIGUOUS_RESPONSE_NO_AUTO_RETRY']))}`; + return `def c=jenkins.model.Jenkins.instance.getComputer('${agent}');if(c==null || c.channel==null)throw new RuntimeException('TEST agent unavailable');print(hudson.util.RemotingDiagnostics.executeGroovy(new String(java.util.Base64.decoder.decode(${literal(groovy)}),'UTF-8'),c.channel))`; +} +export function createInboundResourceRuntime(env, scope, fetcher=globalThis.fetch) { + need(env.CHONGCHONG_ENV==='test','EXPLICIT_TEST_ENV_REQUIRED'); + need(env.JENKINS_URL&&env.JENKINS_USER&&env.JENKINS_API_TOKEN&&env.FBA_CORE_JENKINS_AGENT,'EXPLICIT_TEST_JENKINS_REQUIRED'); + return async(action,extra={})=>{ + const script=renderInboundResourceAction(action,scope,env.FBA_CORE_JENKINS_AGENT,extra); + let response; + try {response=await fetcher(new URL('scriptText',env.JENKINS_URL.replace(/\/?$/,'/')),{method:'POST',redirect:'error',signal:AbortSignal.timeout(55000), + headers:{Authorization:'Basic '+encode(env.JENKINS_USER+':'+env.JENKINS_API_TOKEN),'Content-Type':'application/x-www-form-urlencoded'},body:new URLSearchParams({script})});} + catch {throw Error('RUNTIME_'+action.toUpperCase().replaceAll('-','_')+'_OUTCOME_UNKNOWN_USE_STATUS');} + need(response.ok,'RUNTIME_HTTP_'+response.status+'_NO_AUTO_RETRY'); + const text=await response.text();need(!text.includes('MultipleCompilationErrorsException'),'RENDERER_COMPILATION_FAILED_NO_ACTION'); + const rows=text.split(/\r?\n/).filter(row=>row.startsWith('INBOUND_RESOURCE_JSON='));need(rows.length===1,'AMBIGUOUS_RESPONSE_NO_AUTO_RETRY'); + let result;try {result=JSON.parse(rows[0].slice('INBOUND_RESOURCE_JSON='.length));}catch{throw Error('INVALID_RESPONSE_NO_AUTO_RETRY');} + if(result?.ok!==true) { + const error=Error(/^[A-Z_0-9]{1,90}$/.test(result?.error||'')?result.error:'RUNTIME_ACTION_FAILED'); + const d=result?.diagnostic; + if(d&&/^[A-Z_0-9]{1,90}$/.test(d.stage||'')&&/^[A-Za-z_]{1,80}$/.test(d.errorType||'')) + error.runtimeDiagnostic={stage:d.stage,errorType:d.errorType,...(Number.isInteger(d.httpStatus)&&d.httpStatus>=100&&d.httpStatus<=599?{httpStatus:d.httpStatus}:{}), + ...(/^[A-Za-z_]{1,80}$/.test(d.transportErrorType||'')?{transportErrorType:d.transportErrorType}:{}), + ...(Number.isInteger(d.errno)&&d.errno>=-10000&&d.errno<=10000?{errno:d.errno}:{})}; + throw error; + } + return result.data; + }; +} +async function main(argv) { + const [action='plan',...rest]=argv,args={}; + for(let i=0;i{console.error(JSON.stringify({error:/^[A-Z_0-9]{1,90}$/.test(error.message)?error.message:'INBOUND_RESOURCE_ARGUMENT_ERROR',diagnostic:error.runtimeDiagnostic}));process.exitCode=1;}); diff --git a/scripts/inbound-plan-resource-fault-executor.test.mjs b/scripts/inbound-plan-resource-fault-executor.test.mjs new file mode 100644 index 000000000..327476387 --- /dev/null +++ b/scripts/inbound-plan-resource-fault-executor.test.mjs @@ -0,0 +1,93 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {mkdtempSync,writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {scopeFor,batchPrefixHash,renderInboundResourceAction,createInboundResourceRuntime,actions} from './inbound-plan-resource-fault-executor.mjs'; +const plan={environment:'TEST',marker:'F27-B-TEST',containerBookingId:'2106714159440728066',warehouseId:'193000000000200003',runtimeChangesExecuted:false}; +plan.batchPrefixHash=batchPrefixHash(plan.containerBookingId); +const scope=scopeFor(plan); +const env={CHONGCHONG_ENV:'test',JENKINS_URL:'https://offline.invalid/',JENKINS_USER:'offline',JENKINS_API_TOKEN:'secret-do-not-render',FBA_CORE_JENKINS_AGENT:'test-agent'}; +const routing={configurationReviewed:true,nacos:{baseUrl:'http://offline.invalid:8848',loginPath:'/nacos/v3/auth/user/login',configPath:'/nacos/v3/admin/cs/config'},upstreams:{WAREHOUSE:'http://offline.invalid:9305'},sourceSha256:'a'.repeat(64)}; +test('coordinator hashes the JSON string including quotes, never raw booking bytes',()=>{ + assert.equal(plan.batchPrefixHash,'4c9034968e117813e549a12f5e63f1c8366a7f2dbb6722a3ff4967b983a5f9e7'); +}); +test('scope rejects non-TEST, executed, multiple-container, unknown and bad hash plans',()=>{ + for(const change of [p=>p.environment='PROD',p=>p.runtimeChangesExecuted=true,p=>p.containerBookingId=['1','2'],p=>p.extra='secret',p=>p.batchPrefixHash='b'.repeat(64)]){ + const value=structuredClone(plan);change(value);assert.throws(()=>scopeFor(value)); + } +}); +const arrays=text=>Array.from(text.matchAll(/\[('[A-Za-z0-9+/=]+'(?:,'[A-Za-z0-9+/=]+')*)\]\.join\(''\)/g),m=>Array.from(m[1].matchAll(/'([^']+)'/g),p=>p[1])); +test('renderer chunks complete payload and carries both proxy modules and reviewed primitives',()=>{ + const rendered=renderInboundResourceAction('prepare',scope,'test-agent',{routing,expectedImageId:'sha256:'+'c'.repeat(64)}); + assert.ok(!rendered.includes(env.JENKINS_API_TOKEN)); + const groovy=Buffer.from(arrays(rendered)[0].join(''),'base64').toString();const embedded=arrays(groovy); + assert.ok(embedded.flat().every(s=>s.length<=4000)); + const config=JSON.parse(Buffer.from(embedded[1].join(''),'base64').toString()); + assert.deepEqual(config.scope,scope);assert.equal(config.action,'prepare'); + for(const key of ['proxySource','httpProxySource','commonSource','baseSource'])assert.ok(config[key].length>100); + const runtime=Buffer.from(embedded[0].join(''),'base64').toString();assert.ok(runtime.includes('firstleg-test-lastleg')); + assert.match(groovy,/new ProcessBuilder\(\['python3','-c',new String\(.+?,'UTF-8'\)\]\)/s); + assert.match(groovy,/p\.outputStream\.withCloseable\{s->s\.write\(java\.util\.Base64\.decoder\.decode\(/); + assert.ok(Buffer.byteLength(runtime)<65536); + assert.ok(embedded[1].join('').length>131072,'prepare reproduces the old over-limit configuration argv'); + assert.ok(runtime.includes('main(read_stdin_config())')); + assert.ok(!runtime.includes('base64.b64decode(sys.argv[1])')); +}); +test('complete prepare configuration travels over stdin to actual Python with bounded argv and no config echo',()=>{ + const rendered=renderInboundResourceAction('prepare',scope,'test-agent',{routing,expectedImageId:'sha256:'+'c'.repeat(64)}); + const groovy=Buffer.from(arrays(rendered)[0].join(''),'base64').toString();const embedded=arrays(groovy); + const runtime=Buffer.from(embedded[0].join(''),'base64').toString(); + const config=JSON.parse(Buffer.from(embedded[1].join(''),'base64').toString()); + // Reject scope before locks/Docker/network. JSON still must consume the complete four-source body. + config.scope.environment='PROD';const input=JSON.stringify(config); + const argv=['-c',runtime];assert.equal(argv.length,2);assert.ok(argv.every(a=>Buffer.byteLength(a)<65536)); + assert.ok(Buffer.byteLength(input)>100000);for(const key of ['baseSource','commonSource','proxySource','httpProxySource'])assert.ok(config[key].length>100); + const result=spawnSync(process.env.PYTHON||'python',argv,{input,encoding:'utf8',env:{...process.env,PYTHONDONTWRITEBYTECODE:'1'}}); + assert.equal(result.status,0,result.stderr||result.error?.message);assert.equal(result.stderr,''); + const rows=result.stdout.trim().split(/\r?\n/);assert.equal(rows.length,1);assert.ok(rows[0].startsWith('INBOUND_RESOURCE_JSON=')); + const value=JSON.parse(rows[0].slice('INBOUND_RESOURCE_JSON='.length));assert.equal(value.error,'EXPLICIT_TEST_SCOPE_REQUIRED'); + assert.equal(value.diagnostic.stage,'READ_STDIN_CONFIG');assert.ok(!result.stdout.includes(config.baseSource.slice(0,80)));assert.ok(!result.stdout.includes('upstreams')); +}); +test('origin/schema guards reject credential embedding, wrong imports and unbounded fault windows',()=>{ + for(const change of [r=>r.password='secret',r=>r.upstreams.IAM='http://offline.invalid',r=>r.nacos.baseUrl='http://user:secret@offline.invalid',r=>r.nacos.configPath='/fallback']){ + const r=structuredClone(routing);change(r);assert.throws(()=>renderInboundResourceAction('prepare',scope,'test-agent',{routing:r,expectedImageId:'sha256:'+'c'.repeat(64)})); + } + for(const ttl of [0,61,1.5])assert.throws(()=>renderInboundResourceAction('mode',scope,'agent',{mode:'CONFIRM_ONCE',ttl})); + assert.throws(()=>renderInboundResourceAction('mode',scope,'agent',{mode:'FAIL_ALL',ttl:1})); + assert.throws(()=>renderInboundResourceAction('status',{...scope,warehouseId:'0'},'agent')); + assert.throws(()=>renderInboundResourceAction('business-convert',scope,'agent')); +}); +test('transport unknown, duplicate result and malformed result each fail after exactly one call',async()=>{ + for(const fetcher of [async()=>{throw Error('lost secret')},async()=>({ok:true,text:async()=> 'INBOUND_RESOURCE_JSON={}\nINBOUND_RESOURCE_JSON={}'}),async()=>({ok:true,text:async()=> 'INBOUND_RESOURCE_JSON={'})]){ + let calls=0;const runtime=createInboundResourceRuntime(env,scope,async(...args)=>{calls++;return fetcher(...args)}); + await assert.rejects(runtime('activate'),/UNKNOWN|AMBIGUOUS|INVALID_RESPONSE/);assert.equal(calls,1); + } + assert.throws(()=>createInboundResourceRuntime({...env,CHONGCHONG_ENV:'prod'},scope)); +}); +test('structured remote process-start/stdin errors remain single results and never echo private payloads',async()=>{ + for(const [code,stage] of [['REMOTE_PROCESS_START_FAILED','REMOTE_PROCESS_START'],['RUNTIME_STDIN_OUTCOME_UNKNOWN_USE_STATUS','REMOTE_PROCESS_STDIN']]){ + let calls=0;const runtime=createInboundResourceRuntime(env,scope,async()=>{calls++;return{ok:true,text:async()=> 'INBOUND_RESOURCE_JSON='+JSON.stringify({ok:false,error:code,diagnostic:{stage,errorType:'IOException'}})}}); + await assert.rejects(runtime('prepare',{routing,expectedImageId:'sha256:'+'c'.repeat(64)}),error=>{assert.equal(error.message,code);assert.deepEqual(error.runtimeDiagnostic,{stage,errorType:'IOException'});return true;});assert.equal(calls,1); + } +}); +test('remote error preserves safe stage/type/status and strips untrusted diagnostic fields',async()=>{ + const runtime=createInboundResourceRuntime(env,scope,async()=>({ok:true,text:async()=> 'INBOUND_RESOURCE_JSON='+JSON.stringify({ok:false,error:'NACOS_CONFIG_READ_HTTP_404',diagnostic:{stage:'NACOS_CONFIG_READ',errorType:'HTTPError',httpStatus:404,token:'must-never-escape',body:'secret',url:'http://private.invalid'}})})); + await assert.rejects(runtime('preflight',{routing}),error=>{ + assert.equal(error.message,'NACOS_CONFIG_READ_HTTP_404');assert.deepEqual(error.runtimeDiagnostic,{stage:'NACOS_CONFIG_READ',errorType:'HTTPError',httpStatus:404});return true; + }); +}); +test('DNS diagnostics preserve only bounded errno and transport class, no private resolver message',async()=>{ + const runtime=createInboundResourceRuntime(env,scope,async()=>({ok:true,text:async()=> 'INBOUND_RESOURCE_JSON='+JSON.stringify({ok:false,error:'NACOS_SOURCE_READ_TRANSPORT_FAILED',diagnostic:{stage:'NACOS_SOURCE_READ',errorType:'URLError',transportErrorType:'gaierror',errno:-2,message:'private resolver secret'}})})); + await assert.rejects(runtime('preflight',{routing}),error=>{ + assert.deepEqual(error.runtimeDiagnostic,{stage:'NACOS_SOURCE_READ',errorType:'URLError',transportErrorType:'gaierror',errno:-2});return true; + }); +}); +test('default plan CLI has zero HTTP and exposes no business conversion action',()=>{ + const dir=mkdtempSync(join(tmpdir(),'inbound-fault-plan-'));const file=join(dir,'plan.json');writeFileSync(file,JSON.stringify(plan)); + const run=spawnSync(process.execPath,[fileURLToPath(new URL('./inbound-plan-resource-fault-executor.mjs',import.meta.url)),'plan','--plan',file],{encoding:'utf8',env:{...process.env,CHONGCHONG_ENV:'prod',JENKINS_URL:'http://127.0.0.1:1'}}); + assert.equal(run.status,0,run.stderr);const output=JSON.parse(run.stdout);assert.equal(output.networkCalls,0);assert.equal(output.businessWritesSupported,false); + assert.equal(output.target,'firstleg-test-lastleg');assert.equal(output.property,'baiyue.warehouse.base-url');assert.deepEqual(output.actions,actions); +}); diff --git a/scripts/inbound-plan-resource-fault-proxy.py b/scripts/inbound-plan-resource-fault-proxy.py new file mode 100644 index 000000000..90c731020 --- /dev/null +++ b/scripts/inbound-plan-resource-fault-proxy.py @@ -0,0 +1,113 @@ +"""TEST-only one-shot resource-confirm fault for one frozen container batch. + +No Docker, SQL or business mutation is performed by this module. A separately +reviewed runtime must install/restore routing and own the private configuration. +The shared HTTP server/transport provides bounded bodies and a loopback control +listener; only this Router decides which request may receive the synthetic 503. +""" +import argparse +import hashlib +import hmac +import importlib.util +import json +import re +import threading +import time +from pathlib import Path + +_spec = importlib.util.spec_from_file_location( + 'dependency_proxy_http', Path(__file__).with_name('order-submit-dependency-fault-proxy.py')) +_http = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_http) + +CONFIRM_PATH = '/internal/v1/warehouse-pallet-batches/confirm' + + +class Router: + def __init__(self, config, forward=_http.transport, clock=time.monotonic, record=None): + self.config, self.forward, self.clock = config, forward, clock + self.record = record or (lambda row: None) + self.lock = threading.Lock() + self.mode, self.deadline, self.generation = 'PASS', 0, 0 + self.counts = {'forwarded': 0, 'faulted': 0} + if config.get('environment') != 'TEST': + raise ValueError('EXPLICIT_TEST_REQUIRED') + for key in ('containerBookingId', 'warehouseId'): + if not re.fullmatch(r'[1-9][0-9]{0,19}', str(config.get(key, ''))): + raise ValueError('EXACT_CONTAINER_AND_WAREHOUSE_REQUIRED') + if not config.get('lastlegIps') or not config.get('internalToken') or not config.get('controlToken'): + raise ValueError('AUTHENTICATED_CALLER_SCOPE_REQUIRED') + if not re.fullmatch(r'[a-f0-9]{64}', config.get('batchPrefixHash', '')): + raise ValueError('REVIEWED_BATCH_PREFIX_REQUIRED') + self.prefix = 'CIP:' + config['batchPrefixHash'] + ':' + # Coordinator hashes ObjectMapper.writeValueAsString(String bookingId), including JSON quotes. + expected = hashlib.sha256(json.dumps(str(config['containerBookingId']), separators=(',', ':')).encode()).hexdigest() + if expected != config['batchPrefixHash']: + raise ValueError('CONTAINER_BATCH_PREFIX_MISMATCH') + + def status(self): + with self.lock: + self._expire() + return {'mode': self.mode, 'generation': self.generation, + 'remainingSeconds': max(0, self.deadline - self.clock()), **self.counts} + + def _expire(self): + if self.mode != 'PASS' and self.clock() >= self.deadline: + self.mode, self.deadline = 'PASS', 0 + + def set_mode(self, token, mode, ttl=0): + if not hmac.compare_digest(str(token), self.config['controlToken']): + raise PermissionError('CONTROL_AUTH_REQUIRED') + with self.lock: + self.mode, self.deadline = 'PASS', 0 + if mode not in ('PASS', 'CONFIRM_ONCE') or (mode != 'PASS' and + (type(ttl) is not int or not 1 <= ttl <= 60)): + raise ValueError('INVALID_MODE_OR_TTL') + self.mode = mode + self.deadline = self.clock() + ttl if mode != 'PASS' else 0 + self.generation += 1 + return self.status() + + def dispatch(self, method, path, headers, body, peer): + normalized = {k.lower(): v for k, v in headers.items()} + token = normalized.get('x-internal-service-token', '') + authenticated = bool(token) and hmac.compare_digest(token, self.config['internalToken']) + payload = None + if method == 'POST' and path == CONFIRM_PATH: + try: + payload = json.loads(body) + except (ValueError, UnicodeDecodeError): + pass + selected = (isinstance(payload, dict) and authenticated and peer in self.config['lastlegIps'] + and str(payload.get('warehouseId')) == str(self.config['warehouseId']) + and isinstance(payload.get('batchKey'), str) + and re.fullmatch(re.escape(self.prefix) + r'[A-Za-z0-9_-]{1,80}', payload['batchKey']) is not None + and isinstance(payload.get('commandKey'), str) and bool(payload['commandKey'])) + with self.lock: + self._expire() + fault = selected and self.mode == 'CONFIRM_ONCE' + generation = self.generation + if fault: + self.mode, self.deadline = 'PASS', 0 + self.counts['faulted'] += 1 + if fault: + result = (503, {'Content-Type': 'text/plain'}, b'TEST selected resource confirmation unavailable') + else: + result = self.forward(self.config['upstream'], method, path, headers, body) + with self.lock: + self.counts['forwarded'] += 1 + if selected: + self.record({'method': method, 'path': path, 'matched': fault, 'forwarded': not fault, + 'responseStatus': result[0], 'generation': generation, + 'bodySha256': hashlib.sha256(body).hexdigest(), + 'containerBookingId': self.config['containerBookingId'], + 'warehouseId': self.config['warehouseId'], 'internalTokenValidated': True}) + return result + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--config', required=True) + # Reuse only the existing transport/server, not its Order routing policy. + _http.Router = Router + _http.main(parser.parse_args().config) diff --git a/scripts/inbound-plan-resource-fault-proxy.test.mjs b/scripts/inbound-plan-resource-fault-proxy.test.mjs new file mode 100644 index 000000000..0cdc20804 --- /dev/null +++ b/scripts/inbound-plan-resource-fault-proxy.test.mjs @@ -0,0 +1,65 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +test('one container resource-confirm fault is scoped, one-shot, expiring and transparent (offline)', () => { + const source=String.raw` +import hashlib,importlib.util,json,sys,unittest,concurrent.futures +spec=importlib.util.spec_from_file_location('fault',sys.argv[1]);p=importlib.util.module_from_spec(spec);spec.loader.exec_module(p) +class Tests(unittest.TestCase): + def setUp(self): + self.now=[10.0];self.calls=[];self.logs=[] + self.config={'environment':'TEST','containerBookingId':'7','warehouseId':'30','batchPrefixHash':hashlib.sha256(b'"7"').hexdigest(), + 'controlToken':'private-control','internalToken':'private-token','lastlegIps':['172.20.0.10'],'upstream':'http://warehouse.invalid:9305'} + def forward(*args):self.calls.append(args);return 200,{'X-Upstream':'original'},b'original-response' + self.router=p.Router(self.config,forward=forward,clock=lambda:self.now[0],record=self.logs.append) + self.body={'warehouseId':30,'batchKey':self.router.prefix+'generation-1','commandKey':'original-command'} + self.headers={'X-Internal-Service-Token':'private-token'} + def send(self,body=None,path=p.CONFIRM_PATH,method='POST',headers=None,peer='172.20.0.10'): + raw=json.dumps(self.body if body is None else body).encode() + return self.router.dispatch(method,path,self.headers if headers is None else headers,raw,peer) + def test_default_pass_and_original_payload_preserved(self): + self.assertEqual(self.send(),(200,{'X-Upstream':'original'},b'original-response')) + self.assertEqual(json.loads(self.calls[0][4]),self.body) + self.assertEqual(self.calls[0][3],self.headers) + def test_only_exact_confirm_body_caller_and_identity_can_consume_fault(self): + self.router.set_mode('private-control','CONFIRM_ONCE',60) + for kwargs in [dict(path=p.CONFIRM_PATH+'?other=1'),dict(path='/internal/v1/warehouse-pallet-batches/ensure-reserved'), + dict(method='GET'),dict(body={**self.body,'warehouseId':31}),dict(body={**self.body,'batchKey':'another-container'}), + dict(body=[]),dict(body={**self.body,'commandKey':''}),dict(peer='172.20.0.11'),dict(headers={}), + dict(headers={'X-Internal-Service-Token':'wrong'})]:self.assertEqual(self.send(**kwargs)[0],200) + self.assertEqual(self.router.status()['mode'],'CONFIRM_ONCE') + self.assertEqual(self.send()[0],503);self.assertEqual(self.send()[0],200) + self.assertEqual(self.router.status()['faulted'],1) + def test_one_shot_is_atomic_under_concurrent_requests(self): + self.router.set_mode('private-control','CONFIRM_ONCE',60) + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as e:codes=list(e.map(lambda _:self.send()[0],range(8))) + self.assertEqual(codes.count(503),1);self.assertEqual(codes.count(200),7) + def test_ttl_expiry_and_malformed_control_disarm(self): + self.router.set_mode('private-control','CONFIRM_ONCE',2);self.now[0]=12 + self.assertEqual(self.send()[0],200) + for mode,ttl in [('OTHER',1),('CONFIRM_ONCE',0),('CONFIRM_ONCE',61),('CONFIRM_ONCE',True)]: + with self.assertRaises(ValueError):self.router.set_mode('private-control',mode,ttl) + self.assertEqual(self.router.status()['mode'],'PASS') + with self.assertRaises(PermissionError):self.router.set_mode('wrong','CONFIRM_ONCE',1) + def test_production_or_mismatched_container_scope_rejected(self): + for override in [{'environment':'PROD'},{'containerBookingId':'8'},{'warehouseId':'0'},{'lastlegIps':[]}]: + with self.assertRaises(ValueError):p.Router({**self.config,**override}) + def test_malformed_json_is_forwarded_unchanged_and_never_consumes_fault(self): + self.router.set_mode('private-control','CONFIRM_ONCE',60) + raw=b'invalid-json' + self.assertEqual(self.router.dispatch('POST',p.CONFIRM_PATH,self.headers,raw,'172.20.0.10')[0],200) + self.assertEqual(self.calls[0][4],raw);self.assertEqual(self.router.status()['mode'],'CONFIRM_ONCE') + def test_evidence_redacts_credentials_and_raw_body(self): + self.router.set_mode('private-control','CONFIRM_ONCE',60);self.send() + evidence=json.dumps(self.logs) + for secret in ['private-control','private-token','original-command','generation-1']:self.assertNotIn(secret,evidence) + self.assertTrue(self.logs[0]['internalTokenValidated']);self.assertFalse(self.logs[0]['forwarded']) +result=unittest.TextTestRunner().run(unittest.defaultTestLoader.loadTestsFromTestCase(Tests)) +sys.exit(0 if result.wasSuccessful() else 1) +` + const r=spawnSync(process.env.PYTHON||'python',['-c',source,fileURLToPath(new URL('./inbound-plan-resource-fault-proxy.py',import.meta.url))], + {encoding:'utf8',env:{...process.env,PYTHONDONTWRITEBYTECODE:'1'}}) + assert.equal(r.status,0,r.stderr||r.error?.message) +}) diff --git a/scripts/inbound-plan-resource-fault-runtime.py b/scripts/inbound-plan-resource-fault-runtime.py new file mode 100644 index 000000000..d20a4625f --- /dev/null +++ b/scripts/inbound-plan-resource-fault-runtime.py @@ -0,0 +1,283 @@ +"""Explicit TEST/single-container runtime. Import performs no network or mutation. + +Reuses the reviewed Order Docker journal/reconstruction/reconciliation methods; +only Lastleg's baiyue.warehouse.base-url is temporarily changed. No business/SQL. +""" +import ast +import base64 +import copy +import hashlib +import json +import os +import re +import secrets +import signal +import socket +import subprocess +import sys +import time +import urllib.parse +import urllib.request +import urllib.error +from pathlib import Path + +TARGET = 'firstleg-test-lastleg' +UPSTREAM = 'firstleg-test-warehouse-overseas' +PROPERTY = 'baiyue.warehouse.base-url' +STAGE = 'INITIALIZE' + + +def safe_diagnostic(error): + """Never return exception messages, URLs, bodies, environment or identities.""" + stage, status = STAGE, None + if isinstance(error, urllib.error.HTTPError): + endpoint = urllib.parse.urlsplit(error.geturl()).path + stage = {'/nacos/v3/auth/user/login': 'NACOS_LOGIN', + '/nacos/v3/admin/cs/config': 'NACOS_CONFIG_READ'}.get(endpoint, stage) + status = error.code if type(error.code) is int and 100 <= error.code <= 599 else None + kind = type(error).__name__ + diagnostic = {'stage': stage, 'errorType': kind if re.fullmatch(r'[A-Za-z_]{1,80}', kind) else 'Exception'} + if isinstance(error, urllib.error.URLError) and not isinstance(error, urllib.error.HTTPError): + reason = error.reason + reason_type = type(reason).__name__ + if re.fullmatch(r'[A-Za-z_]{1,80}', reason_type): diagnostic['transportErrorType'] = reason_type + number = getattr(reason, 'errno', None) + if type(number) is int and -10000 <= number <= 10000: diagnostic['errno'] = number + if type(status) is int and 100 <= status <= 599: diagnostic['httpStatus'] = status + message = str(error) + code = message if re.fullmatch(r'[A-Z_0-9]{1,90}', message) else 'INBOUND_RESOURCE_ACTION_FAILED' + if status is not None: code = stage + '_HTTP_' + str(status) + elif isinstance(error, urllib.error.URLError): code = stage + '_TRANSPORT_FAILED' + return {'ok': False, 'error': code, 'diagnostic': diagnostic} + + +def validate_scope(scope): + if set(scope) != {'environment', 'marker', 'containerBookingId', 'warehouseId', 'batchPrefixHash', 'planSha256'}: + raise RuntimeError('INVALID_SCOPE') + if scope['environment'] != 'TEST' or not re.fullmatch(r'[A-Za-z0-9_-]{1,80}', scope['marker']): + raise RuntimeError('EXPLICIT_TEST_SCOPE_REQUIRED') + if any(not re.fullmatch(r'[1-9][0-9]{0,19}', scope[key]) for key in ('containerBookingId', 'warehouseId')): + raise RuntimeError('SINGLE_CONTAINER_WAREHOUSE_REQUIRED') + expected = hashlib.sha256(json.dumps(scope['containerBookingId'], separators=(',', ':')).encode()).hexdigest() + if scope['batchPrefixHash'] != expected or not re.fullmatch(r'[a-f0-9]{64}', scope['planSha256']): + raise RuntimeError('BATCH_OR_PLAN_HASH_MISMATCH') + + +def runtime_class(base_source): + # No __main__ executes. Only the known config import's dataId is adapted. + tree = ast.parse(base_source) + class Imports(ast.NodeTransformer): + def visit_Constant(self, node): + if node.value == 'order-service.yml': return ast.copy_location(ast.Constant('lastleg-service.yml'), node) + return node + tree = ast.fix_missing_locations(Imports().visit(tree)) + namespace = {'__name__': 'reviewed_resource_docker_runtime'} + exec(compile(tree, '', 'exec'), namespace) + namespace.update(ORDER=TARGET, PROPERTIES={'WAREHOUSE': PROPERTY}, validate_scope=validate_scope) + Parent = namespace['Runtime'] + + class Runtime(Parent): + def __init__(self, scope, tools, base=Path('/tmp')): + validate_scope(scope) + self.scope, self.tools = scope, tools + self.directory = base / ('chongchong-inbound-resource-' + scope['marker']) + self.path = self.directory / 'state.json' + self.lock = base / 'chongchong-inbound-resource.lock' + + def probes(self, state): + target = self.verified_upstream(self.container(), state['routing'], state['internalToken']) + if (target['Id'] != state['upstreamId'] or target['Image'] != state['upstreamImageId'] + or self.tools['fingerprint'](target) != state['upstreamFingerprint']): + raise RuntimeError('WAREHOUSE_UPSTREAM_CONFIGURATION_CHANGED') + request = urllib.request.Request(state['upstreams']['WAREHOUSE'] + '/actuator/health', + headers={'X-Internal-Service-Token': state['internalToken']}) + with namespace['http_open'](request, timeout=8) as response: + value = json.load(response) + if response.status != 200 or value.get('status') != 'UP': + raise RuntimeError('WAREHOUSE_UPSTREAM_HEALTH_FAILED') + return {'WAREHOUSE': True} + + def verified_upstream(self, original, routing, token): + target = self.tools['inspect'](UPSTREAM); self.tools['require_test'](target, UPSTREAM) + target_env = self.tools['env_of'](target) + address = urllib.parse.urlsplit(routing['upstreams']['WAREHOUSE']) + ips = {n['IPAddress'] for name, n in target['NetworkSettings']['Networks'].items() + if name in original['NetworkSettings']['Networks']} + if (address.scheme != 'http' or address.hostname not in ips or str(address.port) != target_env.get('SERVER_PORT') + or address.username or address.password or address.path not in ('', '/') or address.query or address.fragment + or target_env.get('DEPLOY_ENV') != 'test' or target['State'].get('Health', {}).get('Status') != 'healthy' + or len(token) < 16 or target_env.get('INTERNAL_SERVICE_TOKEN') != token): + raise RuntimeError('UPSTREAM_NOT_VERIFIED_TEST_CONTAINER') + return target + + def prepare(self, config): + if self.directory.exists(): raise RuntimeError('STATE_EXISTS_USE_STATUS_OR_RESTORE') + original = self.container() + if original['Image'] != config.get('expectedImageId') or not re.fullmatch(r'sha256:[a-f0-9]{64}', original['Image']): + raise RuntimeError('ORIGINAL_IMAGE_MISMATCH') + if original['State'].get('Health', {}).get('Status') != 'healthy': raise RuntimeError('LASTLEG_NOT_HEALTHY') + if original['Config']['Entrypoint'] != namespace['ENTRYPOINT'] or original['Config'].get('Cmd') not in (None, []): + raise RuntimeError('UNREVIEWED_JAVA_ENTRYPOINT') + if 'host.docker.internal:host-gateway' not in (original['HostConfig'].get('ExtraHosts') or []): + raise RuntimeError('REVIEWED_PROXY_HOST_ROUTE_REQUIRED') + env = self.tools['env_of'](original) + token = env.get('INTERNAL_SERVICE_TOKEN', '') + if len(token) < 16: raise RuntimeError('INTERNAL_TOKEN_UNAVAILABLE') + routing = config['routing'] + if routing.get('configurationReviewed') is not True or set(routing['upstreams']) != {'WAREHOUSE'}: + raise RuntimeError('REVIEWED_WAREHOUSE_ROUTING_REQUIRED') + source = self.config_fingerprint(original, routing) + if source != routing.get('sourceSha256'): raise RuntimeError('REVIEWED_NACOS_SOURCE_DIGEST_CHANGED') + target = self.verified_upstream(original, routing, token) + fd = os.open(self.lock, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, 'w') as stream: stream.write(self.scope['marker']); stream.flush(); os.fsync(stream.fileno()) + self.directory.mkdir(mode=0o700) + sockets = [socket.socket(), socket.socket()] + for sock in sockets: sock.bind(('127.0.0.1', 0)) + proxy_port, control_port = [sock.getsockname()[1] for sock in sockets] + for sock in sockets: sock.close() + state = {'scope': self.scope, 'phase': 'PREPARED', 'originalId': original['Id'], 'originalImageId': original['Image'], + 'originalFingerprint': self.tools['fingerprint'](original), 'originalBody': self.tools['recreation'](original, env, full=True), + 'internalToken': token, 'controlToken': secrets.token_urlsafe(32), 'proxyPort': proxy_port, 'controlPort': control_port, + 'upstreams': {'WAREHOUSE': routing['upstreams']['WAREHOUSE'].rstrip('/')}, 'routing': routing, + 'routingSourceSha256': source, 'lastlegIps': [n['IPAddress'] for n in original['NetworkSettings']['Networks'].values()], + 'upstreamId': target['Id'], 'upstreamImageId': target['Image'], 'upstreamFingerprint': self.tools['fingerprint'](target), + 'mutation': None, 'proxyLaunchIntent': False} + self.save(state) + self.probes(state) + for name, field in [('proxy.py', 'proxySource'), ('order-submit-dependency-fault-proxy.py', 'httpProxySource')]: + file = self.directory / name; file.write_bytes(base64.b64decode(config[field])); os.chmod(file, 0o600) + namespace['private_write'](self.directory / 'proxy-config.json', { + key: state[key] for key in ('internalToken', 'controlToken', 'proxyPort', 'controlPort', 'lastlegIps')} | { + 'environment': 'TEST', 'containerBookingId': self.scope['containerBookingId'], 'warehouseId': self.scope['warehouseId'], + 'batchPrefixHash': self.scope['batchPrefixHash'], 'upstream': state['upstreams']['WAREHOUSE']}) + state['phase'] = 'PROXY_START_INTENT'; state['proxyLaunchIntent'] = True; self.save(state) + with open(self.directory / 'proxy-output.log', 'ab') as output: + os.chmod(self.directory / 'proxy-output.log', 0o600) + process = subprocess.Popen(['python3', str(self.directory / 'proxy.py'), '--config', str(self.directory / 'proxy-config.json')], + stdout=output, stderr=output, start_new_session=True) + state['proxyPid'] = process.pid; state['phase'] = 'PROXY_STARTED'; self.save(state) + return self.summary(state) + + def injected_entrypoint(self, state): + value = copy.deepcopy(state['originalBody']['Entrypoint']) + value[2] += ' --' + PROPERTY + '=http://host.docker.internal:' + str(state['proxyPort']) + return value + + def matches_injected(self, current, state): + view = copy.deepcopy(current); view['Config']['Entrypoint'] = state['originalBody']['Entrypoint'] + return (current['Image'] == state['originalImageId'] and current['Config']['Entrypoint'] == self.injected_entrypoint(state) + and self.tools['fingerprint'](view) == state['originalFingerprint']) + + def activate(self, state): + if state['phase'] != 'PROXY_STARTED' or state.get('uncertain'): raise RuntimeError('ACTIVATE_NOT_RETRYABLE') + if self.control(state)['mode'] != 'PASS': raise RuntimeError('PROXY_NOT_PASS') + current = self.container() + if current['Id'] != state['originalId'] or self.tools['fingerprint'](current) != state['originalFingerprint']: + raise RuntimeError('ORIGINAL_CONFIGURATION_CHANGED') + desired = copy.deepcopy(state['originalBody']); desired['Entrypoint'] = self.injected_entrypoint(state) + self.replace(state, current, desired, 'INJECTED') + state['injectedFingerprint'] = self.tools['fingerprint'](self.container()); self.save(state) + return self.summary(state) + + def finish(self, state, _business=None): + self.verify_restored(state) # Includes healthy, full config/mount/network/image + Nacos + real upstream. + if self.lock.is_symlink() or self.lock.read_text() != self.scope['marker']: raise RuntimeError('RUNTIME_LOCK_OWNER_CHANGED') + pid = state.get('proxyPid') + if not pid and state.get('proxyLaunchIntent'): + file = self.directory / 'proxy.pid' + if file.is_symlink() or not file.exists() or not file.read_text().isdigit(): raise RuntimeError('PROXY_PID_UNCERTAIN') + pid = int(file.read_text()) + command = Path('/proc/' + str(pid) + '/cmdline') if pid else None + if command and command.exists(): + if str(self.directory / 'proxy.py').encode() not in command.read_bytes().split(b'\0'): + raise RuntimeError('PROXY_PROCESS_OWNERSHIP_MISMATCH') + os.kill(pid, signal.SIGTERM) + for _ in range(40): + if not command.exists() or not command.read_bytes(): break + time.sleep(.1) + else: raise RuntimeError('PROXY_NOT_STOPPED') + state['restored'] = True; state['phase'] = 'FINISHED' + for field in ('originalBody', 'desiredBody', 'internalToken', 'controlToken'): state.pop(field, None) + self.save(state) + file = self.directory / 'proxy-config.json' + if file.exists(): file.unlink() + self.lock.unlink() + return {'restored': True, 'healthy': True, 'proxyStopped': True, 'lastlegRoutesVerified': True, + 'originalImageId': state['originalImageId'], 'originalFingerprint': state['originalFingerprint']} + return Runtime, namespace + + +def execute(config): + global STAGE + STAGE = 'LOAD_REVIEWED_PRIMITIVES' + Runtime, namespace = runtime_class(base64.b64decode(config['baseSource']).decode()) + runtime = Runtime(config['scope'], namespace['common_tools'](base64.b64decode(config['commonSource']).decode())) + action = config['action'] + if action == 'preflight': + STAGE = 'LASTLEG_DOCKER_READ' + current = runtime.container() + STAGE = 'WAREHOUSE_UPSTREAM_IDENTITY' + target = runtime.verified_upstream(current, config['routing'], runtime.tools['env_of'](current).get('INTERNAL_SERVICE_TOKEN', '')) + STAGE = 'LASTLEG_FULL_FINGERPRINT' + original_fingerprint = runtime.tools['fingerprint'](current) + STAGE = 'NACOS_SOURCE_READ' + source_hash = runtime.config_fingerprint(current, config['routing']) + STAGE = 'WAREHOUSE_FULL_FINGERPRINT' + upstream_fingerprint = runtime.tools['fingerprint'](target) + return {'originalImageId': current['Image'], 'originalFingerprint': original_fingerprint, + 'sourceSha256': source_hash, 'runtimeChanged': False, + 'upstreamImageId': target['Image'], 'upstreamFingerprint': upstream_fingerprint} + if action == 'prepare': return runtime.prepare(config) + STAGE = 'RUNTIME_STATE_READ' + state = runtime.load() + if action == 'status': + result = runtime.summary(state) + if not state.get('restored'): + try: result['proxy'] = runtime.control(state) + except Exception: result['proxy'] = {'unavailable': True} + return result + if state.get('restored'): raise RuntimeError('ALREADY_FINISHED_USE_STATUS') + if runtime.lock.is_symlink() or runtime.lock.read_text() != runtime.scope['marker']: raise RuntimeError('RUNTIME_LOCK_OWNER_CHANGED') + if action == 'records': + file = runtime.directory / 'proxy-evidence.jsonl' + return {'records': [json.loads(line) for line in file.read_text().splitlines()] if file.exists() else []} + if action == 'mode': + if state['phase'] != 'INJECTED' or state.get('uncertain'): raise RuntimeError('FAULT_WINDOW_NOT_READY') + mode, ttl = config.get('mode'), config.get('ttl', 0) + if mode not in ('PASS', 'CONFIRM_ONCE') or (mode != 'PASS' and (type(ttl) is not int or not 1 <= ttl <= 60)): + raise RuntimeError('INVALID_MODE_OR_TTL') + return runtime.control(state, mode, ttl) + if action == 'health': return {'healthy': runtime.container()['State'].get('Health', {}).get('Status') == 'healthy'} + actions = {'activate': runtime.activate, 'restore': runtime.restore, 'reconcile': runtime.reconcile, + 'verify-restored': runtime.verify_restored, 'finish': runtime.finish} + if action not in actions: raise RuntimeError('UNKNOWN_ACTION') + return actions[action](state) + + +def main(config): + validate_scope(config['scope']) + import fcntl + file = Path('/tmp') / ('chongchong-inbound-resource-' + config['scope']['marker'] + '.action.lock') + if file.is_symlink(): raise RuntimeError('UNSAFE_ACTION_LOCK') + fd = os.open(file, os.O_RDWR | os.O_CREAT, 0o600) + try: + try: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: raise RuntimeError('RUNTIME_ACTION_IN_PROGRESS_USE_STATUS_LATER') + return execute(config) + finally: os.close(fd) + + +def read_stdin_config(stream=None): + """One bounded UTF-8 JSON stdin protocol; configuration is never argv.""" + global STAGE + STAGE = 'READ_STDIN_CONFIG' + raw = (sys.stdin.buffer if stream is None else stream).read(1024 * 1024 + 1) + if len(raw) > 1024 * 1024: raise RuntimeError('RUNTIME_STDIN_CONFIG_TOO_LARGE') + return json.loads(raw.decode('utf-8')) + + +if __name__ == '__main__': + try: print('INBOUND_RESOURCE_JSON=' + json.dumps({'ok': True, 'data': main(read_stdin_config())})) + except Exception as error: + print('INBOUND_RESOURCE_JSON=' + json.dumps(safe_diagnostic(error))) diff --git a/scripts/inbound-plan-resource-fault-runtime.test.mjs b/scripts/inbound-plan-resource-fault-runtime.test.mjs new file mode 100644 index 000000000..6f20e27b9 --- /dev/null +++ b/scripts/inbound-plan-resource-fault-runtime.test.mjs @@ -0,0 +1,85 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {spawnSync} from 'node:child_process'; +import {fileURLToPath} from 'node:url'; +const script=String.raw` +import importlib.util,sys,json,hashlib,copy,urllib.error +import io +from pathlib import Path +spec=importlib.util.spec_from_file_location('resource_runtime',sys.argv[1]);m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) +payload={'scope':{'marker':'stdin'},'complete':'x'*200000} +assert m.read_stdin_config(io.BytesIO(json.dumps(payload).encode()))==payload +try:m.read_stdin_config(io.BytesIO(b'x'*(1024*1024+1)));raise AssertionError('unbounded config accepted') +except RuntimeError as e:assert str(e)=='RUNTIME_STDIN_CONFIG_TOO_LARGE' +try:m.read_stdin_config(io.BytesIO(b'{"private":"secret"'));raise AssertionError('partial JSON accepted') +except json.JSONDecodeError as e: + d=m.safe_diagnostic(e);assert d['diagnostic']['stage']=='READ_STDIN_CONFIG';assert 'secret' not in json.dumps(d) +m.STAGE='NACOS_SOURCE_READ' +for endpoint,stage in [('/nacos/v3/auth/user/login','NACOS_LOGIN'),('/nacos/v3/admin/cs/config','NACOS_CONFIG_READ')]: + d=m.safe_diagnostic(urllib.error.HTTPError('http://user:password@private.invalid'+endpoint+'?token=secret',404,'secret body',{},None)) + assert d=={'ok':False,'error':stage+'_HTTP_404','diagnostic':{'stage':stage,'errorType':'HTTPError','httpStatus':404}} + assert 'secret' not in json.dumps(d) and 'private' not in json.dumps(d) +assert m.safe_diagnostic(TypeError('private-secret-response'))=={'ok':False,'error':'INBOUND_RESOURCE_ACTION_FAILED','diagnostic':{'stage':'NACOS_SOURCE_READ','errorType':'TypeError'}} +import socket +dns=m.safe_diagnostic(urllib.error.URLError(socket.gaierror(-2,'private DNS hostname secret'))) +assert dns=={'ok':False,'error':'NACOS_SOURCE_READ_TRANSPORT_FAILED','diagnostic':{'stage':'NACOS_SOURCE_READ','errorType':'URLError','transportErrorType':'gaierror','errno':-2}} +assert 'private' not in json.dumps(dns) and 'secret' not in json.dumps(dns) +base=Path(sys.argv[1]).with_name('order-submit-dependency-fault-runtime.py').read_text() +C,ns=m.runtime_class(base) +scope={'environment':'TEST','marker':'OFFLINE','containerBookingId':'2106714159440728066','warehouseId':'193000000000200003','batchPrefixHash':hashlib.sha256(json.dumps('2106714159440728066').encode()).hexdigest(),'planSha256':'a'*64} +r=C(scope,{}) +assert ns['ORDER']=='firstleg-test-lastleg' and ns['PROPERTIES']=={'WAREHOUSE':'baiyue.warehouse.base-url'} +assert r.lock.name=='chongchong-inbound-resource.lock' +assert 'lastleg-service.yml' in C.config_fingerprint.__code__.co_consts or any(isinstance(c,tuple) and 'lastleg-service.yml' in c for c in C.config_fingerprint.__code__.co_consts) +state={'originalBody':{'Entrypoint':['sh','-c','exec java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher']},'proxyPort':4321,'originalImageId':'sha256:'+'c'*64,'originalFingerprint':'f'} +entry=r.injected_entrypoint(state) +assert entry[2].endswith(' --baiyue.warehouse.base-url=http://host.docker.internal:4321') +assert '/warehouse' not in entry[2] and state['originalBody']['Entrypoint'][2].endswith('JarLauncher') +saved=[];calls=[] +r.save=lambda s:saved.append(copy.deepcopy(s)) +def lost(*args):calls.append(args);raise RuntimeError('connection lost') +r.tools={'docker':lost};journal={} +try:r.mutation(journal,'STOP_INTENT','POST','/containers/frozen/stop?t=15');raise AssertionError('unknown accepted') +except RuntimeError as e:assert str(e)=='UNCERTAIN_DOCKER_MUTATION' +assert saved[0]['mutation']['step']=='STOP_INTENT' and journal['uncertain'] +try:r.mutation(journal,'DELETE_INTENT','DELETE','/containers/frozen');raise AssertionError('blind retry') +except RuntimeError as e:assert str(e)=='UNCERTAIN_MUTATION_REQUIRES_RECONCILE' +assert len(calls)==1 +original={'NetworkSettings':{'Networks':{'test-net':{'IPAddress':'172.18.0.7'}}}} +warehouse={'Id':'warehouse-frozen','Image':'sha256:'+'e'*64,'State':{'Health':{'Status':'healthy'}},'NetworkSettings':{'Networks':{'test-net':{'IPAddress':'172.18.0.8'}}}} +token='offline-internal-identity' +r.tools={'inspect':lambda name: warehouse if name=='firstleg-test-warehouse-overseas' else (_ for _ in ()).throw(AssertionError('wrong upstream')), + 'require_test':lambda c,name:None,'env_of':lambda c:{'SERVER_PORT':'9305','DEPLOY_ENV':'test','INTERNAL_SERVICE_TOKEN':token}} +routing={'upstreams':{'WAREHOUSE':'http://172.18.0.8:9305'}} +assert r.verified_upstream(original,routing,token)['Id']=='warehouse-frozen' +for url in ['http://172.18.0.9:9305','http://172.18.0.8:9999','http://user:secret@172.18.0.8:9305','http://172.18.0.8:9305/fallback']: + try:r.verified_upstream(original,{'upstreams':{'WAREHOUSE':url}},token);raise AssertionError('wrong route accepted') + except RuntimeError as e:assert str(e)=='UPSTREAM_NOT_VERIFIED_TEST_CONTAINER' +current={'Image':state['originalImageId'],'Config':{'Entrypoint':entry},'State':{'Health':{'Status':'healthy'}}} +r.tools={'fingerprint':lambda c:'f' if c['Config']['Entrypoint']==state['originalBody']['Entrypoint'] else 'wrong'} +assert r.matches_injected(current,state) +changed=copy.deepcopy(current);changed['Config']['Entrypoint'][2]+=' --unrelated=true';assert not r.matches_injected(changed,state) +def refuse(_):raise RuntimeError('RESTORED_NOT_HEALTHY') +r.verify_restored=refuse +try:r.finish(state);raise AssertionError('finished before restored') +except RuntimeError as e:assert str(e)=='RESTORED_NOT_HEALTHY' +for key,value in [('environment','PROD'),('warehouseId','0'),('batchPrefixHash','b'*64)]: + bad=scope|{key:value} + try:m.validate_scope(bad);raise AssertionError('invalid scope accepted') + except RuntimeError:pass +import base64 +original_factory=m.runtime_class +class MissingState: + def __init__(self,*_):pass + def load(self):raise FileNotFoundError('private-state-path-must-not-escape') +m.runtime_class=lambda _:(MissingState,{'common_tools':lambda _: {}}) +try:m.execute({'action':'status','scope':scope,'baseSource':base64.b64encode(b'offline').decode(),'commonSource':base64.b64encode(b'offline').decode()});raise AssertionError('missing state accepted') +except FileNotFoundError as e: + d=m.safe_diagnostic(e);assert d['diagnostic']=={'stage':'RUNTIME_STATE_READ','errorType':'FileNotFoundError'};assert 'private' not in json.dumps(d) +m.runtime_class=original_factory +print('OFFLINE_RUNTIME_PASS') +`; +test('reviewed Docker methods retain exact target, one-property overlay, journal uncertainty and restore-before-cleanup',()=>{ + const result=spawnSync(process.env.PYTHON||'python',['-c',script,fileURLToPath(new URL('./inbound-plan-resource-fault-runtime.py',import.meta.url))],{encoding:'utf8',env:{...process.env,PYTHONDONTWRITEBYTECODE:'1'}}); + assert.equal(result.status,0,result.stderr||result.error?.message);assert.ok(result.stdout.includes('OFFLINE_RUNTIME_PASS')); +}); diff --git a/scripts/java-topology-contract.test.mjs b/scripts/java-topology-contract.test.mjs index 31f96cf24..f421241d8 100644 --- a/scripts/java-topology-contract.test.mjs +++ b/scripts/java-topology-contract.test.mjs @@ -62,13 +62,13 @@ const parseCompose = (yaml) => { })) } -test('root POM freezes 22 modules and service-map freezes 15 executable services', async () => { +test('root POM freezes 20 modules and service-map freezes 13 executable services', async () => { const modules = parseRootModules(await read('pom.xml')) const services = parseServiceMap(await read('deploy/service-map.yml')) - assert.equal(modules.length, 22) - assert.equal(new Set(modules).size, 22) - assert.equal(services.size, 15) + assert.equal(modules.length, 20) + assert.equal(new Set(modules).size, 20) + assert.equal(services.size, 13) const bootModules = [] for (const module of modules) { @@ -181,24 +181,13 @@ test('SpotBugs remains an opt-in non-blocking report without quality thresholds' assert.doesNotMatch(packageJob, /^ - 静态分析报告$/m) }) -test('regional Compose and Nacos manifests cover the service-map topology', async () => { +test('one TEST Compose and Nacos manifest cover every retained service', async () => { const services = parseServiceMap(await read('deploy/service-map.yml')) - const composeByRegion = new Map([ - ['CN', parseCompose(await read('deploy/docker-compose.yml'))], - ['OVERSEAS', parseCompose(await read('deploy/docker-compose.overseas.yml'))] - ]) - + const compose = parseCompose(await read('deploy/docker-compose.yml')) + assert.deepEqual(sorted(compose.keys()), sorted(services.keys())) + assert.equal(services.size, 13) for (const [module, service] of services) { - const regions = service.regions ?? ['CN'] - for (const [region, compose] of composeByRegion) { - assert.equal(compose.has(module), regions.includes(region), `${module} Compose region drifted for ${region}`) - if (!regions.includes(region)) continue - assert.equal(compose.get(module), service.port, `${module} internal port drifted for ${region}`) - await access(new URL(`../config/nacos/${region.toLowerCase()}/${service.artifactId}.yml`, import.meta.url)) - } + assert.equal(compose.get(module), service.port, module) + await access(new URL(`../config/nacos/test/${service.artifactId}.yml`, import.meta.url)) } - assert.deepEqual( - sorted(new Set([...composeByRegion.values()].flatMap((compose) => [...compose.keys()]))), - sorted(services.keys()) - ) }) diff --git a/scripts/mixed-container-outbound-test-env.mjs b/scripts/mixed-container-outbound-test-env.mjs index 9f5a48cf4..383edbff1 100644 --- a/scripts/mixed-container-outbound-test-env.mjs +++ b/scripts/mixed-container-outbound-test-env.mjs @@ -3,6 +3,7 @@ import { resolve } from 'node:path' import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs' import { createPicker, disablePicker, resolvePickerAccess } from './admin-test-env-modules/phase4-overseas-pda.mjs' import { pickAll, saveReceiptAndComplete } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs' +import { confirmFleetLoading } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' // Explicit retained fixture continuation, not a new fixture generator. const [sourcePath, containerNo, label, outputPath] = process.argv.slice(2) @@ -114,9 +115,14 @@ try { if (!picker?.token) throw new Error('PDA operator unavailable') result.pickerMembershipId = picker.membershipId save() + const tripTask = await api('GET', `/api/pda/v1/last-leg/dispatches/${result.dispatchNo}`, undefined, picker.token) + if (!tripTask.tripNo) throw new Error('Retained dispatch must be assigned to a qualified fleet trip before picking; prepare through the current fleet API') + const trip = await api('GET', `${base}/fleet/trips/${encodeURIComponent(tripTask.tripNo)}`) if (dispatch.status !== 'READY_TO_DEPART' && !await pickAll(context, picker.token, result.dispatchNo)) throw new Error('PDA picking failed') + const tripVersion = await confirmFleetLoading(context, 'mixed-container-outbound', { tripNo: tripTask.tripNo, stops: trip.trip.stops }) result.departure = await api('POST', `/api/pda/v1/last-leg/dispatches/${result.dispatchNo}/depart`, - { idempotencyKey: `${context.marker}-DEPART`, remark: `${context.marker}-test-only` }, picker.token) + { recordVersion: tripVersion, idempotencyKey: `${context.marker}-DEPART`.slice(0,128), remark: `${context.marker}-test-only` }, picker.token) + if (result.departure.status !== 'IN_TRANSIT' || !result.departure.dispatches?.some(item => item.dispatchNo === result.dispatchNo)) throw new Error('Whole-trip departure result missing retained dispatch') save() } const deliveries = result.transfer.deliveries diff --git a/scripts/order-submit-dependency-fault-executor.mjs b/scripts/order-submit-dependency-fault-executor.mjs new file mode 100644 index 000000000..a347c2ab1 --- /dev/null +++ b/scripts/order-submit-dependency-fault-executor.mjs @@ -0,0 +1,292 @@ +// Explicit TEST-only runtime controller. Importing this module performs no HTTP. +import { readFileSync } from 'node:fs' +import { open, mkdir } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { resolve, dirname } from 'node:path' +import { pathToFileURL } from 'node:url' +import { assertOrderDependencyFaultObservation, orderPersistenceFacts } from './admin-test-env-modules/orders.mjs' + +const runtime = readFileSync(new URL('./order-submit-dependency-fault-runtime.py', import.meta.url), 'utf8') +const proxySource = readFileSync(new URL('./order-submit-dependency-fault-proxy.py', import.meta.url)).toString('base64') +const commonSource = readFileSync(new URL('./test-event-fault-runtime.py', import.meta.url)).toString('base64') +const encode = value => Buffer.from(value).toString('base64') +const sha = value => createHash('sha256').update(JSON.stringify(value)).digest('hex') +const assert = (ok, message) => { if (!ok) throw Error(message) } +const pause = ms => new Promise(resolve => setTimeout(resolve, ms)) +const safeError = error => /^(?:[A-Z][A-Z_0-9]{1,100}|Dependency fault evidence: [A-Za-z /:-]+)$/.test(error?.message || '') + ? error.message : 'ORDER_DEPENDENCY_VERIFICATION_FAILED' + +export function scopeFor(plan) { + assert(/^[A-Za-z0-9_-]{1,80}$/.test(plan?.marker || '') && /^[1-9]\d{0,19}$/.test(plan?.customerId || ''), 'Invalid TEST plan scope') + for (const role of ['merchant', 'platform']) { + const command = plan.commands?.[role] + assert(command?.method === 'POST' && /^[1-9]\d{0,19}$/.test(command.orderId || '') + && command.path === `/api/${role}/v1/orders/first-leg/${command.orderId}/submit${role === 'platform' ? `?tenantId=${plan.customerId}` : ''}` + && Number.isSafeInteger(command.body?.expectedVersion) && command.body.expectedVersion >= 0 + && Object.keys(command.body).length === 1 && /^[A-Za-z0-9_-]{1,128}$/.test(command.headers?.['Idempotency-Key'] || '') + && Object.keys(command.headers).length === 1 + && /^[0-9a-f]{64}$/.test(command.initialOrderSha256 || ''), 'Invalid frozen submission command') + } + assert(plan.commands.merchant.orderId !== plan.commands.platform.orderId, 'Two distinct new drafts required') + assert(plan.runtimeChangesExecuted === false, 'Only a reviewed unexecuted plan is accepted') + assert(/^[1-9]\d{0,19}$/.test(plan.receivingWarehouseId || ''), 'Bounded public receiving warehouse required') + assert(Array.isArray(plan.phases) && plan.phases.length === 3, 'Exact three dependency phases required') + for (const [dependency, property, path, messages] of [ + ['IAM','baiyue.clients.iam.url',`/internal/iam/tenants/${plan.customerId}/order-eligibility`,['IAM eligibility service is unavailable','IAM 下单资格响应无效']], + ['FINANCE','baiyue.clients.finance.url',`/internal/finance/v1/wallet/accounts/${plan.customerId}`,['Finance account service is unavailable','Finance account response is invalid']], + ['WAREHOUSE','baiyue.clients.warehouse.url',`/internal/warehouses/0/${plan.receivingWarehouseId}/active`,['主数据服务暂时不可用']], + ]) { + const rows = plan.phases.filter(row => row.dependency === dependency) + assert(rows.length === 1 && rows[0].urlProperty === property && rows[0].targetPath === path && rows[0].targetMethod === 'GET' + && rows[0].faultHttp === 503 && rows[0].expectedHttp === 200 && rows[0].expectedCode === 'S0503' + && JSON.stringify(rows[0].expectedMessages) === JSON.stringify(messages), 'Dependency failure contract may not be weakened') + } + return { marker: plan.marker, customerId: plan.customerId, receivingWarehouseId: plan.receivingWarehouseId, + orderIds: Object.fromEntries(['merchant', 'platform'].map(role => [role, plan.commands[role].orderId])), planSha256: sha(plan) } +} + +export function renderOrderFaultAction(action, scope, agent, extra = {}) { + assert(['preflight','prepare','activate','health','mode','status','records','restore','reconcile','verify-restored','finish'].includes(action), 'Unknown runtime action') + assert(/^[A-Za-z0-9_.-]{1,128}$/.test(agent || '') && /^[A-Za-z0-9_-]{1,80}$/.test(scope.marker), 'Invalid agent/scope') + if (['prepare','preflight'].includes(action)) { + const routing = extra.routing + assert(routing && Object.keys(routing).every(key => ['configurationReviewed','sourceSha256','upstreams','nacos'].includes(key)) + && routing.configurationReviewed === true && routing.nacos && Object.keys(routing.nacos).every(key => ['baseUrl','loginPath','configPath'].includes(key)) + && routing.upstreams && Object.keys(routing.upstreams).sort().join(',') === 'FINANCE,IAM,WAREHOUSE', 'Reviewed routing schema only; do not embed credentials') + for (const address of [routing.nacos.baseUrl,...Object.values(routing.upstreams)]) { + const url = new URL(address) + assert(['http:','https:'].includes(url.protocol) && !url.username && !url.password && !url.search && !url.hash + && ['', '/'].includes(url.pathname), 'Routing origins cannot contain credentials/path/query') + } + if (action === 'prepare') assert(/^sha256:[0-9a-f]{64}$/.test(extra.expectedImageId || '') + && /^[0-9a-f]{64}$/.test(routing.sourceSha256 || ''), 'Reviewed image and Nacos source fingerprint required') + } + const data = encode(JSON.stringify({ ...extra, action, scope, commonSource, ...(action === 'prepare' ? { proxySource } : {}) })) + const literal = encoded => '[' + encoded.match(/.{1,4000}/g).map(part => `'${part}'`).join(',') + "].join('')" + const expression = value => literal(encode(value)) + const groovy = `def p=new ProcessBuilder(['python3','-c',new String(java.util.Base64.decoder.decode(${expression(runtime)}),'UTF-8'),${literal(data)}]).redirectErrorStream(true).start();def out=p.inputStream.getText('UTF-8');p.waitFor();print(out)` + return `def c=jenkins.model.Jenkins.instance.getComputer('${agent}');if(c==null || c.channel==null)throw new RuntimeException('TEST agent unavailable');print(hudson.util.RemotingDiagnostics.executeGroovy(new String(java.util.Base64.decoder.decode(${expression(groovy)}),'UTF-8'),c.channel))` +} + +export function createOrderFaultRuntime(env, scope, fetcher = globalThis.fetch) { + assert(env.CHONGCHONG_ENV === 'test', 'CHONGCHONG_ENV=test required') + assert(env.JENKINS_URL && env.JENKINS_USER && env.JENKINS_API_TOKEN && env.FBA_CORE_JENKINS_AGENT, 'Explicit TEST Jenkins credentials and agent required') + return async (action, extra = {}) => { + let response + try { + response = await fetcher(new URL('scriptText', env.JENKINS_URL.replace(/\/?$/, '/')), { + method: 'POST', redirect: 'error', signal: AbortSignal.timeout(55000), + headers: { Authorization: 'Basic ' + encode(env.JENKINS_USER + ':' + env.JENKINS_API_TOKEN), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ script: renderOrderFaultAction(action, scope, env.FBA_CORE_JENKINS_AGENT, extra) }), + }) + } catch { throw Error(`RUNTIME_${action.toUpperCase().replaceAll('-', '_')}_OUTCOME_UNKNOWN_USE_STATUS`) } + assert(response.ok, `RUNTIME_HTTP_${response.status}_NO_AUTO_RETRY`) + const output = await response.text() + assert(!(output.includes('MultipleCompilationErrorsException') && output.includes('String too long')), + 'RUNTIME_RENDERER_COMPILATION_FAILED_NO_ACTION') + const rows = output.split(/\r?\n/).filter(row => row.startsWith('ORDER_DEPENDENCY_JSON=')) + assert(rows.length === 1, 'RUNTIME_RESPONSE_AMBIGUOUS_NO_AUTO_RETRY') + const result = JSON.parse(rows[0].slice('ORDER_DEPENDENCY_JSON='.length)) + assert(result?.ok === true, /^[A-Z_0-9]{1,90}$/.test(result?.error || '') ? result.error : 'RUNTIME_ACTION_FAILED') + return result.data + } +} + +export async function runOrderFaultWindow({ plan, routing, expectedImageId, action, snapshot, submit, recovery, wait = pause }) { + const scope = scopeFor(plan), report = { scope, success: false, observations: [], restoration: null } + let prepareAttempted = false, failure + const health = async () => { + for (let i = 0; i < 180; i++) { + const value = await action('health') + if (value.healthy === true) return + await wait(1000) + } + throw Error('ORDER_HEALTH_TIMEOUT') + } + try { + for (const role of ['merchant','platform']) { + const before = await snapshot(role) + assert(createHash('sha256').update(orderPersistenceFacts(before.detail)).digest('hex') === plan.commands[role].initialOrderSha256 + && before.detail.order.status === 'DRAFT' && String(before.detail.order.tenantId) === scope.customerId + && String(before.detail.order.id) === scope.orderIds[role] + && String(before.detail.order.receivingWarehouseId) === scope.receivingWarehouseId + && ['cargoes','parties','internalNotes','statusHistory'].every(field => Array.isArray(before.detail[field])) + && before.inboundPageComplete === true, 'PREPARED_DRAFT_CHANGED') + } + prepareAttempted = true + report.prepared = await action('prepare', { routing, expectedImageId }) + for (let i = 0; i < 15; i++) { + if ((await action('status')).proxy?.mode === 'PASS') break + assert(i < 14, 'PROXY_STARTUP_NOT_READY'); await wait(1000) + } + await action('activate'); await health() + for (const dependency of ['IAM','FINANCE','WAREHOUSE']) { + for (const role of ['merchant','platform']) { + const before = await snapshot(role), command = plan.commands[role] + const request = { method: command.method, path: command.path, body: structuredClone(command.body), + headers: { ...command.headers, 'X-Request-Id': `dep-${plan.marker}-${dependency}-${role}` } } + let response + await action('mode', { mode: dependency, ttl: 60 }) + try { response = await submit(role, request) } + finally { await action('mode', { mode: 'PASS' }) } + const after = await snapshot(role) + const proxyRecords = (await action('records')).records.filter(row => row.dependency === dependency) + report.observations.push(assertOrderDependencyFaultObservation(plan, dependency, role, { request, response, before, after, proxyRecords })) + } + } + } catch (error) { failure = error } + finally { + if (prepareAttempted) { + try { + await action('restore') // Never retry a mutation after an ambiguous outcome. + await health() + report.restoredRuntime = await action('verify-restored') + assert(report.restoredRuntime.originalImageId === expectedImageId + && report.restoredRuntime.currentImageId === expectedImageId + && report.restoredRuntime.originalFingerprint === report.restoredRuntime.currentFingerprint + && report.restoredRuntime.nacosSourcesUnchanged === true + && report.restoredRuntime.upstreamReads?.IAM === true && report.restoredRuntime.upstreamReads?.FINANCE === true + && report.restoredRuntime.upstreamReads?.WAREHOUSE === true, + 'RESTORED_IDENTITY_OR_ROUTES_NOT_PROVED') + const businessRecovery = await recovery() + report.businessRecovery = businessRecovery + report.restoration = await action('finish', { businessRecovery }) + assert(report.restoration.restored && report.restoration.proxyStopped && report.restoration.orderRoutesVerified, 'RESTORATION_NOT_COMPLETE') + } catch (error) { + report.recoveryError = safeError(error) + if (error.recoveryDiagnostics) report.recoveryDiagnostics = error.recoveryDiagnostics + failure ||= error + } + } + } + report.success = !failure && report.observations.length === 6 && Boolean(report.restoration?.restored) + if (failure) report.error = safeError(failure) + return report +} + +export async function waitForRecoveredSubmission({ plan, role, snapshot, wait = pause }) { + const command = plan.commands[role] + let previous, orderFacts + for (let i = 0; i <= 60; i++) { + const current = await snapshot(role), detail = current.detail + assert(current.inboundPageComplete === true && Array.isArray(current.inbounds) && current.inbounds.length <= 1 + && ['cargoes','parties','internalNotes','statusHistory'].every(field => Array.isArray(detail?.[field])) + && String(detail.order.id) === command.orderId && String(detail.order.tenantId) === plan.customerId + && detail.order.status === 'SUBMITTED' && Number(detail.order.version) === command.body.expectedVersion + 1 + && detail.statusHistory.filter(row => row.action === 'SUBMITTED').length === 1 + && detail.statusHistory.filter(row => row.action === 'CREATE').length === 1 + && (current.task === null || current.task && String(current.task.orderId) === command.orderId + && String(current.task.tenantId) === plan.customerId) + && current.inbounds.every(row => String(row.orderId) === command.orderId && row.orderNo === command.orderNo + && String(row.tenantId) === plan.customerId), 'RECOVERY_SUBMISSION_FACTS_INVALID') + const facts = orderPersistenceFacts(detail) + orderFacts ??= facts + assert(facts === orderFacts, 'RECOVERY_ORDER_DRIFT') + const projected = current.inbounds.length === 1 && current.inbounds[0].orderStatusSnapshot === 'SUBMITTED' + if (projected && previous && sha(previous) === sha(current)) return current + previous = projected ? current : undefined + if (i < 60) await wait(500) + } + throw Error('RECOVERY_PROJECTION_TIMEOUT') +} + +function businessClient(env, plan, fetcher = globalThis.fetch) { + const sessions = {} + const call = async (method, path, body, headers = {}, token) => { + const response = await fetcher(new URL(path, env.ADMIN_TEST_GATEWAY), { method, redirect:'error', signal:AbortSignal.timeout(20000), + headers:{'Content-Type':'application/json', ...headers, ...(token ? {Authorization:`Bearer ${token}`} : {})}, ...(body === undefined ? {} : {body:JSON.stringify(body)}) }) + return {http:response.status,body:await response.json()} + } + const ok = value => { assert(value.http === 200 && String(value.body?.code) === '200', 'BUSINESS_READ_OR_RECOVERY_FAILED'); return value.body.data } + const snapshot = async role => { + const command = plan.commands[role] + const detailPath = `/api/${role}/v1/orders/first-leg/${command.orderId}${role === 'platform' ? `?tenantId=${plan.customerId}` : ''}` + const detail = ok(await call('GET',detailPath,undefined,{},sessions[role].accessToken)) + assert(String(detail?.order?.id)===command.orderId && String(detail.order.tenantId)===plan.customerId + && ['cargoes','parties','internalNotes','statusHistory'].every(field=>Array.isArray(detail[field])), 'ORDER_SNAPSHOT_INCOMPLETE_OR_FOREIGN') + const taskResponse = await call('GET',`/api/platform/v1/first-leg/tasks/by-order/${command.orderId}?tenantId=${plan.customerId}`,undefined,{},sessions.platform.accessToken) + assert(taskResponse.http === 200 && ['200','A0404'].includes(String(taskResponse.body?.code)), 'TASK_SNAPSHOT_FAILED') + const task = String(taskResponse.body.code) === '200' ? taskResponse.body.data ?? null : null + const inbounds = []; let total + for (let page=1;;page++) { + const value=ok(await call('GET',`/api/platform/inbounds?tenantId=${plan.customerId}&orderNo=${encodeURIComponent(command.orderNo)}&page=${page}&size=200`,undefined,{},sessions.platform.accessToken)) + assert(Array.isArray(value.records) && Number(value.page)===page && Number.isSafeInteger(Number(value.total)), 'INBOUND_PAGE_INVALID') + total ??= Number(value.total); assert(Number(value.total)===total && value.records.length===Math.min(200,Math.max(0,total-inbounds.length)), 'INBOUND_PAGE_INCOMPLETE') + for(const row of value.records)assert(row.orderNo===command.orderNo && String(row.tenantId)===plan.customerId && !inbounds.some(before=>String(before.id)===String(row.id)), 'INBOUND_SCOPE_OR_DUPLICATE') + inbounds.push(...value.records);if(inbounds.length===total)break + } + return {detail,task,inbounds,inboundPageComplete:true} + } + const submit = (role, request) => call(request.method,request.path,request.body,request.headers,sessions[role].accessToken) + return { snapshot, submit, + async login() { + for(const [role,prefix] of [['platform','ADMIN_TEST'],['merchant','MERCHANT_TEST']]) { + assert(env[prefix+'_USERNAME'] && env[prefix+'_PASSWORD'], 'EXPLICIT_BUSINESS_CREDENTIALS_REQUIRED') + sessions[role]=ok(await call('POST','/api/auth/login',{loginId:env[prefix+'_USERNAME'],password:env[prefix+'_PASSWORD']})) + } + const profile=ok(await call('GET','/api/merchant/v1/iam/tenant/profile',undefined,{},sessions.merchant.accessToken)) + assert(String(profile.id)===plan.customerId && profile.status==='ACTIVE','REGISTERED_CUSTOMER_SESSION_MISMATCH') + }, + async recovery() { + const evidence={} + try { + for(const role of ['merchant','platform']) { + const command=plan.commands[role] + ok(await submit(role,command)) + const before=await waitForRecoveredSubmission({plan,role,snapshot}) + ok(await submit(role,command));const after=await snapshot(role) + evidence[role]={orderId:command.orderId,before,after,snapshotSha256:sha(after)} + assert(sha(before)===sha(after),'RECOVERY_REPLAY_DRIFT') + Object.assign(evidence[role],{status:'SUBMITTED',sameKeyReplayVerified:true}) + } + } catch(error) { + error.recoveryDiagnostics=evidence + throw error + } + return evidence + }, + async logout() { + let success=true + for(const session of Object.values(sessions))try {ok(await call('POST','/api/auth/logout',{refreshToken:session.refreshToken},{},session.accessToken))}catch{success=false} + return success + }, + } +} + +async function main(argv) { + const [mode,...rest]=argv, args={} + for(let i=0;i{console.error(/^[A-Z_0-9]{1,90}$/.test(error.message)?error.message:'ORDER_DEPENDENCY_ARGUMENT_OR_CONFIGURATION_ERROR');process.exitCode=1}) diff --git a/scripts/order-submit-dependency-fault-executor.test.mjs b/scripts/order-submit-dependency-fault-executor.test.mjs new file mode 100644 index 000000000..df99ca192 --- /dev/null +++ b/scripts/order-submit-dependency-fault-executor.test.mjs @@ -0,0 +1,323 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { buildOrderDependencyFaultPlan } from './admin-test-env-modules/orders.mjs' +import { scopeFor, renderOrderFaultAction, createOrderFaultRuntime, runOrderFaultWindow, waitForRecoveredSubmission } from './order-submit-dependency-fault-executor.mjs' + +const image = 'sha256:' + 'a'.repeat(64) +function fixture() { + const detail = id => ({order:{id,tenantId:'20',receivingWarehouseId:'30',orderNo:`FL${id}`,version:2,status:'DRAFT'},cargoes:[],parties:[],internalNotes:[],statusHistory:[]}) + const raw={marker:'TEST-DEPENDENCY',customerId:'20',registeredCustomerId:'20',merchant:detail('101'),platform:detail('102')} + return {raw,plan:buildOrderDependencyFaultPlan(raw)} +} +const env={CHONGCHONG_ENV:'test',JENKINS_URL:'https://offline.invalid/',JENKINS_USER:'offline',JENKINS_API_TOKEN:'never-print-this',FBA_CORE_JENKINS_AGENT:'test-agent'} + +function recoverySnapshot(projected=true) { + const {raw,plan}=fixture(),detail=structuredClone(raw.merchant) + detail.order.status='SUBMITTED';detail.order.version=3 + detail.statusHistory=[{action:'CREATE'},{action:'SUBMITTED'}] + return {plan,snapshot:{detail,task:null,inbounds:projected?[{id:'1001',orderId:'101',orderNo:'FL101',tenantId:'20',orderStatusSnapshot:'SUBMITTED'}]:[],inboundPageComplete:true}} +} +test('recovery waits for the actual inbound projection even when two empty reads are stable',async()=>{ + const {plan,snapshot}=recoverySnapshot(),empty=recoverySnapshot(false).snapshot + const values=[empty,empty,snapshot,snapshot];let reads=0 + const value=await waitForRecoveredSubmission({plan,role:'merchant',snapshot:async()=>values[Math.min(reads++,values.length-1)],wait:async()=>{}}) + assert.equal(reads,4);assert.deepEqual(value,snapshot) +}) +test('missing submission projection cannot pass recovery after the bounded wait',async()=>{ + const {plan,snapshot}=recoverySnapshot(false);let reads=0 + await assert.rejects(waitForRecoveredSubmission({plan,role:'merchant',snapshot:async()=>{reads++;return snapshot},wait:async()=>{}}),/RECOVERY_PROJECTION_TIMEOUT/) + assert.equal(reads,61) +}) +test('recovery rejects duplicate or foreign projected inbounds instead of waiting them away',async()=>{ + for(const change of [s=>s.inbounds.push({...s.inbounds[0],id:'1002'}),s=>s.inbounds[0].tenantId='21',s=>s.inbounds[0].orderId='102']) { + const {plan,snapshot}=recoverySnapshot();change(snapshot) + await assert.rejects(waitForRecoveredSubmission({plan,role:'merchant',snapshot:async()=>snapshot,wait:async()=>{}}),/RECOVERY_SUBMISSION_FACTS_INVALID/) + } +}) +test('recovery projection wait rejects order fact drift and incomplete pagination',async()=>{ + const {plan,snapshot}=recoverySnapshot(),changed=structuredClone(snapshot);changed.detail.order.remark='late mutation' + let reads=0 + await assert.rejects(waitForRecoveredSubmission({plan,role:'merchant',snapshot:async()=>reads++?changed:snapshot,wait:async()=>{}}),/RECOVERY_ORDER_DRIFT/) + const incomplete=structuredClone(snapshot);incomplete.inboundPageComplete=false + await assert.rejects(waitForRecoveredSubmission({plan,role:'merchant',snapshot:async()=>incomplete,wait:async()=>{}}),/RECOVERY_SUBMISSION_FACTS_INVALID/) +}) + +test('runtime classifies dependency HTTP stages without exposing tokens, URLs or response bodies',()=>{ + const script=String.raw` +import importlib.util,sys,urllib.error +spec=importlib.util.spec_from_file_location('diagnostic_runtime',sys.argv[1]);m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) +for path,expected in [('/nacos/v3/auth/user/login','NACOS_LOGIN_404'),('/nacos/v3/admin/cs/config','NACOS_CONFIG_404'),('/internal/finance/wallet','DEPENDENCY_HTTP_404')]: + error=urllib.error.HTTPError('http://private.invalid'+path+'?accessToken=secret',404,'private response secret',{},None) + assert m.safe_error_code(error)==expected +assert m.safe_error_code(urllib.error.URLError('http://private.invalid?secret'))=='DEPENDENCY_TRANSPORT_FAILED' +assert m.safe_error_code(RuntimeError('KNOWN_FAILURE'))=='KNOWN_FAILURE' +assert m.safe_error_code(ValueError('secret in response'))=='ORDER_DEPENDENCY_ACTION_FAILED' +` + const result=spawnSync(process.env.PYTHON||'python',['-c',script,fileURLToPath(new URL('./order-submit-dependency-fault-runtime.py',import.meta.url))],{encoding:'utf8',env:{...process.env,PYTHONDONTWRITEBYTECODE:'1'}}) + assert.equal(result.status,0,result.stderr||result.error?.message) +}) + +test('renderer binds TEST scope and emits no Jenkins credential; ambiguous remote mutation is never retried',async()=>{ + const {plan}=fixture(),scope=scopeFor(plan) + assert.ok(!renderOrderFaultAction('status',scope,env.FBA_CORE_JENKINS_AGENT).includes(env.JENKINS_API_TOKEN)) + assert.throws(()=>renderOrderFaultAction('delete-all',scope,'agent')) + assert.throws(()=>renderOrderFaultAction('prepare',scope,'agent',{routing:{configurationReviewed:true,password:'must-not-embed'}})) + assert.throws(()=>createOrderFaultRuntime({...env,CHONGCHONG_ENV:'prod'},scope)) + for(const mutate of [p=>p.phases[0].expectedCode='A0403',p=>p.phases[0].targetPath+='/other',p=>p.phases[0].expectedMessages.push('anything')]) { + const changed=structuredClone(plan);mutate(changed);assert.throws(()=>scopeFor(changed)) + } + let calls=0 + const runtime=createOrderFaultRuntime(env,scope,async()=>{calls++;throw Error('connection lost with secret')}) + await assert.rejects(runtime('activate'),/OUTCOME_UNKNOWN_USE_STATUS/) + assert.equal(calls,1) +}) +test('renderer chunks every embedded Groovy literal and preserves the complete runtime payload',()=>{ + const {plan}=fixture(),scope=scopeFor(plan) + const routing={configurationReviewed:true,nacos:{baseUrl:'http://nacos.invalid:8848',loginPath:'/nacos/v3/auth/user/login',configPath:'/nacos/v3/admin/cs/config'},upstreams:{IAM:'http://iam.invalid:9201',FINANCE:'http://finance.invalid:9203',WAREHOUSE:'http://warehouse.invalid:9205'}} + const rendered=renderOrderFaultAction('preflight',scope,'test-agent',{routing}) + const arrays=text=>Array.from(text.matchAll(/\[('[A-Za-z0-9+/=]+'(?:,'[A-Za-z0-9+/=]+')*)\]\.join\(''\)/g),match=>Array.from(match[1].matchAll(/'([^']+)'/g),part=>part[1])) + const outer=arrays(rendered) + assert.equal(outer.length,1) + const inner=Buffer.from(outer[0].join(''),'base64').toString('utf8') + assert.ok(Array.from(inner.matchAll(/'([^']*)'/g),match=>match[1].length).every(length=>length<=4000)) + const embedded=arrays(inner) + assert.equal(embedded.length,2) + const config=JSON.parse(Buffer.from(embedded[1].join(''),'base64').toString('utf8')) + assert.deepEqual(config.scope,scope) + assert.deepEqual(config.routing,routing) + assert.equal(config.action,'preflight') + assert.ok(Buffer.from(config.commonSource,'base64').length>0) +}) + +function windowFixture(fault='') { + const {raw,plan}=fixture(),calls=[],records=[] + let mode='PASS' + const options={plan,routing:{configurationReviewed:true},expectedImageId:image,wait:async()=>{}, + async action(action,extra={}) { + calls.push(action) + if(action==='prepare'&&fault==='prepare-unknown')throw Error('PREPARE_OUTCOME_UNKNOWN') + if(action==='restore'&&['restore-unknown','prepare-unknown'].includes(fault))throw Error('UNCERTAIN_MUTATION_REQUIRES_RECONCILE') + if(action==='status')return {proxy:{mode:'PASS'}} + if(action==='health')return {healthy:true} + if(action==='mode'){mode=extra.mode;return {mode}} + if(action==='records')return {records} + if(action==='verify-restored')return {originalImageId:image,currentImageId:image,originalFingerprint:'same',currentFingerprint:'same',upstreamReads:{IAM:true,FINANCE:true,WAREHOUSE:true},nacosSourcesUnchanged:true} + if(action==='finish')return {restored:true,healthy:true,proxyStopped:true,orderRoutesVerified:true} + return {} + }, + async snapshot(role){return {detail:structuredClone(raw[role]),task:null,inbounds:[],inboundPageComplete:true}}, + async submit(role,request){ + const phase=plan.phases.find(item=>item.dependency===mode) + records.push({requestId:request.headers['X-Request-Id'],dependency:mode,method:'GET',path:phase.targetPath, + matched:true,responseStatus:503,forwarded:false,internalTokenPresent:true}) + return {http:200,body:{code:fault==='wrong-business-code'?'A0403':'S0503',message:phase.expectedMessages[0]}} + }, + async recovery(){calls.push('business-recovery');if(fault==='business-recovery-failed')throw Error('RECOVERY_REPLAY_DRIFT') + return {merchant:{orderId:'101',status:'SUBMITTED',sameKeyReplayVerified:true},platform:{orderId:'102',status:'SUBMITTED',sameKeyReplayVerified:true}}}, + } + return {options,calls} +} + +test('six observations restore original routes and verify business recovery before stopping proxy',async()=>{ + const {options,calls}=windowFixture(),report=await runOrderFaultWindow(options) + assert.equal(report.success,true);assert.equal(report.observations.length,6) + assert.ok(calls.indexOf('restore'){ + const {options,calls}=windowFixture('wrong-business-code'),report=await runOrderFaultWindow(options) + assert.equal(report.success,false);assert.equal(report.restoration.restored,true) + assert.ok(calls.includes('finish')) +}) +test('a redirected public warehouse plan stops before any runtime mutation',async()=>{ + const {options,calls}=windowFixture() + options.plan.receivingWarehouseId='31' + options.plan.phases[2].targetPath='/internal/warehouses/0/31/active' + const report=await runOrderFaultWindow(options) + assert.equal(report.success,false) + assert.ok(!calls.includes('prepare')) + assert.ok(!calls.includes('activate')) +}) +test('unverified warehouse restoration blocks business recovery and proxy finish',async()=>{ + const {options,calls}=windowFixture(),original=options.action + options.action=async(action,extra)=>{ + const result=await original(action,extra) + if(action==='verify-restored')result.upstreamReads.WAREHOUSE=false + return result + } + const report=await runOrderFaultWindow(options) + assert.equal(report.success,false) + assert.ok(calls.includes('restore')) + assert.ok(!calls.includes('business-recovery')) + assert.ok(!calls.includes('finish')) +}) +for(const fault of ['prepare-unknown','restore-unknown','business-recovery-failed'])test(`unsafe cleanup blocks finish: ${fault}`,async()=>{ + const {options,calls}=windowFixture(fault),report=await runOrderFaultWindow(options) + assert.equal(report.success,false);assert.equal(calls.filter(action=>action==='prepare').length,1) + assert.equal(calls.filter(action=>action==='restore').length,1);assert.ok(!calls.includes('finish')) +}) + +test('offline runtime journals Docker transitions, reconciles observed outcomes and retains original configuration (no Docker/HTTP)',()=>{ + const script=String.raw` +import importlib.util,sys,copy,json,unittest +from pathlib import Path +spec=importlib.util.spec_from_file_location('runtime',sys.argv[1]);m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) +common=m.common_tools(Path(sys.argv[2]).read_text(encoding='utf-8')) +class Fake: + def __init__(self): + self.current={'Id':'original-container-id','Name':'/firstleg-test-order','Image':'sha256:'+'a'*64, + 'Config':{'Image':'offline/order:tag','Env':['DEPLOY_ENV=test','SERVER_PORT=9204','INTERNAL_SERVICE_TOKEN=offline-secret-long'], + 'Entrypoint':copy.deepcopy(m.ENTRYPOINT),'Cmd':None,'Labels':{'com.docker.compose.project':'firstleg-test'}}, + 'HostConfig':{'AutoRemove':False,'ExtraHosts':['host.docker.internal:host-gateway']},'Mounts':[], + 'NetworkSettings':{'Networks':{'test':{'IPAddress':'172.20.0.10','NetworkID':'n1','Aliases':['order','original-con'],'MacAddress':'aa:bb:cc:dd:ee:ff'}}}, + 'State':{'Running':True,'Health':{'Status':'healthy'}}} + self.original=copy.deepcopy(self.current);self.calls=[];self.fail=None;self.failBefore=None;self.serial=0 + def inspect(self,name): + if not self.current or name not in ('firstleg-test-order',self.current['Id']):raise RuntimeError('DOCKER_404') + return copy.deepcopy(self.current) + def docker(self,method,path,body=None): + if method=='GET':return {'Id':self.original['Image']} + self.calls.append((method,path));result=None + if self.failBefore=='stop' and '/stop?' in path:raise RuntimeError('REQUEST_OUTCOME_UNKNOWN') + if '/stop?' in path:self.current['State']['Running']=False;op='stop' + elif method=='DELETE':self.current=None;op='delete' + elif '/create?' in path: + self.serial+=1;new_id='new-container-'+str(self.serial);op='create' + self.current={'Id':new_id,'Name':'/firstleg-test-order','Image':self.original['Image'], + 'Config':{k:copy.deepcopy(v) for k,v in body.items() if k not in ('HostConfig','NetworkingConfig')}, + 'HostConfig':copy.deepcopy(body['HostConfig']),'Mounts':[], + 'NetworkSettings':{'Networks':{}},'State':{'Running':False,'Health':{'Status':'healthy'}}} + for name,entry in body['NetworkingConfig']['EndpointsConfig'].items(): + self.current['NetworkSettings']['Networks'][name]={'IPAddress':entry['IPAMConfig']['IPv4Address'],'NetworkID':'n1', + 'Aliases':entry['Aliases']+[new_id[:12]],'MacAddress':entry['MacAddress'],'IPAMConfig':entry['IPAMConfig']} + result={'Id':new_id} + elif path.endswith('/start'):self.current['State']['Running']=True;op='start' + else:raise AssertionError(path) + if self.fail==op:self.fail=None;raise RuntimeError('RESPONSE_LOST') + return result +class RuntimeTests(unittest.TestCase): + def fixture(self): + f=Fake();tools=dict(common);tools.update(inspect=f.inspect,docker=f.docker) + scope={'marker':'OFFLINE','customerId':'7','receivingWarehouseId':'30','orderIds':{'merchant':'10','platform':'11'},'planSha256':'b'*64} + r=m.Runtime(scope,tools,Path('offline-not-created'));saved=[];controls=[] + r.save=lambda state:saved.append(copy.deepcopy(state));r.control=lambda state,mode=None,ttl=0:(controls.append(mode) or {'mode':'PASS'}) + r.probes=lambda state:{'IAM':True,'FINANCE':True,'WAREHOUSE':True} + r.config_fingerprint=lambda container,routing:'reviewed-config' + state={'scope':scope,'phase':'PROXY_STARTED','originalId':f.original['Id'],'originalImageId':f.original['Image'], + 'originalFingerprint':common['fingerprint'](f.original),'originalBody':common['recreation'](f.original,common['env_of'](f.original),full=True), + 'proxyPort':12345,'controlPort':12346,'mutation':None,'routing':{},'routingSourceSha256':'reviewed-config'} + return f,r,state,saved,controls + def test_warehouse_probe_requires_the_exact_active_public_domestic_warehouse(self): + import io + from unittest.mock import patch + f,r,s,saved,controls=self.fixture() + state={'internalToken':'never-public','upstreams':{'IAM':'http://iam.invalid','FINANCE':'http://finance.invalid','WAREHOUSE':'http://warehouse.invalid'}} + warehouse={'id':30,'tenantId':0,'status':'ACTIVE','warehouseType':'DOMESTIC','warehouseName':'Scoped receiving warehouse'} + class Response(io.BytesIO):status=200 + def response(request,timeout): + url=request.full_url + if '/internal/iam/' in url:data={'eligible':True} + elif '/internal/finance/' in url:data={'actualBalance':0} + else: + self.assertTrue(url.endswith('/internal/warehouses/0/30/active')) + data={'warehouse':warehouse} + return Response(json.dumps({'code':'200','data':data}).encode()) + with patch.object(m,'http_open',side_effect=response): + self.assertEqual(m.Runtime.probes(r,state),{'IAM':True,'FINANCE':True,'WAREHOUSE':True}) + for field,value in [('id',31),('tenantId',20),('status','DISABLED'),('warehouseType','FBA'),('warehouseName','')]: + original=warehouse[field];warehouse[field]=value + with self.assertRaisesRegex(RuntimeError,'RECEIVING_WAREHOUSE_NOT_READY'):m.Runtime.probes(r,state) + warehouse[field]=original + self.assertEqual(f.calls,[]) + def test_original_fingerprint_config_and_image_restored(self): + f,r,s,saved,controls=self.fixture();r.activate(s) + self.assertTrue(r.matches_injected(f.current,s));self.assertEqual(s['phase'],'INJECTED') + self.assertTrue(any(x.get('mutation',{}).get('step')=='CREATE_INTENT' for x in saved if x.get('mutation'))) + r.restore(s);result=r.verify_restored(s) + self.assertEqual(result['originalFingerprint'],result['currentFingerprint']);self.assertEqual(f.current['Config'],f.original['Config']) + def test_unknown_outcomes_never_auto_repeat_docker_and_can_be_explicitly_reconciled(self): + for operation in ['stop','delete','create','start']: + f,r,s,saved,controls=self.fixture();f.fail=operation + with self.assertRaisesRegex(RuntimeError,'UNCERTAIN'):r.activate(s) + count=len(f.calls) + with self.assertRaisesRegex(RuntimeError,'RECONCILE'):r.restore(s) + self.assertEqual(len(f.calls),count);self.assertEqual(controls[-1],'PASS') + r.reconcile(s);self.assertEqual(len(f.calls),count) + r.restore(s);self.assertTrue(r.verify_restored(s)['healthy']) + def test_unrecognized_configuration_is_never_overwritten(self): + f,r,s,saved,controls=self.fixture();r.activate(s);f.current['Config']['Env'].append('UNRELATED_CHANGE=1');count=len(f.calls) + with self.assertRaisesRegex(RuntimeError,'UNRECOGNIZED'):r.restore(s) + self.assertEqual(len(f.calls),count) + def test_unknown_stop_without_observed_stop_is_not_misreported_as_restored(self): + f,r,s,saved,controls=self.fixture();f.failBefore='stop' + with self.assertRaisesRegex(RuntimeError,'UNCERTAIN'):r.activate(s) + count=len(f.calls) + with self.assertRaisesRegex(RuntimeError,'NO_BLIND_RECREATE'):r.reconcile(s) + self.assertTrue(s['uncertain']);self.assertTrue(f.current['State']['Running']);self.assertEqual(len(f.calls),count) + def test_restore_still_runs_when_proxy_control_is_unreachable(self): + f,r,s,saved,controls=self.fixture();r.activate(s) + def unavailable(*args):raise RuntimeError('CONTROL_LOST') + r.control=unavailable;r.restore(s);self.assertTrue(r.verify_restored(s)['healthy']) + def test_finish_refuses_unproved_business_route_recovery(self): + f,r,s,saved,controls=self.fixture();r.activate(s);r.restore(s) + from unittest.mock import patch + with patch.object(Path,'read_text',return_value='OFFLINE'),patch.object(Path,'is_symlink',return_value=False): + with self.assertRaisesRegex(RuntimeError,'ROUTES_NOT_PROVED'):r.finish(s,{}) + def test_changed_nacos_configuration_prevents_recovery_claim(self): + f,r,s,saved,controls=self.fixture();r.activate(s);r.restore(s);r.config_fingerprint=lambda *args:'changed' + with self.assertRaisesRegex(RuntimeError,'NACOS_ROUTE_CONFIGURATION_CHANGED'):r.verify_restored(s) + def test_nacos_source_reads_are_explicit_authenticated_and_redacted(self): + from unittest.mock import patch + f,r,s,saved,controls=self.fixture() + f.current['Config']['Env']+=['NACOS_SERVER_ADDR=host.docker.internal:8848','NACOS_USERNAME=offline-user', + 'NACOS_PASSWORD=offline-password','NACOS_GROUP=BAIYUE','NACOS_NAMESPACE=test-only'] + routing={'nacos':{'baseUrl':'http://127.0.0.1:8848','loginPath':'/nacos/v3/auth/user/login','configPath':'/nacos/v3/admin/cs/config'}} + calls=[] + class Response: + status=200 + def __init__(self,value):self.value=value + def __enter__(self):return self + def __exit__(self,*args):pass + def read(self):return self.value + def read(req,timeout): + calls.append(req) + if req.data is not None:return Response(b'{"accessToken":"offline-token"}') + query=m.urllib.parse.parse_qs(m.urllib.parse.urlsplit(req.full_url).query) + self.assertEqual(req.get_method(),'GET');self.assertEqual(req.get_header('Authorization'),'Bearer offline-token') + self.assertNotIn('offline-token',req.full_url) + self.assertEqual(query['namespaceId'],['test-only']);self.assertEqual(query['groupName'],['BAIYUE']) + return Response(json.dumps({'code':0,'data':{'dataId':query['dataId'][0],'namespaceId':'test-only','groupName':'BAIYUE','content':'spring:\n test: only\n'}}).encode()) + with patch.object(m,'http_open',side_effect=read): + value=m.Runtime.config_fingerprint(r,f.current,routing) + self.assertEqual(len(value),64);self.assertEqual(len(calls),3) + self.assertIn(b'password=offline-password',calls[0].data) + self.assertTrue(all('/nacos/v3/admin/cs/config?' in req.full_url for req in calls[1:])) + with patch.object(m,'http_open',return_value=Response(b'{"accessToken":"offline-token","code":0,"data":{"content":"secret config","dataId":"foreign"}}')): + with self.assertRaisesRegex(RuntimeError,'NACOS_SOURCE_IDENTITY_MISMATCH'):m.Runtime.config_fingerprint(r,f.current,routing) + routing['nacos']['baseUrl']='http://other.invalid:8848' + with self.assertRaisesRegex(RuntimeError,'EXPLICIT_NACOS'):m.Runtime.config_fingerprint(r,f.current,routing) + self.assertEqual(len(calls),3) + def test_effective_source_extraction_excludes_all_event_and_sql_functions(self): + self.assertEqual(set(common),{'DockerConnection','docker','inspect','env_of','require_test','recreation','fingerprint'}) + def test_control_token_is_not_sent_to_an_unowned_listener(self): + from unittest.mock import patch + f,r,s,saved,controls=self.fixture();s.update(proxyPid=777,controlToken='private-control') + command=str(r.directory/'proxy.py').encode()+b'\0' + tcp='header\n 0: 00000000:3039 00000000:0000 0A 0:0 0:0 0 1000 0 123\n' + with patch.object(Path,'read_bytes',return_value=command),patch.object(Path,'iterdir',return_value=iter([Path('fd')])), \ + patch.object(m.os,'readlink',return_value='socket:[123]'),patch.object(Path,'read_text',return_value=tcp), \ + patch.object(m,'http_open') as request: + with self.assertRaisesRegex(RuntimeError,'LISTENERS_NOT_OWNED'):m.Runtime.control(r,s) + request.assert_not_called() +result=unittest.TextTestRunner().run(unittest.defaultTestLoader.loadTestsFromTestCase(RuntimeTests)) +sys.exit(0 if result.wasSuccessful() else 1) +` + const result=spawnSync(process.env.PYTHON||'python',['-c',script, + fileURLToPath(new URL('./order-submit-dependency-fault-runtime.py',import.meta.url)), + fileURLToPath(new URL('./test-event-fault-runtime.py',import.meta.url))],{encoding:'utf8',env:{...process.env,PYTHONDONTWRITEBYTECODE:'1'}}) + assert.equal(result.status,0,result.stderr || result.error?.message) + assert.match(result.stderr,/Ran 11 tests/) +}) diff --git a/scripts/order-submit-dependency-fault-proxy.py b/scripts/order-submit-dependency-fault-proxy.py new file mode 100644 index 000000000..94d2a9738 --- /dev/null +++ b/scripts/order-submit-dependency-fault-proxy.py @@ -0,0 +1,180 @@ +"""TEST-only scoped synchronous dependency proxy. Default/expired mode is PASS. + +Configuration and control token are read from a mode-0600 file, never argv values. +The runtime owns lifecycle; this file never invokes Docker, SQL or a shell. +""" +import argparse +import hmac +import http.client +import json +import os +import re +import threading +import time +import urllib.parse +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +HOP = {'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', + 'te', 'trailer', 'transfer-encoding', 'upgrade', 'host', 'content-length'} + + +def forward_headers(headers, length): + connection = {k.lower(): v for k, v in headers.items()}.get('connection', '') + excluded = HOP | {x.strip().lower() for x in connection.split(',')} + result = {k: v for k, v in headers.items() if k.lower() not in excluded} + result['Content-Length'] = str(length) + return result + + +def transport(upstream, method, path, headers, body): + url = urllib.parse.urlsplit(upstream) + cls = http.client.HTTPSConnection if url.scheme == 'https' else http.client.HTTPConnection + conn = cls(url.hostname, url.port, timeout=8) + try: + conn.request(method, path, body, forward_headers(headers, len(body))) + response = conn.getresponse() + return response.status, dict(response.getheaders()), response.read() + finally: + conn.close() + + +class Router: + def __init__(self, config, forward=transport, clock=time.monotonic, record=None): + self.config, self.forward, self.clock = config, forward, clock + self.mode, self.deadline, self.generation = 'PASS', 0, 0 + self.lock = threading.Lock() + self.record = record or (lambda row: None) + self.counts = {'forwarded': 0, 'faulted': 0} + customer = config['customerId'] + if not re.fullmatch(r'[1-9][0-9]{0,19}', customer): + raise ValueError('INVALID_CUSTOMER') + warehouse = config.get('receivingWarehouseId', '') + if not re.fullmatch(r'[1-9][0-9]{0,19}', warehouse): + raise ValueError('INVALID_RECEIVING_WAREHOUSE') + if not config.get('requestIds') or any(not re.fullmatch(r'[A-Za-z0-9_.:-]{1,128}', value) for value in config['requestIds']): + raise ValueError('BOUNDED_REQUEST_IDS_REQUIRED') + self.paths = {'IAM': '/internal/iam/tenants/' + customer + '/order-eligibility', + 'FINANCE': '/internal/finance/v1/wallet/accounts/' + customer, + 'WAREHOUSE': '/internal/warehouses/0/' + warehouse + '/active'} + + def status(self): + with self.lock: + if self.mode != 'PASS' and self.clock() >= self.deadline: + self.mode, self.deadline = 'PASS', 0 + return {'mode': self.mode, 'generation': self.generation, + 'remainingSeconds': max(0, self.deadline - self.clock()), **self.counts} + + def set_mode(self, token, mode, ttl=0): + if not hmac.compare_digest(str(token), self.config['controlToken']): + raise PermissionError('CONTROL_AUTH_REQUIRED') + with self.lock: + # Even an authenticated malformed command disarms first. + self.mode, self.deadline = 'PASS', 0 + if mode not in ('PASS', 'IAM', 'FINANCE', 'WAREHOUSE') or (mode != 'PASS' and + (type(ttl) is not int or not 1 <= ttl <= 60)): + raise ValueError('INVALID_MODE_OR_TTL') + self.mode = mode + self.deadline = self.clock() + ttl if mode != 'PASS' else 0 + self.generation += 1 + return self.status() + + def dispatch(self, method, path, headers, body, peer): + prefix, separator, rest = path.lstrip('/').partition('/') + dependency = {'iam': 'IAM', 'finance': 'FINANCE', 'warehouse': 'WAREHOUSE'}.get(prefix) + if not dependency or not separator: + return 404, {}, b'Unknown dependency route' + actual_path = '/' + rest + normalized = {k.lower(): v for k, v in headers.items()} + token = normalized.get('x-internal-service-token', '') + authenticated = bool(token) and hmac.compare_digest(token, self.config['internalToken']) + target = method == 'GET' and actual_path == self.paths[dependency] + request_id = normalized.get('x-request-id', '') + selected = target and peer in self.config['orderIps'] and authenticated and request_id in self.config['requestIds'] + mode = self.status()['mode'] + fault = selected and mode == dependency + # Only bounded correlation IDs and this customer's selected paths enter evidence. + safe_id = request_id if re.fullmatch(r'[A-Za-z0-9_.:-]{1,128}', request_id) else None + if fault: + result = (503, {'Content-Type': 'text/plain'}, b'TEST dependency temporarily unavailable') + else: + result = self.forward(self.config['upstreams'][dependency], method, actual_path, headers, body) + with self.lock: + self.counts['faulted' if fault else 'forwarded'] += 1 + if selected: + self.record({'requestId': safe_id, 'method': method, 'path': actual_path, + 'dependency': dependency, 'matched': fault, 'responseStatus': result[0], + 'forwarded': not fault, 'internalTokenPresent': True, + 'internalTokenValidated': True, 'mode': mode}) + return result + + +def main(path): + config_path = Path(path) + if config_path.is_symlink() or config_path.stat().st_mode & 0o077: + raise RuntimeError('PRIVATE_CONFIG_REQUIRED') + config = json.loads(config_path.read_text()) + evidence_path = config_path.parent / 'proxy-evidence.jsonl' + evidence_lock = threading.Lock() + + def record(row): + with evidence_lock: + fd = os.open(evidence_path, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600) + with os.fdopen(fd, 'w') as stream: + stream.write(json.dumps(row) + '\n'); stream.flush(); os.fsync(stream.fileno()) + + router = Router(config, record=record) + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + + def dispatch(self): + try: + length = int(self.headers.get('Content-Length', '0')) + if length < 0 or length > 2 * 1024 * 1024: + raise ValueError('BODY_LIMIT') + body = self.rfile.read(length) + if self.server.control: + token = self.headers.get('X-Order-Fault-Control', '') + if not hmac.compare_digest(token, config['controlToken']): + raise PermissionError('CONTROL_AUTH_REQUIRED') + if self.command == 'GET' and self.path == '/status': + value = router.status() + elif self.command == 'POST' and self.path == '/mode': + value = json.loads(body) + value = router.set_mode(token, value.get('mode'), value.get('ttl', 0)) + else: + raise ValueError('UNKNOWN_CONTROL') + status, headers, response = 200, {'Content-Type': 'application/json'}, json.dumps(value).encode() + else: + status, headers, response = router.dispatch(self.command, self.path, dict(self.headers), body, self.client_address[0]) + except PermissionError: + status, headers, response = 403, {}, b'Forbidden' + except (ValueError, KeyError): + if self.server.control and hmac.compare_digest(self.headers.get('X-Order-Fault-Control', ''), config['controlToken']): + router.set_mode(config['controlToken'], 'PASS') + status, headers, response = 400, {}, b'Invalid request' + except Exception: + status, headers, response = 502, {}, b'Upstream transport failed' + self.send_response(status) + for name, value in headers.items(): + if name.lower() not in HOP: + self.send_header(name, value) + self.send_header('Content-Length', str(len(response))); self.end_headers() + if self.command != 'HEAD': + self.wfile.write(response) + + do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = do_HEAD = do_OPTIONS = dispatch + + business = ThreadingHTTPServer(('0.0.0.0', config['proxyPort']), Handler); business.control = False + control = ThreadingHTTPServer(('127.0.0.1', config['controlPort']), Handler); control.control = True + (config_path.parent / 'proxy.pid').write_text(str(os.getpid())) + os.chmod(config_path.parent / 'proxy.pid', 0o600) + threading.Thread(target=business.serve_forever, daemon=True).start() + control.serve_forever() + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(); parser.add_argument('--config', required=True) + main(parser.parse_args().config) diff --git a/scripts/order-submit-dependency-fault-proxy.test.mjs b/scripts/order-submit-dependency-fault-proxy.test.mjs new file mode 100644 index 000000000..fccf049b0 --- /dev/null +++ b/scripts/order-submit-dependency-fault-proxy.test.mjs @@ -0,0 +1,87 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +test('offline proxy routing/authentication/TTL/transparency and redaction (no sockets or HTTP)', () => { + const script = String.raw` +import importlib.util,json,sys,unittest +spec=importlib.util.spec_from_file_location('proxy',sys.argv[1]);p=importlib.util.module_from_spec(spec);spec.loader.exec_module(p) +class ProxyTests(unittest.TestCase): + def setUp(self): + self.now=[10.0];self.calls=[];self.logs=[] + self.config={'customerId':'7','receivingWarehouseId':'30','controlToken':'private-control','internalToken':'private-internal-token', + 'orderIps':['172.20.0.10'],'requestIds':['fixture-request'],'upstreams':{'IAM':'http://iam.invalid:9201','FINANCE':'http://finance.invalid:9207','WAREHOUSE':'http://warehouse.invalid:9202'}} + def forward(*args): self.calls.append(args);return 201,{'X-Upstream':'kept'},b'original-response' + self.router=p.Router(self.config,forward=forward,clock=lambda:self.now[0],record=self.logs.append) + self.headers={'X-Internal-Service-Token':'private-internal-token','X-Request-Id':'fixture-request'} + self.path='/iam/internal/iam/tenants/7/order-eligibility' + def send(self,path=None,method='GET',headers=None,peer='172.20.0.10'): + return self.router.dispatch(method,path or self.path,headers or self.headers,b'original-body',peer) + def test_default_pass_and_transport_are_unchanged(self): + self.assertEqual(self.send(),(201,{'X-Upstream':'kept'},b'original-response')) + self.assertEqual(self.calls[0][2],'/internal/iam/tenants/7/order-eligibility') + self.assertEqual(self.calls[0][3]['X-Internal-Service-Token'],'private-internal-token') + self.assertEqual(self.calls[0][4],b'original-body') + def test_only_exact_authenticated_customer_get_from_order_faults(self): + self.router.set_mode('private-control','IAM',60) + self.assertEqual(self.send()[0],503);self.assertEqual(len(self.calls),0) + for path,method,headers,peer in [ + (self.path.replace('/7/','/8/'),'GET',self.headers,'172.20.0.10'), + (self.path+'?other=1','GET',self.headers,'172.20.0.10'), + (self.path,'POST',self.headers,'172.20.0.10'), + (self.path,'GET',{'X-Internal-Service-Token':'wrong'},'172.20.0.10'), + (self.path,'GET',{**self.headers,'X-Request-Id':'other-order-request'},'172.20.0.10'), + (self.path,'GET',self.headers,'172.20.0.11'), + ('/finance/internal/finance/v1/wallet/accounts/7','GET',self.headers,'172.20.0.10'), + ('/iam/internal/iam/finance-customers?page=1','GET',self.headers,'172.20.0.10')]: + self.assertEqual(self.send(path,method,headers,peer)[0],201) + def test_expiry_needs_no_control_request_and_returns_to_pass(self): + self.router.set_mode('private-control','FINANCE',5) + path='/finance/internal/finance/v1/wallet/accounts/7' + self.assertEqual(self.send(path)[0],503) + self.now[0]=15.0 + self.assertEqual(self.send(path)[0],201);self.assertEqual(self.router.status()['mode'],'PASS') + def test_public_warehouse_fault_is_bounded_by_exact_warehouse_request_and_order_identity(self): + self.router.set_mode('private-control','WAREHOUSE',60) + path='/warehouse/internal/warehouses/0/30/active' + self.assertEqual(self.send(path)[0],503) + for other,method,headers,peer in [ + (path.replace('/30/','/31/'),'GET',self.headers,'172.20.0.10'), + (path.replace('/0/','/7/'),'GET',self.headers,'172.20.0.10'), + (path+'?extra=1','GET',self.headers,'172.20.0.10'), + (path,'POST',self.headers,'172.20.0.10'), + (path,'GET',{**self.headers,'X-Request-Id':'another-customer-request'},'172.20.0.10'), + (path,'GET',{**self.headers,'X-Internal-Service-Token':'wrong'},'172.20.0.10'), + (path,'GET',self.headers,'172.20.0.11')]: + self.assertEqual(self.send(other,method,headers,peer)[0],201) + self.now[0]=70.0 + self.assertEqual(self.send(path)[0],201) + def test_invalid_receiving_warehouse_scope_is_rejected(self): + for value in ['0','../30','30?all=true','']: + with self.assertRaises(ValueError):p.Router({**self.config,'receivingWarehouseId':value}) + def test_control_auth_and_malformed_authenticated_command(self): + self.router.set_mode('private-control','IAM',10) + with self.assertRaises(PermissionError):self.router.set_mode('wrong','PASS') + for mode,ttl in [('UNKNOWN',10),('IAM',0),('IAM',61),('IAM',True)]: + with self.assertRaises(ValueError):self.router.set_mode('private-control',mode,ttl) + self.assertEqual(self.router.status()['mode'],'PASS') + def test_evidence_contains_no_secret_headers_or_payload(self): + self.router.set_mode('private-control','IAM',10);self.send() + serialized=json.dumps(self.logs) + for secret in ['private-control','private-internal-token','original-body']:self.assertNotIn(secret,serialized) + self.assertTrue(self.logs[0]['internalTokenValidated']);self.assertFalse(self.logs[0]['forwarded']) + self.assertEqual(self.logs[0]['requestId'],'fixture-request') + def test_hop_headers_removed_but_identity_preserved(self): + headers=p.forward_headers({'connection':'x-hop','x-hop':'drop','Host':'old','X-Internal-Service-Token':'keep','Content-Length':'99'},3) + self.assertNotIn('x-hop',headers);self.assertNotIn('Host',headers) + self.assertEqual(headers['X-Internal-Service-Token'],'keep');self.assertEqual(headers['Content-Length'],'3') +result=unittest.TextTestRunner().run(unittest.defaultTestLoader.loadTestsFromTestCase(ProxyTests)) +sys.exit(0 if result.wasSuccessful() else 1) +` + const result = spawnSync(process.env.PYTHON || 'python', ['-c', script, + fileURLToPath(new URL('./order-submit-dependency-fault-proxy.py', import.meta.url))], + { encoding:'utf8', env:{...process.env,PYTHONDONTWRITEBYTECODE:'1'} }) + assert.equal(result.status,0,result.stderr || result.error?.message) + assert.match(result.stderr,/Ran 8 tests/) +}) diff --git a/scripts/order-submit-dependency-fault-runtime.py b/scripts/order-submit-dependency-fault-runtime.py new file mode 100644 index 000000000..2ed481f55 --- /dev/null +++ b/scripts/order-submit-dependency-fault-runtime.py @@ -0,0 +1,478 @@ +"""Bounded remote TEST actions; injected by the JS executor, never invoked at import. + +Only Order is recreated. Every Docker mutation is journaled before sending; an +uncertain result blocks further mutations until explicit read-only reconciliation. +""" +import ast +import base64 +import copy +import hashlib +import http.client +import json +import os +import re +import secrets +import signal +import socket +import subprocess +import sys +import time +import urllib.parse +import urllib.request +import urllib.error +from pathlib import Path + +ORDER = 'firstleg-test-order' +ENTRYPOINT = ['sh', '-c', 'exec java $JAVA_OPTS org.springframework.boot.loader.launch.JarLauncher'] +PROPERTIES = {'IAM': 'baiyue.clients.iam.url', 'FINANCE': 'baiyue.clients.finance.url', + 'WAREHOUSE': 'baiyue.clients.warehouse.url'} +UPSTREAM_CONTAINERS = {'IAM': 'firstleg-test-iam', 'FINANCE': 'firstleg-test-finance', + 'WAREHOUSE': 'firstleg-test-warehouse'} + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *_args, **_kwargs): return None + + +def http_open(request, timeout): + return urllib.request.build_opener(NoRedirect()).open(request, timeout=timeout) + + +def common_tools(source): + """Reuse only Docker read/config reconstruction primitives, never event/SQL functions.""" + tree = ast.parse(source) + allowed = {'DockerConnection', 'docker', 'inspect', 'env_of', 'require_test', 'recreation', 'fingerprint'} + nodes = [node for node in tree.body if isinstance(node, (ast.Import, ast.ImportFrom)) + or isinstance(node, (ast.FunctionDef, ast.ClassDef)) and node.name in allowed] + namespace = {'__name__': 'order_dependency_primitives'} + exec(compile(ast.Module(body=nodes, type_ignores=[]), '', 'exec'), namespace) + return {name: namespace[name] for name in allowed} + + +def digest(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest() + + +def private_write(path, value): + temporary = path.with_name(path.name + '.next') + fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, 'w') as stream: + json.dump(value, stream); stream.flush(); os.fsync(stream.fileno()) + os.replace(temporary, path) + parent = os.open(path.parent, os.O_RDONLY) + try: os.fsync(parent) + finally: os.close(parent) + + +def validate_scope(scope): + if set(scope) != {'marker', 'customerId', 'receivingWarehouseId', 'orderIds', 'planSha256'} or not re.fullmatch(r'[A-Za-z0-9_-]{1,80}', scope['marker']): + raise RuntimeError('INVALID_SCOPE') + if not re.fullmatch(r'[1-9][0-9]{0,19}', scope['customerId']) or set(scope['orderIds']) != {'merchant', 'platform'}: + raise RuntimeError('INVALID_SCOPE') + if not re.fullmatch(r'[1-9][0-9]{0,19}', scope['receivingWarehouseId']): + raise RuntimeError('INVALID_RECEIVING_WAREHOUSE') + if any(not re.fullmatch(r'[1-9][0-9]{0,19}', value) for value in scope['orderIds'].values()) or len(set(scope['orderIds'].values())) != 2: + raise RuntimeError('TWO_DISTINCT_ORDERS_REQUIRED') + if not re.fullmatch(r'[0-9a-f]{64}', scope['planSha256']): raise RuntimeError('INVALID_PLAN_HASH') + + +class Runtime: + def __init__(self, scope, tools, base=Path('/tmp')): + validate_scope(scope) + self.scope, self.tools = scope, tools + self.directory = base / ('chongchong-order-dependency-' + scope['marker']) + self.path = self.directory / 'state.json' + self.lock = base / 'chongchong-order-dependency.lock' + + def save(self, state): private_write(self.path, state) + + def load(self): + if self.directory.is_symlink() or self.directory.stat().st_uid != os.getuid() or self.directory.stat().st_mode & 0o077: + raise RuntimeError('UNSAFE_STATE_DIRECTORY') + if self.path.is_symlink() or self.path.stat().st_mode & 0o077: raise RuntimeError('UNSAFE_STATE_FILE') + state = json.loads(self.path.read_text()) + if state['scope'] != self.scope: raise RuntimeError('SCOPE_CHANGED') + if (state.get('mutation') or {}).get('step', '').endswith('_INTENT'): + state['uncertain'] = True # Includes process death between HTTP completion and journal commit. + return state + + def container(self): + value = self.tools['inspect'](ORDER) + self.tools['require_test'](value, ORDER) + if self.tools['env_of'](value).get('DEPLOY_ENV') != 'test': raise RuntimeError('NOT_TEST_ENV') + return value + + def control(self, state, mode=None, ttl=0): + pid = state.get('proxyPid') + if not isinstance(pid, int) or pid <= 1: raise RuntimeError('PROXY_PID_NOT_READY') + process = Path('/proc') / str(pid) + if str(self.directory / 'proxy.py').encode() not in (process / 'cmdline').read_bytes().split(b'\0'): + raise RuntimeError('PROXY_PROCESS_OWNERSHIP_MISMATCH') + socket_inodes = set() + for descriptor in (process / 'fd').iterdir(): + try: target = os.readlink(descriptor) + except FileNotFoundError: continue + match = re.fullmatch(r'socket:\[(\d+)\]', target) + if match: socket_inodes.add(match.group(1)) + owned_ports = set() + for line in Path('/proc/net/tcp').read_text().splitlines()[1:]: + values = line.split() + if len(values) > 9 and values[3] == '0A' and values[9] in socket_inodes: + owned_ports.add(int(values[1].split(':')[1], 16)) + if not {state['proxyPort'], state['controlPort']}.issubset(owned_ports): raise RuntimeError('PROXY_LISTENERS_NOT_OWNED') + req = urllib.request.Request('http://127.0.0.1:' + str(state['controlPort']) + ('/mode' if mode else '/status'), + None if mode is None else json.dumps({'mode': mode, 'ttl': ttl}).encode(), + {'X-Order-Fault-Control': state['controlToken'], 'Content-Type': 'application/json'}) + with http_open(req, timeout=5) as response: return json.load(response) + + def probes(self, state): + """Real authenticated reads, not manufactured business responses. No body/secret is returned.""" + customer = self.scope['customerId']; results = {} + targets = {'IAM': '/internal/iam/tenants/' + customer + '/order-eligibility', + 'FINANCE': '/internal/finance/v1/wallet/accounts/' + customer, + 'WAREHOUSE': '/internal/warehouses/0/' + self.scope['receivingWarehouseId'] + '/active'} + for dependency, path in targets.items(): + req = urllib.request.Request(state['upstreams'][dependency] + path, + headers={'X-Internal-Service-Token': state['internalToken']}) + with http_open(req, timeout=8) as response: + value = json.load(response) + if response.status != 200 or str(value.get('code')) != '200': raise RuntimeError('UPSTREAM_PROBE_FAILED') + data = value.get('data') or {} + if dependency == 'IAM' and data.get('eligible') is not True: raise RuntimeError('CUSTOMER_NOT_ELIGIBLE') + if dependency == 'FINANCE' and (data.get('actualBalance') is None or float(data['actualBalance']) < 0): + raise RuntimeError('CUSTOMER_BALANCE_NOT_READY') + if dependency == 'WAREHOUSE': + warehouse = data.get('warehouse') or {} + if (str(warehouse.get('id')) != self.scope['receivingWarehouseId'] + or str(warehouse.get('tenantId')) != '0' or warehouse.get('status') != 'ACTIVE' + or warehouse.get('warehouseType') != 'DOMESTIC' or not warehouse.get('warehouseName')): + raise RuntimeError('RECEIVING_WAREHOUSE_NOT_READY') + results[dependency] = True + return results + + def config_fingerprint(self, container, routing): + """Read the two actual Nacos imports using the current v3 Admin endpoints. + No endpoint guessing/fallback and no Nacos write operation is supported. + """ + env = self.tools['env_of'](container); config = routing.get('nacos', {}) + base = urllib.parse.urlsplit(config.get('baseUrl', '')) + configured = urllib.parse.urlsplit(env.get('NACOS_SERVER_ADDR', '') if '://' in env.get('NACOS_SERVER_ADDR', '') + else 'http://' + env.get('NACOS_SERVER_ADDR', '')) + allowed_hosts = {configured.hostname, '127.0.0.1', 'localhost'} | { + network.get('Gateway') for network in container['NetworkSettings']['Networks'].values()} + if (base.scheme not in ('http', 'https') or base.hostname not in allowed_hosts or base.port != configured.port + or base.path not in ('', '/') or base.username or base.password or base.query or base.fragment + or config.get('loginPath') != '/nacos/v3/auth/user/login' + or config.get('configPath') != '/nacos/v3/admin/cs/config'): + raise RuntimeError('EXPLICIT_NACOS_READ_ENDPOINTS_REQUIRED') + if any(not env.get(key) for key in ['NACOS_USERNAME', 'NACOS_PASSWORD', 'NACOS_GROUP', 'NACOS_NAMESPACE']): + raise RuntimeError('NACOS_READ_IDENTITY_UNAVAILABLE') + origin = config['baseUrl'].rstrip('/') + request = urllib.request.Request(origin + config['loginPath'], urllib.parse.urlencode({ + 'username': env['NACOS_USERNAME'], 'password': env['NACOS_PASSWORD']}).encode(), + {'Content-Type': 'application/x-www-form-urlencoded'}) + with http_open(request, timeout=8) as response: token = json.load(response).get('accessToken') + if not isinstance(token, str) or not token: raise RuntimeError('NACOS_READ_LOGIN_FAILED') + fingerprints = {} + for data_id in ('baiyue-common.yml', 'order-service.yml'): + query = urllib.parse.urlencode({'dataId': data_id, 'groupName': env['NACOS_GROUP'], 'namespaceId': env['NACOS_NAMESPACE']}) + request = urllib.request.Request(origin + config['configPath'] + '?' + query, + headers={'Authorization': 'Bearer ' + token}) + with http_open(request, timeout=8) as response: + if response.status != 200: raise RuntimeError('NACOS_SOURCE_READ_FAILED') + value = json.load(response) + data = value.get('data') + if value.get('code') != 0 or not isinstance(data, dict): raise RuntimeError('NACOS_SOURCE_RESPONSE_INVALID') + if (data.get('dataId') != data_id or data.get('groupName') != env['NACOS_GROUP'] + or data.get('namespaceId') != env['NACOS_NAMESPACE']): raise RuntimeError('NACOS_SOURCE_IDENTITY_MISMATCH') + content = data.get('content') + if not isinstance(content, str) or not content.strip() or content.lstrip().startswith(('{', '<')): + raise RuntimeError('NACOS_SOURCE_NOT_YAML') + content = content.encode('utf-8') + fingerprints[data_id] = hashlib.sha256(content).hexdigest() + return digest({'imports': fingerprints, 'namespace': env['NACOS_NAMESPACE'], 'group': env['NACOS_GROUP']}) + + def prepare(self, config): + if self.directory.exists(): raise RuntimeError('STATE_EXISTS_USE_STATUS_OR_RESTORE') + original = self.container() + if original['Image'] != config.get('expectedImageId') or not re.fullmatch(r'sha256:[0-9a-f]{64}', original['Image']): + raise RuntimeError('ORIGINAL_IMAGE_MISMATCH') + if original['State'].get('Health', {}).get('Status') != 'healthy': raise RuntimeError('ORDER_NOT_HEALTHY') + if original['Config']['Entrypoint'] != ENTRYPOINT or original['Config'].get('Cmd') not in (None, []): + raise RuntimeError('UNREVIEWED_JAVA_ENTRYPOINT') + if 'host.docker.internal:host-gateway' not in (original['HostConfig'].get('ExtraHosts') or []): + raise RuntimeError('REVIEWED_PROXY_HOST_ROUTE_REQUIRED') + env = self.tools['env_of'](original) + token = env.get('INTERNAL_SERVICE_TOKEN', '') + if len(token) < 16: raise RuntimeError('INTERNAL_TOKEN_UNAVAILABLE') + routing = config.get('routing', {}) + if routing.get('configurationReviewed') is not True: + raise RuntimeError('UPSTREAM_CONFIGURATION_REVIEW_REQUIRED') + source_digest = self.config_fingerprint(original, routing) + if routing.get('sourceSha256') != source_digest: raise RuntimeError('REVIEWED_NACOS_SOURCE_DIGEST_CHANGED') + upstreams = routing.get('upstreams', {}) + if set(upstreams) != set(PROPERTIES): raise RuntimeError('EXPLICIT_UPSTREAMS_REQUIRED') + for dependency, upstream in upstreams.items(): + url = urllib.parse.urlsplit(upstream) + target = self.tools['inspect'](UPSTREAM_CONTAINERS[dependency]); self.tools['require_test'](target, UPSTREAM_CONTAINERS[dependency]) + target_env = self.tools['env_of'](target) + ips = {network['IPAddress'] for name, network in target['NetworkSettings']['Networks'].items() + if name in original['NetworkSettings']['Networks']} + if (url.scheme != 'http' or url.username or url.password or url.path not in ('', '/') or url.query or url.fragment + or url.hostname not in ips or str(url.port) != target_env.get('SERVER_PORT') + or target_env.get('DEPLOY_ENV') != 'test' or target['State'].get('Health', {}).get('Status') != 'healthy'): + raise RuntimeError('UPSTREAM_NOT_VERIFIED_TEST_CONTAINER') + lock_fd = os.open(self.lock, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(lock_fd, 'w') as stream: stream.write(self.scope['marker']); stream.flush(); os.fsync(stream.fileno()) + self.directory.mkdir(mode=0o700) + sockets = [socket.socket(), socket.socket()] + for sock in sockets: sock.bind(('127.0.0.1', 0)) + proxy_port, control_port = [sock.getsockname()[1] for sock in sockets] + for sock in sockets: sock.close() + state = {'scope': self.scope, 'phase': 'PREPARED', 'originalId': original['Id'], + 'originalImageId': original['Image'], 'originalFingerprint': self.tools['fingerprint'](original), + 'originalBody': self.tools['recreation'](original, env, full=True), 'internalToken': token, + 'proxyPort': proxy_port, 'controlPort': control_port, 'controlToken': secrets.token_urlsafe(32), + 'upstreams': {k: v.rstrip('/') for k, v in upstreams.items()}, 'routingSourceSha256': source_digest, 'routing': routing, + 'orderIps': [x['IPAddress'] for x in original['NetworkSettings']['Networks'].values()], 'mutation': None, 'proxyLaunchIntent': False} + self.save(state) # Recovery info exists before any process or container mutation. + self.probes(state) + proxy = self.directory / 'proxy.py'; proxy.write_bytes(base64.b64decode(config['proxySource'])); os.chmod(proxy, 0o600) + private_write(self.directory / 'proxy-config.json', {k: state[k] for k in + ['internalToken', 'controlToken', 'proxyPort', 'controlPort', 'upstreams', 'orderIps']} | {'customerId': self.scope['customerId'], + 'receivingWarehouseId': self.scope['receivingWarehouseId'], + 'requestIds': ['dep-' + self.scope['marker'] + '-' + dependency + '-' + role + for dependency in PROPERTIES for role in ('merchant', 'platform')]}) + state['phase'] = 'PROXY_START_INTENT'; state['proxyLaunchIntent'] = True; self.save(state) + with open(self.directory / 'proxy-output.log', 'ab') as output: + os.chmod(self.directory / 'proxy-output.log', 0o600) + process = subprocess.Popen(['python3', str(proxy), '--config', str(self.directory / 'proxy-config.json')], + stdout=output, stderr=output, start_new_session=True) + state['proxyPid'] = process.pid; state['phase'] = 'PROXY_STARTED'; self.save(state) + return self.summary(state) + + def summary(self, state): + return {'scope': self.scope, 'phase': state['phase'], 'originalImageId': state['originalImageId'], + 'originalFingerprint': state['originalFingerprint'], 'uncertain': bool(state.get('uncertain')), + 'mutationStep': (state.get('mutation') or {}).get('step'), 'restored': state.get('restored', False)} + + def mutation(self, state, step, method, path, body=None): + if state.get('uncertain'): raise RuntimeError('UNCERTAIN_MUTATION_REQUIRES_RECONCILE') + state['mutation'] = {'step': step, 'path': path, 'bodySha256': digest(body), 'intentAt': time.time()}; self.save(state) + try: + result = self.tools['docker'](method, path, body) + if step == 'CREATE_INTENT' and (not isinstance(result, dict) or not re.fullmatch(r'[A-Za-z0-9_-]{1,128}', result.get('Id', ''))): + raise RuntimeError('INVALID_CREATE_ACK') + except Exception: + state['uncertain'] = True; self.save(state) + raise RuntimeError('UNCERTAIN_DOCKER_MUTATION') + state['mutation']['step'] = step.replace('_INTENT', '_DONE') + if step == 'CREATE_INTENT': state['createdId'] = result['Id'] + self.save(state) + return result + + def replace(self, state, current, desired, purpose): + if state.get('uncertain'): raise RuntimeError('UNCERTAIN_MUTATION_REQUIRES_RECONCILE') + if self.container()['Id'] != current['Id']: raise RuntimeError('CONCURRENT_ORDER_CHANGE') + image = self.tools['docker']('GET', '/images/' + urllib.parse.quote(desired['Image'], safe='') + '/json') + if image['Id'] != state['originalImageId']: raise RuntimeError('IMAGE_TAG_MOVED_NO_RECREATE') + state['purpose'] = purpose; state['desiredBody'] = desired; state['replacingId'] = current['Id']; self.save(state) + if current['State']['Running']: + self.mutation(state, 'STOP_INTENT', 'POST', '/containers/' + current['Id'] + '/stop?t=15') + self.mutation(state, 'DELETE_INTENT', 'DELETE', '/containers/' + current['Id'] + '?v=false') + created = self.mutation(state, 'CREATE_INTENT', 'POST', '/containers/create?name=' + ORDER, desired) + self.mutation(state, 'START_INTENT', 'POST', '/containers/' + created['Id'] + '/start') + state['currentId'] = created['Id']; state['phase'] = purpose; state['mutation'] = None; self.save(state) + + def matches_injected(self, current, state): + expected = copy.deepcopy(state['originalBody']['Entrypoint']) + expected[2] += ''.join(' --' + prop + '=http://host.docker.internal:' + str(state['proxyPort']) + '/' + name.lower() + for name, prop in PROPERTIES.items()) + restored_view = copy.deepcopy(current) + restored_view['Config']['Entrypoint'] = state['originalBody']['Entrypoint'] + return (current['Image'] == state['originalImageId'] and current['Config']['Entrypoint'] == expected + and self.tools['fingerprint'](restored_view) == state['originalFingerprint']) + + def activate(self, state): + if state['phase'] != 'PROXY_STARTED' or state.get('uncertain'): raise RuntimeError('ACTIVATE_NOT_RETRYABLE') + if self.control(state)['mode'] != 'PASS': raise RuntimeError('PROXY_NOT_PASS') + current = self.container() + if current['Id'] != state['originalId'] or self.tools['fingerprint'](current) != state['originalFingerprint']: + raise RuntimeError('ORIGINAL_CONFIGURATION_CHANGED') + desired = copy.deepcopy(state['originalBody']) + desired['Entrypoint'][2] += ''.join(' --' + prop + '=http://host.docker.internal:' + str(state['proxyPort']) + '/' + name.lower() + for name, prop in PROPERTIES.items()) + self.replace(state, current, desired, 'INJECTED') + state['injectedFingerprint'] = self.tools['fingerprint'](self.container()); self.save(state) + return self.summary(state) + + def restore(self, state): + # PASS is attempted even if Docker state is ambiguous. Never stop the proxy here. + try: self.control(state, 'PASS') + except Exception: + state['passUnconfirmed'] = True; self.save(state) + if state.get('uncertain'): raise RuntimeError('UNCERTAIN_MUTATION_REQUIRES_RECONCILE') + if state['phase'] == 'ABSENT_CONFIRMED': + try: self.tools['inspect'](ORDER) + except RuntimeError as error: + if str(error) != 'DOCKER_404': raise + else: raise RuntimeError('ORDER_REAPPEARED_NO_RECREATE') + body = copy.deepcopy(state['originalBody']) + image = self.tools['docker']('GET', '/images/' + urllib.parse.quote(body['Image'], safe='') + '/json') + if image['Id'] != state['originalImageId']: raise RuntimeError('IMAGE_TAG_MOVED_NO_RECREATE') + state['purpose'] = 'ORIGINAL_RESTORED'; state['desiredBody'] = body; self.save(state) + created = self.mutation(state, 'CREATE_INTENT', 'POST', '/containers/create?name=' + ORDER, body) + self.mutation(state, 'START_INTENT', 'POST', '/containers/' + created['Id'] + '/start') + state['currentId'] = created['Id']; state['phase'] = 'ORIGINAL_RESTORED'; state['mutation'] = None; self.save(state) + return self.summary(state) + current = self.container() + if self.tools['fingerprint'](current) == state['originalFingerprint'] and current['Image'] == state['originalImageId']: + if not current['State']['Running']: + state['purpose'] = 'ORIGINAL_RESTORED'; state['createdId'] = current['Id']; self.save(state) + self.mutation(state, 'START_INTENT', 'POST', '/containers/' + current['Id'] + '/start') + state['phase'] = 'ORIGINAL_RESTORED'; self.save(state); return self.summary(state) + if current['Id'] != state.get('currentId') or not self.matches_injected(current, state): + raise RuntimeError('UNRECOGNIZED_ORDER_CONFIGURATION') + self.replace(state, current, copy.deepcopy(state['originalBody']), 'ORIGINAL_RESTORED') + return self.summary(state) + + def reconcile(self, state): + # This action is intentionally read-only with respect to Docker. It never resumes deletion/creation. + if not state.get('uncertain'): return self.summary(state) + try: current = self.container() + except RuntimeError as error: + if str(error) != 'DOCKER_404' or (state.get('mutation') or {}).get('step') != 'DELETE_INTENT': + raise RuntimeError('MANUAL_DOCKER_RECOVERY_REQUIRED_NO_BLIND_RECREATE') + try: self.tools['inspect'](state['replacingId']) + except RuntimeError as by_id: + if str(by_id) != 'DOCKER_404': raise + else: raise RuntimeError('ORIGINAL_ID_STILL_PRESENT') + state['uncertain'] = False; state['phase'] = 'ABSENT_CONFIRMED'; state['mutation'] = None; self.save(state) + return self.summary(state) + step = (state.get('mutation') or {}).get('step') + original = current['Image'] == state['originalImageId'] and self.tools['fingerprint'](current) == state['originalFingerprint'] + injected = self.matches_injected(current, state) + desired_matches = injected if state.get('purpose') == 'INJECTED' else original + if step == 'STOP_INTENT' and current['Id'] == state.get('replacingId') and not current['State']['Running'] and (original or injected): + phase = 'ORIGINAL_RESTORED' if original else 'INJECTED' + elif step == 'CREATE_INTENT' and current['Id'] != state.get('replacingId') and desired_matches: + phase = state['purpose'] + elif step == 'START_INTENT' and current['Id'] == state.get('createdId') and current['State']['Running'] and desired_matches: + phase = state['purpose'] + else: + raise RuntimeError('MANUAL_DOCKER_RECOVERY_REQUIRED_NO_BLIND_RECREATE') + state['uncertain'] = False; state['currentId'] = current['Id']; state['phase'] = phase; state['mutation'] = None + if phase == 'INJECTED': state['injectedFingerprint'] = self.tools['fingerprint'](current) + self.save(state); return self.summary(state) + + def verify_restored(self, state): + current = self.container() + if state.get('uncertain') or current['Image'] != state['originalImageId'] or self.tools['fingerprint'](current) != state['originalFingerprint']: + raise RuntimeError('RESTORED_CONFIG_OR_IMAGE_MISMATCH') + if current['State'].get('Health', {}).get('Status') != 'healthy': raise RuntimeError('RESTORED_NOT_HEALTHY') + if self.config_fingerprint(current, state['routing']) != state['routingSourceSha256']: + raise RuntimeError('NACOS_ROUTE_CONFIGURATION_CHANGED') + checks = self.probes(state) + if checks != {dependency: True for dependency in PROPERTIES}: raise RuntimeError('RESTORED_ROUTE_PROBE_INVALID') + state['routeReadsVerified'] = checks; self.save(state) + return {'healthy': True, 'originalFingerprint': state['originalFingerprint'], 'currentFingerprint': self.tools['fingerprint'](current), + 'originalImageId': state['originalImageId'], 'currentImageId': current['Image'], 'upstreamReads': checks, + 'nacosSourcesUnchanged': True} + + def finish(self, state, business): + self.verify_restored(state) + if self.lock.is_symlink() or self.lock.read_text() != self.scope['marker']: raise RuntimeError('RUNTIME_LOCK_OWNER_CHANGED') + if set(business or {}) != {'merchant', 'platform'} or any( + business[role].get('orderId') != order_id or business[role].get('status') != 'SUBMITTED' + or business[role].get('sameKeyReplayVerified') is not True for role, order_id in self.scope['orderIds'].items()): + raise RuntimeError('RESTORED_ORDER_ROUTES_NOT_PROVED') + pid = state.get('proxyPid') + if not pid and state.get('proxyLaunchIntent'): + pid_file = self.directory / 'proxy.pid' + if pid_file.is_symlink() or not pid_file.exists() or not pid_file.read_text().isdigit(): raise RuntimeError('PROXY_PID_UNCERTAIN') + pid = int(pid_file.read_text()) + command = Path('/proc/' + str(pid) + '/cmdline') if pid else None + if command and command.exists(): + if str(self.directory / 'proxy.py').encode() not in command.read_bytes().split(b'\0'): + raise RuntimeError('PROXY_PROCESS_OWNERSHIP_MISMATCH') + os.kill(pid, signal.SIGTERM) + for _ in range(40): + if not command.exists() or not command.read_bytes(): break + time.sleep(0.1) + else: raise RuntimeError('PROXY_NOT_STOPPED') + state['restored'] = True; state['phase'] = 'FINISHED' + # Recovery snapshots remain protected until completion; erase secrets only after verified restoration. + for field in ['originalBody', 'desiredBody', 'internalToken', 'controlToken']: state.pop(field, None) + self.save(state) + config = self.directory / 'proxy-config.json' + if config.exists(): config.unlink() + self.lock.unlink() + return {'restored': True, 'healthy': True, 'proxyStopped': True, 'originalImageId': state['originalImageId'], + 'originalFingerprint': state['originalFingerprint'], 'orderRoutesVerified': True} + + +def execute(config): + runtime = Runtime(config['scope'], common_tools(base64.b64decode(config['commonSource']).decode())) + action = config['action'] + if action == 'preflight': + current = runtime.container() + return {'originalImageId': current['Image'], 'originalFingerprint': runtime.tools['fingerprint'](current), + 'sourceSha256': runtime.config_fingerprint(current, config['routing']), 'runtimeChanged': False} + if action == 'prepare': return runtime.prepare(config) + state = runtime.load() + if action == 'status': + result = runtime.summary(state) + if not state.get('restored'): + try: result['proxy'] = runtime.control(state) + except Exception: result['proxy'] = {'unavailable': True} + return result + if state.get('restored'): raise RuntimeError('ALREADY_FINISHED_USE_STATUS') + if action not in ('records', 'health') and (runtime.lock.is_symlink() or runtime.lock.read_text() != runtime.scope['marker']): + raise RuntimeError('RUNTIME_LOCK_OWNER_CHANGED') + if action == 'records': + path = runtime.directory / 'proxy-evidence.jsonl' + return {'records': [json.loads(line) for line in path.read_text().splitlines()] if path.exists() else []} + if action == 'activate': return runtime.activate(state) + if action == 'mode': + if state['phase'] != 'INJECTED' or state.get('uncertain'): raise RuntimeError('FAULT_WINDOW_NOT_READY') + return runtime.control(state, config['mode'], config.get('ttl', 0)) + if action == 'health': return {'healthy': runtime.container()['State'].get('Health', {}).get('Status') == 'healthy'} + if action == 'restore': return runtime.restore(state) + if action == 'reconcile': return runtime.reconcile(state) + if action == 'verify-restored': return runtime.verify_restored(state) + if action == 'finish': return runtime.finish(state, config.get('businessRecovery')) + raise RuntimeError('UNKNOWN_ACTION') + + +def main(config): + # A lost Jenkins response must not allow a second process to race the first Docker mutation. + import fcntl + validate_scope(config['scope']) + path = Path('/tmp') / ('chongchong-order-dependency-' + config['scope']['marker'] + '.action.lock') + if path.is_symlink(): raise RuntimeError('UNSAFE_ACTION_LOCK') + fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o600) + try: + try: fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: raise RuntimeError('RUNTIME_ACTION_IN_PROGRESS_USE_STATUS_LATER') + return execute(config) + finally: os.close(fd) + + +def safe_error_code(error): + message = str(error) + if isinstance(error, urllib.error.HTTPError): + path = urllib.parse.urlsplit(error.geturl()).path + stage = 'NACOS_CONFIG' if path == '/nacos/v3/admin/cs/config' else ( + 'NACOS_LOGIN' if path == '/nacos/v3/auth/user/login' else 'DEPENDENCY_HTTP') + return stage + '_' + str(error.code) + if isinstance(error, urllib.error.URLError): + return 'DEPENDENCY_TRANSPORT_FAILED' + return message if re.fullmatch(r'[A-Z_0-9]{1,90}', message) else 'ORDER_DEPENDENCY_ACTION_FAILED' + + +if __name__ == '__main__': + try: + result = main(json.loads(base64.b64decode(sys.argv[1]))) + print('ORDER_DEPENDENCY_JSON=' + json.dumps({'ok': True, 'data': result})) + except Exception as error: + print('ORDER_DEPENDENCY_JSON=' + json.dumps({'ok': False, 'error': safe_error_code(error)})) diff --git a/scripts/overseas-deployment-contract.test.mjs b/scripts/overseas-deployment-contract.test.mjs index 33ad829cf..e4290d567 100644 --- a/scripts/overseas-deployment-contract.test.mjs +++ b/scripts/overseas-deployment-contract.test.mjs @@ -1,206 +1,53 @@ import assert from 'node:assert/strict' -import { readFile } from 'node:fs/promises' +import { readFileSync, existsSync, readdirSync } from 'node:fs' import test from 'node:test' +const read = p => readFileSync(new URL('../' + p, import.meta.url), 'utf8').replaceAll('\r\n','\n') +const exists = p => existsSync(new URL('../' + p, import.meta.url)) -const read = async (path) => (await readFile(new URL(`../${path}`, import.meta.url), 'utf8')) - .replaceAll('\r\n', '\n') - -test('local, development and test Nacos publishing remain environment-isolated', async () => { - const publisher = await read('scripts/publish-nacos-config.ps1') - const local = [ - await read('config/nacos/local/gateway-overseas.yml'), - await read('config/nacos/local/lastleg-service.yml'), - await read('config/nacos/local/warehouse-overseas-service.yml'), - ].join('\n') - const remote = [ - await read('config/nacos/overseas/gateway-overseas.yml'), - await read('config/nacos/overseas/lastleg-service.yml'), - await read('config/nacos/overseas/warehouse-overseas-service.yml'), - ].join('\n') - - assert.match(publisher, /ValidateSet\('local', 'dev', 'development', 'test', 'production'\)/) - assert.match(publisher, /必须显式指定非 public 的 Nacos namespace/) - assert.match(local, /127\.0\.0\.1:9100/) - assert.match(local, /127\.0\.0\.1:9303/) - assert.doesNotMatch(remote, /127\.0\.0\.1|password:\s*123456/) - assert.match(remote, /http:\/\/lastleg-service:9303/) - assert.match(remote, /\$\{LASTLEG_DB_URL\}/) - assert.match(remote, /\$\{WAREHOUSE_DB_URL\}/) +test('TEST has one complete Compose, namespace, session and no retired deployment entry', () => { + const compose=read('deploy/docker-compose.yml') + for(const service of ['gateway','auth','iam','firstleg','finance','order','warehouse','file','print','integration-service','lastleg-service','warehouse-overseas-service','audit-service']) assert.match(compose,new RegExp('^ '+service+':','m')) + assert.doesNotMatch(compose,/event-bridge|gateway-overseas|OVERSEAS_REDIS|OVERSEAS_NACOS|OVERSEAS_JWT|JWT_SECRET|test-cn|test-overseas/) + assert.match(compose,/SPRING_PROFILES_ACTIVE: \$\{DEPLOY_ENV:-test\}/) + assert.match(compose,/NACOS_NAMESPACE: \$\{NACOS_NAMESPACE:\?actual environment namespace ID required\}/) + assert.match(compose,/REDIS_DATABASE: \$\{REDIS_DATABASE:-0\}/) + assert.match(compose,/SPRING_DATA_REDIS_USERNAME: \$\{REDIS_USERNAME:-default\}/) + assert.match(compose,/JWT_JWKS_URI: http:\/\/auth:9100\/api\/auth\/\.well-known\/jwks.json/) + assert.equal([...compose.matchAll(/^ ports:/gm)].length,1) + assert.equal([...compose.matchAll(/^ JWT_RSA_JWK_SET:/gm)].length,1) + assert.equal([...compose.matchAll(/^ EVENT_OPERATIONS_TOKEN:/gm)].length,3) + assert.match(compose,/LASTLEG_SERVICE_URL: http:\/\/lastleg-service:9303/) + assert.match(compose,/FIRSTLEG_SERVICE_URL: http:\/\/firstleg:9202/) + assert.match(compose,/FINANCE_SERVICE_URL: http:\/\/finance:9203/) + assert.match(compose,/FILE_UPLOADS_HOST_DIR/) + assert.match(compose,/ROCKETMQ_NAME_SERVER/) + for(const p of ['deploy/docker-compose.overseas.yml','deploy/scripts/deploy-blue-green-service.sh','deploy/scripts/deploy-development-service.sh']) assert.equal(exists(p),false,p) }) - -test('Jenkins pipelines recognize all overseas deployable modules', async () => { - const controller = await read('Jenkinsfile') - const service = await read('Jenkinsfile.service') - for (const module of ['gateway-overseas', 'lastleg-service', 'warehouse-overseas-service']) { - assert.match(controller, new RegExp(`'${module}'`)) - assert.match(service, new RegExp(`'${module}'`)) - } - assert.match(controller, /'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS'/) - assert.match(controller, /'lastleg-service': 'SERVICE_LASTLEG'/) - assert.match(controller, /'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS'/) - assert.match(service, /gateway-overseas\/target\/gateway-overseas-1\.0\.0-SNAPSHOT\.jar/) - assert.match(service, /lastleg-service\/target\/lastleg-service-1\.0\.0-SNAPSHOT\.jar/) - assert.match(service, /warehouse-overseas-service\/target\/warehouse-overseas-service-1\.0\.0-SNAPSHOT\.jar/) - assert.doesNotMatch(controller, /overseas-security-service|SERVICE_OVERSEAS_SECURITY/) - assert.doesNotMatch(service, /overseas-security-service/) - assert.match(controller, /EVENT_BRIDGE_REGION/) - assert.match(service, /EVENT_BRIDGE_REGION_OVERRIDE=\$\{bridgeRegion\}/) - assert.match(controller, /choices: \['BOTH', 'CN', 'OVERSEAS'\]/) - assert.match(service, /choices: \['BOTH', 'CN', 'OVERSEAS', 'SELECT'\]/) - assert.doesNotMatch(controller, /事件桥 BOTH 仅支持开发或测试环境/) - assert.match(service, /部署生产事件桥实例:/) +test('every retained application imports TEST configuration with explicit namespace and no SQL auto init',()=>{ + const services=[...read('deploy/service-map.yml').matchAll(/^ ([a-z][a-z-]+):/gm)].map(m=>m[1]) + for(const service of services){ + const app=read(`${service}/src/main/resources/application.yml`) + assert.match(app,/nacos:baiyue-common.yml\?group=\$\{NACOS_GROUP:BAIYUE\}/) + assert.match(app,/\$\{NACOS_NAMESPACE\}/) + assert.doesNotMatch(app,/optional:nacos|NACOS_NAMESPACE:public|mode: always|EVENT_BRIDGE|JWT_SECRET|REDIS_PORT:6380/) + } + const common=read('config/nacos/test/baiyue-common.yml') + assert.match(common,/jwt-mode: jwks/) + assert.match(common,/session-mode: redis/) + assert.match(common,/session-validation-uri: \$\{AUTH_SESSION_VALIDATION_URL:http:\/\/auth:9100\/internal\/auth\/accounts\/sessions\/validate\}/) + assert.match(read('deploy/scripts/deploy-built-service.sh'),/Missing HEALTHCHECK; deployment failed/) }) - -test('service map freezes unique internal ports and moves event bridge away from 9300', async () => { - const map = await read('deploy/service-map.yml') - assert.match(map, /^ gateway-overseas:[\s\S]*?port: 9300$/m) - assert.match(map, /^ lastleg-service:[\s\S]*?port: 9303$/m) - assert.match(map, /^ warehouse-overseas-service:[\s\S]*?port: 9305$/m) - assert.match(map, /^ event-bridge-service:[\s\S]*?port: 9312$/m) - assert.match(map, /^ gateway-overseas:[\s\S]*?regions: \[OVERSEAS\]$/m) - assert.match(map, /^ lastleg-service:[\s\S]*?regions: \[OVERSEAS\]$/m) - assert.match(map, /^ warehouse-overseas-service:[\s\S]*?regions: \[OVERSEAS\]$/m) - assert.doesNotMatch(map, /overseas-security-service|9307/) - assert.match(map, /^ event-bridge-service:[\s\S]*?regions: \[CN, OVERSEAS\]$/m) -}) - -test('overseas compose is isolated from CN defaults and disables implicit schema execution', async () => { - const compose = await read('deploy/docker-compose.overseas.yml') - assert.match(compose, /^name: firstleg-\$\{DEPLOY_ENV:-test\}-overseas$/m) - assert.match(compose, /NACOS_USERNAME: \$\{OVERSEAS_NACOS_USERNAME\}/) - assert.match(compose, /NACOS_PASSWORD: \$\{OVERSEAS_NACOS_PASSWORD\}/) - assert.match(compose, /NACOS_NAMESPACE: \$\{OVERSEAS_NACOS_NAMESPACE:-prod-overseas\}/) - assert.match(compose, /REGION_CODE: OVERSEAS/) - assert.match(compose, /SPRING_SQL_INIT_MODE: never/) - assert.match(compose, /^ gateway-overseas:\s*$/m) - assert.match(compose, /^ lastleg-service:\s*$/m) - assert.match(compose, /^ warehouse-overseas-service:\s*$/m) - assert.match(compose, /^ event-bridge-service:\s*$/m) - assert.match(compose, /EVENT_BRIDGE_OVERSEAS_PUBLISHED_PORT:-\$\{EVENT_BRIDGE_OVERSEAS_PORT:-9313\}}:9312/) - assert.match(compose, /INTERNAL_SERVICE_TOKEN: \$\{INTERNAL_SERVICE_TOKEN\}/) - assert.match(compose, /LASTLEG_DB_URL: \$\{LASTLEG_DB_URL\}/) - assert.match(compose, /EVENT_BRIDGE_DB_URL: \$\{EVENT_BRIDGE_OVERSEAS_DB_URL\}/) - assert.match(compose, /EVENT_BRIDGE_DB_USERNAME: \$\{EVENT_BRIDGE_OVERSEAS_DB_USERNAME\}/) - assert.match(compose, /EVENT_BRIDGE_DB_PASSWORD: \$\{EVENT_BRIDGE_OVERSEAS_DB_PASSWORD\}/) - assert.match(compose, /LASTLEG_SECURITY_SERVICE_URL: \$\{LASTLEG_SECURITY_SERVICE_URL:-http:\/\/lastleg-service:9303\}/) - assert.match(compose, /LASTLEG_SECURITY_EVENT_CONSUMER_URL: \$\{LASTLEG_SECURITY_EVENT_CONSUMER_URL:-http:\/\/lastleg-service:9303\/internal\/lastleg\/security\/v1\/events\}/) - assert.doesNotMatch(compose, /overseas-security-service|9307|OVERSEAS_SECURITY_DB|OVERSEAS_SECURITY_SERVICE_URL|OVERSEAS_SECURITY_EVENT_CONSUMER_URL/) - assert.match(compose, /CN_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/) - assert.match(compose, /FIRSTLEG_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/) - assert.match(compose, /FILE_SERVICE_URL: \$\{FILE_SERVICE_URL\}/) - assert.doesNotMatch(compose, /DOMESTIC_FILE_SERVICE_URL/) - assert.doesNotMatch(compose, /CN_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_URL:/) - assert.match(compose, /aliases: \[event-bridge-overseas\]/) -}) - -test('CN and overseas event bridges have explicit opposite-region consumers', async () => { - const cnCompose = await read('deploy/docker-compose.yml') - const overseasCompose = await read('deploy/docker-compose.overseas.yml') - const cn = await read('config/nacos/cn/event-bridge-service.yml') - const overseas = await read('config/nacos/overseas/event-bridge-service.yml') - const firstleg = await read('config/nacos/cn/firstleg-service.yml') - const iam = await read('config/nacos/cn/iam-service.yml') - const iamApplication = await read('iam/src/main/resources/application.yml') - const iamClient = await read('iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java') - const lastleg = await read('config/nacos/overseas/lastleg-service.yml') - const gatewayOverseas = await read('config/nacos/overseas/gateway-overseas.yml') - const scripts = `${await read('deploy/scripts/deploy-built-service.sh')}\n${await read('deploy/scripts/deploy-services.sh')}` - assert.match(cn, /EVENT_CONSUMER_URL:http:\/\/firstleg:9202\/internal\/firstleg\/v1\/container-events/) - assert.match(overseas, /EVENT_CONSUMER_URL:http:\/\/lastleg-service:9303\/internal\/lastleg\/events/) - assert.match(cnCompose, /EVENT_CONSUMER_URL: \$\{FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:-http:\/\/firstleg:9202\/internal\/firstleg\/v1\/container-events\}/) - assert.match(cnCompose, /aliases: \[event-bridge-cn\]/) - assert.match(overseasCompose, /EVENT_CONSUMER_URL: \$\{EVENT_CONSUMER_URL:-http:\/\/lastleg-service:9303\/internal\/lastleg\/events\}/) - assert.match(firstleg, /LASTLEG_EVENT_BRIDGE_URL/) - assert.match(iamClient, /url = "\$\{baiyue\.security-event\.event-bridge-url:\}"/) - assert.match(iam, /SECURITY_EVENT_BRIDGE_URL:http:\/\/event-bridge-overseas:9312/) - assert.match(iamApplication, /SECURITY_EVENT_BRIDGE_URL:http:\/\/event-bridge-overseas:9312/) - assert.match(cnCompose, /SECURITY_EVENT_BRIDGE_URL: \$\{SECURITY_EVENT_BRIDGE_URL:-http:\/\/event-bridge-overseas:9312\}/) - assert.match(overseasCompose, /aliases: \[event-bridge-overseas\]/) - assert.match(firstleg, /LASTLEG_OUTBOX_RELAY_LEASE_SECONDS/) - assert.match(lastleg, /CN_EVENT_BRIDGE_URL:\$\{FIRSTLEG_EVENT_BRIDGE_URL\}/) - assert.match(lastleg, /WAREHOUSE_OVERSEAS_SERVICE_URL:http:\/\/warehouse-overseas-service:9305/) - assert.match(lastleg, /domestic-file:\s*\n\s*url: \$\{FILE_SERVICE_URL\}/) - assert.doesNotMatch(lastleg, /DOMESTIC_FILE_SERVICE_URL/) - assert.match(gatewayOverseas, /LASTLEG_SECURITY_SERVICE_URL:http:\/\/lastleg-service:9303/) - assert.match(overseas, /LASTLEG_SECURITY_EVENT_CONSUMER_URL:http:\/\/lastleg-service:9303\/internal\/lastleg\/security\/v1\/events/) - assert.doesNotMatch(`${gatewayOverseas}\n${overseas}`, /overseas-security-service|9307|OVERSEAS_SECURITY_/) - assert.match(scripts, /FIRSTLEG_EVENT_BRIDGE_URL/) - assert.match(scripts, /LASTLEG_EVENT_BRIDGE_URL/) - assert.match(scripts, /FILE_SERVICE_URL/) - assert.doesNotMatch(scripts, /DOMESTIC_FILE_SERVICE_URL/) - assert.match(scripts, /docker-compose\.overseas\.yml/) - assert.match(scripts, /REGION_CODE=CN 或 REGION_CODE=OVERSEAS/) - assert.match(scripts, /env_file_value REGION_CODE/) - assert.match(scripts, /EVENT_BRIDGE_REGION_OVERRIDE/) - assert.match(scripts, /FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL/) - assert.match(scripts, /COMPOSE_FILE="deploy\/docker-compose\.overseas\.yml"/) - assert.match(scripts, /PROJECT_NAME="firstleg-\$DEPLOY_ENV-overseas"/) - assert.match(scripts, /运行容器镜像与请求镜像不一致/) - assert.match(scripts, /REMOTE_ENV_NAME="\.env\.\$PROJECT_NAME"/) - assert.match(scripts, /REMOTE_COMPOSE_NAME="docker-compose\.\$PROJECT_NAME\.yml"/) - for (const key of [ - 'NACOS_SERVER_ADDR', 'NACOS_USERNAME', 'NACOS_PASSWORD', 'OVERSEAS_NACOS_NAMESPACE', 'INTERNAL_SERVICE_TOKEN', - 'JWT_ISSUER', 'OVERSEAS_JWT_AUDIENCE', 'JWT_JWK_SET_URI' - ]) assert.match(scripts, new RegExp(key)) -}) - -test('domestic Auth receives the overseas JWT signing configuration', async () => { - const compose = await read('deploy/docker-compose.yml') - for (const key of [ - 'OVERSEAS_JWT_ENABLED', 'OVERSEAS_JWT_ISSUER', 'OVERSEAS_JWT_AUDIENCE', - 'OVERSEAS_JWT_RSA_JWK_SET', 'OVERSEAS_JWT_CURRENT_KID', 'OVERSEAS_ACCESS_TOKEN_TTL' - ]) assert.match(compose, new RegExp(`${key}: \\$\\{${key}`)) -}) - -test('domestic event bridge SQL converges legacy tenant columns to the customer entity contract', async () => { - const baseline = await read('event-bridge-service/src/main/resources/db/event-bridge.sql') - const incremental = await read('deploy/sql/头程数据库统一增量-MySQL8.sql') - assert.equal((baseline.match(/customer_id VARCHAR\(128\) NOT NULL/g) || []).length, 2) - assert.equal((incremental.slice(0, incremental.indexOf('CREATE TABLE IF NOT EXISTS bridge_delivery_attempt')) - .match(/customer_id VARCHAR\(128\) NOT NULL/g) || []).length, 2) - assert.match(incremental, /ALTER TABLE bridge_inbox RENAME COLUMN tenant_id TO customer_id/) - assert.match(incremental, /ALTER TABLE bridge_outbox RENAME COLUMN tenant_id TO customer_id/) - assert.match(incremental, /bridge_inbox_customer_column = 0 AND @bridge_inbox_tenant_column = 1/) - assert.match(incremental, /bridge_outbox_customer_column = 0 AND @bridge_outbox_tenant_column = 1/) -}) - -test('both overseas PDF consumers explicitly connect to the existing CN Print service', async () => { - const compose = await read('deploy/docker-compose.overseas.yml') - for (const service of ['lastleg-service', 'warehouse-overseas-service']) { - const block = compose.match(new RegExp(`^ ${service}:\\n([\\s\\S]*?)(?=^ [a-z][\\w-]*:|^networks:)`, 'm'))?.[1] - assert.ok(block, `${service} must have a deployment definition`) - assert.match(block, /PRINT_SERVICE_URL: \$\{PRINT_SERVICE_URL:-http:\/\/print:9207\}/, - `${service} cannot discover the CN Print service from its overseas Nacos namespace`) - const config = await read(`${service}/src/main/resources/application.yml`) - assert.match(config, /print:\s*\n\s+url: \$\{PRINT_SERVICE_URL:\}/) - } -}) - -test('domestic unified SQL creates the overseas hold version before queuing its comment', async () => { - const incremental = await read('deploy/sql/头程数据库统一增量-MySQL8.sql') - const addColumn = incremental.indexOf('CALL add_inbound_overseas_hold_version();') - const queueComment = incremental.indexOf("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'last_overseas_hold_version'") - - assert.notEqual(addColumn, -1) - assert.notEqual(queueComment, -1) - assert.ok(addColumn < queueComment, - 'existing databases must add last_overseas_hold_version before the strict comment queue reads it') -}) - -test('overseas unified SQL initializes the isolated Event Bridge database before services start', async () => { - const overseas = await read('deploy/sql/海外区域统一增量-MySQL8.sql') - assert.match(overseas, /CREATE DATABASE IF NOT EXISTS baiyue_event_bridge_overseas/) - assert.match(overseas, /USE baiyue_event_bridge_overseas;/) - for (const table of ['bridge_outbox', 'bridge_inbox', 'bridge_delivery_attempt']) { - assert.match(overseas, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`)) - } - for (const index of [ - 'uk_bridge_outbox_idempotency', 'idx_bridge_outbox_schedule', - 'uk_bridge_inbox_idempotency', 'idx_bridge_inbox_consume', - 'uk_bridge_attempt', 'idx_bridge_attempt_event' - ]) assert.match(overseas, new RegExp(index)) - assert.match(overseas, /COUNT\(DISTINCT CONCAT\(table_name, ':', index_name\)\)/) - assert.match(overseas, /CALL assert_overseas_event_bridge_schema\(\);/) - assert.ok(overseas.indexOf('USE baiyue_event_bridge_overseas;') < overseas.indexOf('USE baiyue_lastleg;'), - 'overseas Event Bridge schema must be initialized before Lastleg tables') +test('Jenkins retains the unified topology across TEST and gray; Nacos import stays explicit',()=>{ + for(const p of ['Jenkinsfile','Jenkinsfile.service']){ + const s=read(p) + assert.doesNotMatch(s,/EVENT_BRIDGE|gateway-overseas|event-bridge-service|return 'development'|java-dev/) + assert.doesNotMatch(s,/maven.test.skip/) + } + assert.match(read('Jenkinsfile.service'),/node\('java-test'\)/) + assert.match(read('Jenkinsfile.service'),/ALLOW_SAME_CONTRACT_ROLLBACK', defaultValue: false/) + const publisher=read('scripts/publish-nacos-config.ps1') + assert.match(publisher,/ValidateSet\('test'\)/) + assert.match(publisher,/config\/nacos\/test/) + assert.match(publisher,/\[switch\]\$DryRun/) + assert.match(publisher,/accessToken/) }) diff --git a/scripts/overseas-relabel-fulfillment-acceptance.mjs b/scripts/overseas-relabel-fulfillment-acceptance.mjs index fa7bd677b..b23f8a9c1 100644 --- a/scripts/overseas-relabel-fulfillment-acceptance.mjs +++ b/scripts/overseas-relabel-fulfillment-acceptance.mjs @@ -1,4 +1,5 @@ import { readFile, writeFile } from 'node:fs/promises' +import { controlledPalletMeasurements } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' const domestic = process.env.ADMIN_TEST_GATEWAY || 'http://127.0.0.1:9000' const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300' @@ -50,14 +51,7 @@ const pdaToken = directPdaToken || pdaLogin.overseasAccessToken let task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`) let alreadyDeparted = task.status === 'DEPARTED' - || (task.status === 'COMPLETED' && String(task.departBlockReason || '').includes('已发车')) -if (!alreadyDeparted && task.status === 'PENDING') { - await api(overseas, platformToken, 'PUT', `/api/platform/v1/last-leg/dispatches/${dispatchNo}`, { - vehiclePlate: `UAT-${marker.slice(-8)}`, driverName: '尾程验收司机', driverPhone: '19900000000', - carrierName: '尾程验收车队', route: `${marker}-ROUTE`, - estimatedArrivalAt: new Date(Date.now() + 86400000).toISOString(), - }) -} +assert(alreadyDeparted || task.tripNo, 'Prepare and bind a qualified fleet trip before running retained dispatch fulfillment') const existingPage = await api(overseas, platformToken, 'GET', `/api/platform/v1/last-leg/delivery-orders?customerId=${customerId}&page=1&size=20&dispatchNo=${encodeURIComponent(dispatchNo)}`) const existingDeliveries = (existingPage.records || []).filter((value) => value.dispatchNo === dispatchNo) @@ -78,7 +72,6 @@ const transfer = existingDeliveries.length > 0 ? { idempotencyKey: key('TRANSFER'), plannedDeliveryAt: new Date(Date.now() + 172800000).toISOString(), deliveryMethod: 'TRUCK_PALLET', waybillNo: `${marker}-WAYBILL`, carrierName: '尾程验收车队', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '15托', - driverName: '尾程验收司机', driverPhone: '19900000000', vehiclePlate: `UAT-${marker.slice(-8)}`, appointmentAccount: 'SELF', remark: marker, fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费', direction: 'RECEIVABLE', currency: 'CNY', amount: 10, remark: marker }], }) @@ -93,14 +86,6 @@ const expectedPdaItems = scenario.partialRelabel ? 1 : 2 assert(Array.isArray(task.items) && task.items.length === expectedPdaItems, `Expected ${expectedPdaItems} PDA pallet items`) if (!alreadyDeparted) { - if (task.items.every(item => Number(item.pickedPackages) === 0)) { - await api(overseas, platformToken, 'PUT', `/api/platform/v1/last-leg/dispatches/${dispatchNo}`, { - vehiclePlate: `UAT-${marker.slice(-8)}`, driverName: '尾程验收司机', driverPhone: '19900000000', - carrierName: '尾程验收车队', fbaAppointmentNo: `UAT-${marker.slice(-8)}`, - fbaAppointmentStatus: 'CONFIRMED', vehicleAppointmentStatus: 'CONFIRMED', - estimatedArrivalAt: new Date(Date.now() + 172800000).toISOString().slice(0, 19), - }) - } for (const [itemIndex, item] of task.items.entries()) { let remaining = Number(item.scheduledPackages) - Number(item.pickedPackages) for (const [positionIndex, position] of item.positions.entries()) { @@ -118,7 +103,6 @@ if (!alreadyDeparted) { } task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`) alreadyDeparted = task.status === 'DEPARTED' - || (task.status === 'COMPLETED' && String(task.departBlockReason || '').includes('已发车')) } if (!alreadyDeparted && task.status !== 'COMPLETED') { const pickingCompleted = await api(overseas, pdaToken, 'POST', @@ -127,12 +111,21 @@ if (!alreadyDeparted && task.status !== 'COMPLETED') { task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`) } assert(alreadyDeparted || task.status === 'COMPLETED', 'Dispatch did not finish picking') -const departed = alreadyDeparted ? { - status: 'DEPARTED', - shippedPackages: task.items.reduce((total, item) => total + Number(item.pickedPackages), 0), -} : await api(overseas, pdaToken, 'POST', - `/api/pda/v1/last-leg/dispatches/${dispatchNo}/depart`, { idempotencyKey: key('DEPART'), remark: marker }) -assert(departed.status === 'DEPARTED', 'Dispatch did not reach DEPARTED') +if (!alreadyDeparted) { + const tripPath = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(task.tripNo)}` + const trip = await api(overseas, platformToken, 'GET', tripPath) + const loading = await api(overseas, platformToken, 'GET', `${tripPath}/loading`) + assert(!loading.blockReason, `Trip not ready: ${loading.blockReason}`) + const confirmed = await api(overseas, platformToken, 'POST', `${tripPath}/loading`, { + recordVersion: loading.recordVersion, manifestHash: loading.manifestHash, + pallets: controlledPalletMeasurements(loading.cargo, trip.trip.stops), reason: '受控测试测量,每实托100kg/1m³/1托位', + }) + const departed = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/dispatches/${dispatchNo}/depart`, + { recordVersion: confirmed.recordVersion, idempotencyKey: key('DEPART').slice(0,128), remark: marker }) + assert(departed.status === 'IN_TRANSIT' && departed.dispatches?.some(item => item.dispatchNo === dispatchNo), 'Whole-trip departure missing dispatch') +} +const departedDispatch = await api(overseas, platformToken, 'GET', `/api/platform/v1/last-leg/dispatches/${dispatchNo}`) +assert(departedDispatch.status === 'DEPARTED', 'Dispatch did not reach DEPARTED') const outbound = await api(overseas, platformToken, 'GET', `/api/platform/v1/last-leg/outbound-orders/${transfer.outboundNo}?page=1&size=20`) @@ -176,9 +169,9 @@ const visit = (visits.records || []).find((value) => value.deliveryNo === delive assert(visit?.status === 'PENDING', 'Delivery completion did not create a pending customer visit') const report = { marker, scope: 'OVERSEAS_LASTLEG_ONLY', sourceReport, relabelNo: scenario.relabelNo, - dispatchNo, dispatchStatus: departed.status, outboundNo: transfer.outboundNo, + dispatchNo, dispatchStatus: departedDispatch.status, outboundNo: transfer.outboundNo, outboundStatus: outbound.outbound.status, deliveryNo, - deliveryStatus: delivery.order.status, visitId: id(visit.id), shippedPackages: departed.shippedPackages, + deliveryStatus: delivery.order.status, visitId: id(visit.id), shippedPackages: departedDispatch.shippedPackageCount, passed: true, finishedAt: new Date().toISOString() } await writeFile(output, JSON.stringify(report, null, 2), 'utf8') console.log(JSON.stringify(report, null, 2)) diff --git a/scripts/partial-loading-actual-acceptance-plan.md b/scripts/partial-loading-actual-acceptance-plan.md new file mode 100644 index 000000000..9d208c72a --- /dev/null +++ b/scripts/partial-loading-actual-acceptance-plan.md @@ -0,0 +1,68 @@ +# F21/F22 实装与部分装柜验收方案 + +本文件是本阶段的执行方案,不是已通过的 TEST 证据或状态主文档。不直接运行,不访问环境。真实执行必须等总控确认匹配 Firstleg 版本、受控触发器和独占样本窗口;发布、SQL、数据库取证由总控串行负责。凭据只从本轮指定 TEST 环境文件读取,报告不保存密码、Token 或内部服务令牌。 + +## 入口与样本 + +复用 `admin-test-env-modules/phase3-container-loading.mjs` 的合法订单、审核、测量、分柜、RELEASED 订舱、真实柜号、PDA 领取及五类装柜证据链;复用 `phase3-container-customs-fixture.mjs` 的客户资格和报关入口。现有全拣场景不能直接替代本方案的 PARTIAL/SKIPPED 场景,也不能直接运行内部模块当作新验收入口。 + +使用新批次标记 `PARTIAL-ACTUAL-<运行时间>`、独立客户/订单/柜次/任务。一个任务单请求流,只操作本批次样本;已有柜 `TSTU8545690` 仅做只读回查,不回填计划、库存、入仓单或重放历史事件。失败停在原样本,保留原请求和失败报告,不盲目重造样本。 + +最小场景分两柜,沿合法流程分别准备: + +| 柜 | 来源与操作 | 验证目的 | +|---|---|---| +| A | 客户甲退税普通 PACKAGE 行计划 3 箱、每箱 7 件,实拣 2 箱并提交 PARTIAL;甲另一 SKU 零拣 SKIPPED;客户乙买单普通行全拣;客户丙退税整票零拣 SKIPPED | 混合客户/税制、部分数量缩放、同票跳过 SKU 与整客户跳过隔离 | +| B | 唯一实际买单 PACKAGE 行计划 2 箱、实拣 1 箱并提交 PARTIAL;同计划退税票全部 SKIPPED | 全部实际行均 PARTIAL 的完成预检,以及实际全买单柜不因计划历史退税票要求客户资料或退税发送 | + +从预装详情冻结每行 itemId、客户/来源/明细三键、原箱数、unitsPerCarton、productQuantity、palletCount、重量和体积。件数先按每箱件数×实箱;无每箱件数时按原量比例 HALF_UP 至整数。重量 HALF_UP 至 3 位、体积至 4 位;NULL 保持 NULL。托数按原计划托数比例向上取整。托数为空/0 的标签回退另核对原入仓总箱数、合法确认称重/库位证据,不能用本次实拣总数替换原比例分母。样本中的未知值只来自正式接口允许的输入,不以 SQL 修改事实制造样本。 + +## PDA 正式动作 + +接口基址为 `/api/platform/v1/first-leg/pda-loading`,按正式仓绑定会话访问。 + +1. 回读任务、库存和每行版本;通过既有正式扫码/拣货入口取得真实箱码事实。PARTIAL 为最终实箱数,不能把增量当最终数量。 +2. `POST /tasks/{taskId}/items/{itemId}/partial`:提交 `warehouseId, finalPickedPackageCount, version, operationId`。SKIPPED 使用 `POST /tasks/{taskId}/items/{itemId}/skip`,提交 `warehouseId, version, operationId, reason`,仅限零拣行。 +3. 上传并回查 PHOTO_STEP_1–4 与 SEAL 的五类有效照片和锁码;保留任务及计划全量基线、操作历史、预装冻结行。缺证据拒绝场景优先复用已有证据,不额外销毁照片。 +4. 在最终完成前回读标签,确认预检没有新增标签或占用冻结编号。内部数据库/序号核验由总控做只读取证;不存在公网独立预检写入口。 +5. 回读最新任务版本后,只提交一次 `POST /tasks/{taskId}/complete`,载荷 `expectedTaskVersion, operationId, remark`;保存 HTTP 状态、业务 code、requestId/traceId、完整载荷和命令身份,等待实际 COMPLETED,不把受理态当完成。 +6. 完成后原 operationId、原 expectedTaskVersion、原载荷重放,确认返回同一业务结果;另一独立 operationId 不作为原请求重放。 + +## 完成后的独立核对 + +| 对象 | 必须成立的断言 | +|---|---| +| 完成清单 | `/api/platform/v1/first-leg/loading-lists/{planId}?warehouseId=...` 的行集合严格等于有效实拣集合;PARTIAL 行已结算为实箱,SKIPPED/零拣不出现;列表与详情汇总一致 | +| 计划历史/PDA | 计划历史允许保留 SKIPPED;任务保留真实实拣及原计划数量。不能以原计划总箱数与实装箱数相等作为完成断言 | +| 部分指标 | 原 3 箱 21 件变为实 2 箱 14 件;重量/体积按冻结行缩放。例:10.001/8.002/6.004 kg、0.1001 cbm 对应 6.667/5.335/4.003 kg、0.0667 cbm;实际期望以正式冻结值为准 | +| 来源与余量 | 实箱+余量=原可用箱数;余量沿正式分片产生,原订单/入仓编号及 lineage 可追溯。重放后版本、历史、资源结算和分片数量不重复推进 | +| 标签/事件 | 预测与最终冻结托盘码/托数一致,预检零标签写入;最终 V3 只携带各客户实际货物。总控只读核对唯一 Outbox、载荷摘要、ACK 与接收结果,不伪造事件 | +| 报关平台 | `GET /api/platform/v1/customs/loading-plans/{planId}` 的 actualBoxCount 等于实拣之和;退税票/客户范围、资料覆盖要求排除整票跳过;已实装票缺资料仍拒绝开始 | +| 客户资料 | `/api/merchant/v1/customs/orders/{orderId}`:甲只能见自身已实装退税来源;丙整票跳过没有该柜资料入口;乙买单不会获得退税客户入口;外客户拒绝保持原 code 与诊断字段 | +| 买单导出 | 复用正式导出服务,只含乙实际行,不含跳过的买单行;记录分页最大ID、总数、输出行集合与字节摘要,不能只核对 XLSX 魔数 | +| 全买单 B 柜 | 跳过退税不阻断全买单报关开始/完成;拒绝创建退税申报/发送。报关完成后的自动离仓必须满足既有正式前置,不能用本方案绕过 | +| 下游 | Lastleg/入仓计划/在途库存独立读回与事件实际数一致;禁止把正确下游改成原计划总箱数;原柜只读回查保持下游完整快照不变 | + +## 负向预检与证据界限 + +跨客户第二来源缺客户/目的仓快照、非法 PARTIAL、未结算最终发布等服务内非法事实,使用 `LoadingCompletedPreflightDatabaseTest` 的真实 Mapper 场景核验;不得通过 TEST SQL 捏造非法业务数据。有效新样本的跨客户正常完成必须走上述真实 HTTP。H2 对 MySQL UPDATE JOIN 的语法适配与 pendingAutoDispatch 关联替换均需在报告中披露;真实 MySQL 触发器与最终完成另验。 + +证据建议落在 `reports/partial-loading-actual-<批次>/`:版本/健康与触发器就绪证明、请求序列、before/after 全量快照、逐断言结果、事件/接收端只读取证、失败原件、会话退出结果。执行结束退出自己的全部会话,不删除数据或恢复他人资源。HTTP/DOM证据与人工1920×1080、100%缩放及PDA设备验收分别报告,不据服务测试宣布页面或全流程完成。 + +## 最小可执行入口及交给总控的 README 登记文本 + +下列段落应由总控集成到 `scripts/README.md`;本任务不修改共享索引。 + +> `partial-loading-actual-test-env.mjs` 是 F21/F22 两柜实装与部分装柜 TEST 专项入口,默认无参数或 `--plan` 零网络。执行必须在测试后端工作区、使用脚本前的 `node --env-file=<用户指定目录的test-acceptance.env>`,并传入 `--execute --environment=TEST --run-id=PARTIAL-ACTUAL-<新批次> --fixture=NEW`,仅在总控确认匹配 Firstleg 与受控触发器、开放新样本窗口后运行。NEW 模式复用既有资格/订单/测量合法模块,仅准备五个隔离客户/订单与两柜;A 为退税 3→2 PARTIAL、同来源 SKU SKIP、另一客户买单全拣及整客户退税 SKIP,B 为唯一实际买单 2→1 PARTIAL 且退税全部 SKIP。也支持 `--fixture=` 消费本轮通过的 `manual-pda-batch-fixture` 报告,所有动作覆盖每条 PDA 明细,限定全新未领取、零拣货且跨柜不共享来源的样本。入口执行领取、原子 batch、五类证据、唯一完成、实装数量/件数/重量/体积和报关范围、原完成命令重放零漂移,以及 B 全买单报关开始/完成和退税发送拒绝;NEW 模式照片为明确标记的合成 HTTP 夹具,不是现场照片或真机证据。报告 `reports/partial-loading-actual-PARTIAL-ACTUAL-<新批次>/report.json` 使用独占目录,先保存原请求/幂等意图,凭据脱敏、单请求流;失败保留原样本,未知传输结果阻断后续命令,不自动重造、覆盖、续跑或重放历史事件。退出自己的所有会话;不操作旧客户库存、SQL、部署或船节点。B 全买单报关完成后停在合法当前状态供总控复用,不能自行推进进港、提柜或卸柜。此入口不展开 F01–F40 全量,不代替事件 ACK、下游库存、完整退税资料/XLSX及人工页面/PDA证据。直接离线测试为 `partial-loading-actual-test-env.test.mjs`。 + +当前可执行最小范围与上文完整方案分开:两柜完成/报关范围有可执行断言;事件、下游、客户资料隔离和 XLSX 必须另取实际证据,不将方案中的未执行项写成通过。 + +### 原样本恢复与准备检查点续接(追加 README 文本) + +> 本入口支持本轮明确授权的 `--fixture=RESUME:<原失败report.json>`:核对冻结 `blocked-readback.json` 的原客户、订单、入仓码、测量批次、版本及无称重基线,将旧报告/回读 SHA 写入新的恢复目录;不再资格、创建客户或下单。测量直接从原草稿续接上传、独立 SKU 明细、称重确认、提交和审核,不运行“首次扫描新增”夹具断言。multipart 适配支持文件描述对象与现成 FormData,文件字节保留、Content-Type boundary 由 fetch 生成;直接测试使用本地真实 HTTP 接收原始字节。`--fixture=PREPARED:<本轮两柜准备report.json>` 只续接这次原来源形成的两柜零拣货检查点,不重新创建任何客户、订单或柜;任务可为未分配,或已分配给当前独立 PDA 会话的 IN_PROGRESS,仍须冻结版本、全部 PENDING/零实拣及无外部作业记录。任务已发生实拣或完成后不得用此模式重跑。同来源 SKU 的零拣跳过先走正式逐明细 SKIP,再以刷新后的版本和库存快照一次 batch 处理全部剩余待拣;不能把 SKIP 与同来源的实拣放在同一 batch,也不能提交只含部分待拣明细的 batch。原完成失败必须保留命令和失败状态,生产修复与原命令恢复由总控另行协调,不自动重新造柜、改业务事实或继续人工船节点。 + +### A 原完成命令恢复与纯读取证(追加 README 文本) + +> `partial-loading-actual-test-env.mjs` 的 `--fixture=COMPLETION:` 模式仅恢复原 A command `2106716718935052290`,先从旧报告校验唯一已受理请求和准备报告 SHA;必须同时给出 `--ready-commit=ea9732749a9215e660530035c40afc69fe56a5ae`,且已收到总控匹配 Firstleg 就绪及恢复授权。请求体严格固定为 `expectedTaskVersion=2`、`operationId=PARTIAL-ACTUAL-20261004-recovery-r3-A-complete`、`remark=PARTIAL-ACTUAL-20261004-recovery-r3-A`,不使用失败后新 task version 创建另一命令。先保存两旧 LOADED owner、全跳来源及源分配数量/绑定快照,再只 POST 一次正式 complete;遇未知结果仅回查并停止,不盲重试。完成后对比旧来源版本/历史/源分配快照、正实拣集合、清单/报关实际数、Lastleg 事件建单历史和初始在途余额/流水;全部跳过来源不得 LOADED 或获得尾程/库存。B 在此模式明确暂缓,不做资格、下单、拣货、海运节点或 SQL。 +> +> 若原命令已完成,但后续取证失败,使用新的报告目录和 `--fixture=VERIFY_A:<原COMPLETION执行report.json>` 纯读模式接续,仍带相同 ready commit;该模式只允许 GET 及自有登录/退出,不再 complete。库存列表是按维度聚合,入仓归属字段为空时必须沿 `/inventory/balances/{代表id}` 详情读取真实归属;`totalPackages` 表示在仓桶,在途数核对 `inTransitPackages`,不能把在途要求加入在仓总数。公开消费建单/流水事实只证明实装事件结果,不代替 Firstleg Outbox 发布状态、ACK/Inbox SQL 审计;精确 SQL 交总控串行读取。原失败和所有恢复/读取报告均保留,不覆盖。 diff --git a/scripts/partial-loading-actual-test-env.mjs b/scripts/partial-loading-actual-test-env.mjs new file mode 100644 index 000000000..5a4e81e51 --- /dev/null +++ b/scripts/partial-loading-actual-test-env.mjs @@ -0,0 +1,975 @@ +import assert from 'node:assert/strict' +import { readFile, mkdir, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { createHash } from 'node:crypto' +import { stableSnapshot } from './admin-test-env-modules/container-customs-acceptance-matrix.mjs' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const PDA = '/api/platform/v1/first-leg/pda-loading' +const LEG = '/api/platform/v1/first-leg' +const CUSTOMS = '/api/platform/v1/customs/loading-plans' +const TYPES = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL'] +const id = value => String(value ?? '') +const positive = value => typeof value === 'string' && /^[1-9]\d*$/.test(value) +const rows = value => Array.isArray(value) ? value : [] +const sha = value => createHash('sha256').update(value).digest('hex') +class CheckFailure extends Error {} +function need(condition, message) { if (!condition) throw new CheckFailure(message) } + +export function plan() { + return { plan: true, networkRequests: 0, businessWrites: false, + cases: ['A: refund PARTIAL 3→2 + same-owner SKIP + buy PICK + skipped refund customer', + 'B: only actual buy row PARTIAL 2→1 + all refund rows SKIP; all actual rows PARTIAL'], + upstream: 'Use --fixture=NEW to prepare isolated customers/orders/measurements and two confirmed cabinets, or consume two passing manual-pda-batch-fixture reports', + operations: ['validate both unclaimed tasks before writes', 'claim', 'one atomic batch per task', + 'upload five supplied image files', 'complete and poll', 'actual list/metrics/customs scope', + 'same original completion replay and zero drift', 'all-buy start/complete and refund-send rejection', 'logout own sessions'], + excluded: ['F01–F40 full regression', 'SQL/deployment', 'historical events or data repair', + 'browser/device proof', 'Outbox/ACK/downstream inventory evidence', 'refund document workflow and XLSX download'], + manifest: { schemaVersion: 1, runId: 'PARTIAL-ACTUAL-', environment: 'TEST', + cases: ['A', 'B'].map(kind => ({ kind, sourceReport: '', + actions: [{ itemId: '', action: 'PARTIAL', finalPickedPackageCount: kind === 'A' ? 2 : 1 }], + evidence: Object.fromEntries(TYPES.map(type => [type, ''])), sealNo: '' })) }, + execution: 'node --env-file= scripts/partial-loading-actual-test-env.mjs --execute --environment=TEST --run-id= --fixture=' } +} + +export function executionOptions(args, execArgs, env, cwd = process.cwd()) { + if (!args.length || args.length === 1 && args[0] === '--plan') return { execute: false } + need([4,5].includes(args.length) && args[0] === '--execute' && args[1] === '--environment=TEST' + && /^--run-id=PARTIAL-ACTUAL-[A-Za-z0-9_-]{1,40}$/.test(args[2]) + && args[3].startsWith('--fixture=') && args[3].slice(10), 'Explicit TEST, new PARTIAL-ACTUAL run-id and fixture required') + need(resolve(cwd).replaceAll('\\', '/').toLowerCase() === ROOT.replaceAll('\\', '/').toLowerCase(), 'Run in the TEST backend workspace') + need(execArgs.length === 1 && execArgs[0].startsWith('--env-file=') + && resolve(execArgs[0].slice(11)).replaceAll('\\', '/').endsWith('/.codex/env/chongchong-global/test-acceptance.env'), 'Use the authorized TEST env-file') + need(String(env.CHONGCHONG_ENV).toUpperCase() === 'TEST', 'TEST profile required') + for (const key of ['CHONGCHONG_ADMIN_API_BASE_URL', 'CHONGCHONG_ADMIN_USERNAME', 'CHONGCHONG_ADMIN_PASSWORD', 'ADMIN_PDA_USERNAME', 'ADMIN_PDA_PASSWORD']) need(Boolean(env[key]), `Missing ${key}`) + const base = new URL(env.CHONGCHONG_ADMIN_API_BASE_URL) + need(['https:', 'http:'].includes(base.protocol) && !base.username && !base.password + && base.pathname === '/api' && !base.search && !base.hash, 'Configured TEST admin API base must end in /api') + const input = args[3].slice(10) + const completion = input.startsWith('COMPLETION:') || input.startsWith('VERIFY_A:') || input.startsWith('B_ONLY:') + need(completion ? args[4] === '--ready-commit=ea9732749a9215e660530035c40afc69fe56a5ae' : args.length === 4, + 'Original-command recovery requires explicit matching controller-ready commit') + return { execute: true, runId: args[2].slice(9), fixture: input === 'NEW' ? 'NEW' : input.startsWith('RESUME:') + ? { resumeReport: resolve(input.slice(7)) } : input.startsWith('PREPARED:') + ? { preparedReport: resolve(input.slice(9)) } : input.startsWith('B_ONLY:') ? { bOnlyReport: resolve(input.slice(7)) } + : input.startsWith('VERIFY_A:') ? { verifyAReport: resolve(input.slice(9)) } + : completion ? { completionReport: resolve(input.slice(11)) } : resolve(input), + gateway: base.origin, ...(completion ? { readyCommit: args[4].slice(15) } : {}) } +} + +export function originalCompletionCommand(failedReport) { + need(failedReport.runId === 'PARTIAL-ACTUAL-20261004-recovery-r7' && failedReport.success === false, 'Bind the original A failed execution') + const attempts = rows(failedReport.requests).filter(item => item.method === 'POST' + && item.path === `${PDA}/tasks/2106713892758491138/complete`) + need(attempts.length === 1 && attempts[0].state === 'RECEIVED' && attempts[0].http === 200 && attempts[0].code === '200', 'Exactly one accepted original completion command required') + const body = attempts[0].request + assert.deepEqual(body, { expectedTaskVersion: 2, operationId: 'PARTIAL-ACTUAL-20261004-recovery-r3-A-complete', remark: 'PARTIAL-ACTUAL-20261004-recovery-r3-A' }, 'Original immutable completion payload') + const command = attempts[0].response?.data?.completionCommand + need(id(command?.id) === '2106716718935052290' && command.operationId === body.operationId && Number(command.expectedTaskVersion) === 2, + 'Original command identity/version mismatch') + return { commandId: id(command.id), body: structuredClone(body) } +} + +const dataOf = response => { + need(response.http === 200 && String(response.body?.code) === '200', `API rejected ${response.path}: HTTP=${response.http}, code=${response.body?.code ?? 'missing'}`) + return response.body.data +} +const selected = (value, names) => Object.fromEntries(names.map(name => [name, value?.[name] ?? null])) +export function stableSource(source) { + return { summary: selected(source.summary, ['id','tenantId','orderId','orderNo','inboundCode','processStatus','version','packageCount','totalPackageCount','splitStatus','actualGrossWeightKg','actualVolumeCbm']), + statusHistory: source.statusHistory, palletLocations: source.palletLocations } +} + +export async function sourceSnapshot(spec, request) { + const result = [] + for (const owner of spec.owners) result.push({ owner, view: dataOf(await request('GET', `/api/platform/inbounds/${owner.inboundId}?tenantId=${owner.tenantId}`)) }) + // Failed/completed tasks are not an editable inventory-snapshot surface. The + // source detail API exposes the persisted block/slot/reservation and allocation + // quantities needed to prove these already-completed owners do not settle twice. + return { sources: result, inventory: { allocations: result.map(item => ({ tenantId: item.owner.tenantId, + inboundId: item.owner.inboundId, palletLocations: item.view.palletLocations })) } } +} + +export function verifyOldOwnersUnchanged(before, after, expected) { + for (const item of before.sources) { + const current = after.sources.find(row => row.owner.inboundId === item.owner.inboundId) + need(current, 'Original source disappeared from readback') + assert.equal(stableSnapshot(stableSource(current.view)), stableSnapshot(stableSource(item.view)), 'Original owner facts/version/history/resources must not repeat') + const actual = expected.some(row => row.inboundId === item.owner.inboundId && row.actual > 0) + need(actual ? current.view.summary.processStatus === 'LOADED' : current.view.summary.processStatus !== 'LOADED', 'Whole skipped source cannot become LOADED') + } + assert.equal(stableSnapshot(after.inventory), stableSnapshot(before.inventory), 'Original domestic inventory must not be deducted again') +} + +export function verifyPhysicalFacts(expected, task) { + const actual = rows(task.items).filter(row => ['PICKED','PARTIAL'].includes(row.itemStatus) && Number(row.pickedPackageCount) > 0) + const expectedActual = expected.filter(row => row.actual > 0) + assert.deepEqual(actual.map(row => id(row.loadingPlanItemId)).sort(), expectedActual.map(row => row.itemId).sort(), 'Original physical item set') + for (const item of expectedActual) { + const row = actual.find(row => id(row.loadingPlanItemId) === item.itemId) + need(Number(row.pickedPackageCount) === item.actual && id(row.tenantId) === item.accountId && id(row.inboundId) === item.inboundId, + 'Original physical count/customer/source drift') + } +} + +async function readSmallPage(path, request) { + const page = dataOf(await request('GET', path)) + need(Array.isArray(page.records) && Number(page.page) === 1 && Number(page.size) === 200 + && Number(page.total) === page.records.length && new Set(page.records.map(row => id(row.id))).size === page.records.length, + 'Bounded new-sample page incomplete or duplicated') + return page.records +} + +export async function downstreamSnapshot(spec, request) { + const result = [] + for (const owner of spec.owners) { + const query = new URLSearchParams({ customerId: owner.tenantId, containerNo: spec.source.containerNo, page: '1', size: '200' }) + const orders = await readSmallPage(`/api/platform/v1/last-leg/orders?${query}`, request) + const code = spec.expected.find(row => row.inboundId === owner.inboundId)?.frozen.inboundCode + need(code, 'Frozen original inbound code required for inventory scope') + const inventory = new URLSearchParams({ customerId: owner.tenantId, inboundCode: code, page: '1', size: '200' }) + const groupedBalances = await readSmallPage(`/api/platform/v1/last-leg/inventory/balances?${inventory}`, request) + const balances = [] + for (const row of groupedBalances) balances.push(dataOf(await request('GET', `/api/platform/v1/last-leg/inventory/balances/${row.id}?customerId=${owner.tenantId}`))) + const ledger = await readSmallPage(`/api/platform/v1/last-leg/inventory/ledger?${inventory}`, request) + const details = [] + for (const order of orders) details.push(dataOf(await request('GET', `/api/platform/v1/last-leg/orders/${order.id}?customerId=${owner.tenantId}`))) + result.push({ owner, orders, balances, groupedBalances, ledger, details }) + } + return result +} + +export function verifyDownstream(spec, snapshot) { + let packages = 0 + for (const item of snapshot) { + const actualRows = spec.expected.filter(row => row.inboundId === item.owner.inboundId && row.actual > 0) + const expected = actualRows.reduce((sum, row) => sum + row.actual, 0) + if (!expected) { + need(!item.orders.length && !item.balances.length && !item.ledger.length, 'Skipped owner must have no tail cargo or transit inventory') + continue + } + need(item.orders.length === 1 && item.details.length === 1, 'Exactly one consumer-created tail order per actual source') + const order = item.orders[0], detail = item.details[0] + need(id(order.customerId) === item.owner.tenantId && id(order.sourceInboundId) === item.owner.inboundId + && id(order.sourceContainerBookingId) === spec.source.containerBookingId && order.containerNo === spec.source.containerNo + && Number(order.packageCount) === expected, 'Tail materialization must use exact actual customer/source/container boxes') + need(rows(detail.statusHistory).filter(row => row.toStatus === 'PENDING_ASSIGNMENT' && row.remark === '头程装柜完成自动生成').length === 1, + 'Exactly one actual loading-event creation history') + need(rows(detail.cargo).length === actualRows.length, 'Skipped SKU cannot enter frozen tail cargo') + for (const row of actualRows) { + const cargo = detail.cargo.find(cargo => cargo.skuCode === row.frozen.skuCode) + need(cargo && Number(cargo.packageCount) === row.actual, 'Tail SKU actual boxes mismatch') + } + const sum = (values, field) => values.reduce((total, row) => total + Number(row[field]), 0) + need(item.balances.every(row => row.customerId === item.owner.tenantId && row.inboundCode === rowCode(actualRows) && id(row.inboundOrderId) === id(order.id) + && Number(row.availablePackages) === 0 && Number(row.holdPackages) === 0 && Number(row.allocatedPackages) === 0 + && Number(row.pickedPackages) === 0 && Number(row.receivingPackages) === 0 && Number(row.procurementPackages) === 0) + && sum(item.balances, 'inTransitPackages') === expected && sum(item.balances, 'totalPackages') === 0, + 'Initial transit inventory must equal actual boxes only') + need(item.ledger.length === actualRows.length && sum(item.ledger, 'deltaPackages') === expected + && item.ledger.every(row => Number(row.beforePackages) === 0 && Number(row.afterPackages) === Number(row.deltaPackages) + && row.customerId === item.owner.tenantId && row.inboundCode === rowCode(actualRows) && id(row.inboundOrderId) === id(order.id) && row.businessOrderNo === order.orderNo), + 'One initial inventory movement per actual SKU; no duplicate or skipped movement') + packages += expected + } + return { actualPackages: packages, loadingEventMaterializationVerified: true, directOutboxAckAuditVerified: false } +} +const rowCode = actualRows => actualRows[0].frozen.inboundCode + +async function waitDownstream(spec, request, sleep) { + for (let attempt = 0; attempt < 30; attempt++) { + const snapshot = await downstreamSnapshot(spec, request) + const expectedOrders = spec.expected.filter(row => row.actual > 0).map(row => row.inboundId) + const active = [...new Set(expectedOrders)] + if (snapshot.reduce((sum, item) => sum + item.orders.length, 0) === active.length) return { snapshot, facts: verifyDownstream(spec, snapshot) } + await sleep(1000) + } + throw new CheckFailure('Loading event materialization did not converge; no source/event replay') +} + +export async function recoverOriginalA(spec, original, request, checkpoint, sleep = ms => new Promise(done => setTimeout(done, ms))) { + const path = `${PDA}/tasks/${spec.source.pdaTaskId}` + const task = dataOf(await request('GET', `${path}?warehouseId=${spec.source.warehouseId}`, undefined, 'pda')) + need(task.taskStatus === 'COMPLETION_FAILED' && task.completionCommand?.status === 'FAILED' + && id(task.completionCommand.id) === original.commandId && task.completionCommand.operationId === original.body.operationId + && Number(task.completionCommand.expectedTaskVersion) === original.body.expectedTaskVersion, 'Original failed command no longer matches; do not issue another command') + verifyPhysicalFacts(spec.expected, task) + const before = await sourceSnapshot(spec, request) + for (const item of before.sources) { + const actual = spec.expected.some(row => row.inboundId === item.owner.inboundId && row.actual > 0) + need(actual ? item.view.summary.processStatus === 'LOADED' : item.view.summary.processStatus !== 'LOADED', 'Original completed/whole-skipped owner baseline mismatch') + } + await checkpoint({ stage: 'A_BEFORE_ORIGINAL_RECOVERY', task, original, before }) + try { dataOf(await request('POST', `${path}/complete`, original.body, 'pda')) } + catch (error) { + const readback = await request('GET', `${path}?warehouseId=${spec.source.warehouseId}`, undefined, 'pda') + await checkpoint({ stage: 'A_RECOVERY_REJECTED_OR_UNKNOWN_READBACK', readback }) + throw error // no blind repetition of a write with an uncertain result + } + let completed + for (let attempt = 0; attempt < 60; attempt++) { + completed = dataOf(await request('GET', `${path}?warehouseId=${spec.source.warehouseId}`, undefined, 'pda')) + if (completed.taskStatus === 'COMPLETED' && completed.workflowStatus === 'COMPLETED') break + need(completed.taskStatus === 'COMPLETING', `Original recovery failed: ${completed.completionCommand?.failureStage}/${completed.completionCommand?.failureCode}`) + await sleep(1000) + } + need(completed.taskStatus === 'COMPLETED' && id(completed.completionCommand.id) === original.commandId, 'Same original command must finish') + verifyPhysicalFacts(spec.expected, completed) + const after = await sourceSnapshot(spec, request); verifyOldOwnersUnchanged(before, after, spec.expected) + const detail = dataOf(await request('GET', `${LEG}/loading-lists/${spec.source.loadingPlanId}?warehouseId=${spec.source.warehouseId}`)) + const customs = dataOf(await request('GET', `${CUSTOMS}/${spec.source.loadingPlanId}`)) + const planState = dataOf(await request('GET', `${LEG}/loading-plans/${spec.source.loadingPlanId}/lifecycle?warehouseId=${spec.source.warehouseId}`)) + const facts = verifyActual(spec.expected, detail, customs) + need(planState.detailResource === 'LOADING_LIST' && planState.planStatus === 'COMPLETED', 'Recovered plan must expose completed loading-list lifecycle') + const downstream = await waitDownstream(spec, request, sleep) + await checkpoint({ stage: 'A_AFTER_ORIGINAL_RECOVERY', completed, after, detail, customs, planState, downstream }) + const operations = dataOf(await request('GET', `${path}/operations`, undefined, 'pda')) + need(rows(operations).filter(row => row.actionType === 'COMPLETE_LOADING').length === 1, 'Recovered completion audit must be unique') + return { kind: 'A', ...facts, ...downstream.facts, originalCommandId: original.commandId, + oldOwnersAndDomesticInventoryUnchanged: true, wholeSkippedOwnerNotLoaded: true, completed: true } +} + +export async function verifyRecoveredA(spec, original, before, request, checkpoint, sleep = ms => new Promise(done => setTimeout(done, ms))) { + const path = `${PDA}/tasks/${spec.source.pdaTaskId}` + const completed = dataOf(await request('GET', `${path}?warehouseId=${spec.source.warehouseId}`, undefined, 'pda')) + need(completed.taskStatus === 'COMPLETED' && completed.workflowStatus === 'COMPLETED' + && completed.completionCommand?.status === 'COMPLETED' && id(completed.completionCommand.id) === original.commandId + && completed.completionCommand.operationId === original.body.operationId + && Number(completed.completionCommand.expectedTaskVersion) === original.body.expectedTaskVersion, 'Same recovered original command must remain completed') + verifyPhysicalFacts(spec.expected, completed) + const after = await sourceSnapshot(spec, request); verifyOldOwnersUnchanged(before, after, spec.expected) + const detail = dataOf(await request('GET', `${LEG}/loading-lists/${spec.source.loadingPlanId}?warehouseId=${spec.source.warehouseId}`)) + const customs = dataOf(await request('GET', `${CUSTOMS}/${spec.source.loadingPlanId}`)) + const plan = dataOf(await request('GET', `${LEG}/loading-plans/${spec.source.loadingPlanId}/lifecycle?warehouseId=${spec.source.warehouseId}`)) + const facts = verifyActual(spec.expected, detail, customs) + need(plan.detailResource === 'LOADING_LIST' && plan.planStatus === 'COMPLETED', 'Completed original plan must remain completed') + const downstream = await waitDownstream(spec, request, sleep) + const operations = dataOf(await request('GET', `${path}/operations`, undefined, 'pda')) + need(rows(operations).filter(row => row.actionType === 'COMPLETE_LOADING').length === 1, 'One original completion audit') + await checkpoint({ stage: 'A_READONLY_VERIFIED_AFTER_ORIGINAL_RECOVERY', completed, after, detail, customs, plan, downstream, operations }) + return { kind: 'A', ...facts, ...downstream.facts, originalCommandId: original.commandId, readOnlyVerification: true, + oldOwnersAndDomesticAllocationSnapshotsUnchanged: true, wholeSkippedOwnerNotLoaded: true, completed: true } +} + +export function validateZeroWorkTask(source, task, ownUserId) { + need(id(task.id) === source.pdaTaskId && id(task.loadingPlanId) === source.loadingPlanId + && id(task.containerBookingId) === source.containerBookingId && task.currentContainerNo === source.containerNo + && task.taskStatus === 'LOADING' && Number(task.version) === Number(source.beforeTaskVersion), 'Original task identity/version changed') + need(task.workflowStatus === 'UNASSIGNED' && task.assignment == null + || task.workflowStatus === 'IN_PROGRESS' && id(task.assignment?.assigneeId) === ownUserId, + 'Task must be unassigned or assigned to this own PDA session') + need(rows(task.items).length > 0 && rows(task.items).every(item => item.itemStatus === 'PENDING' + && Number(item.pickedPackageCount) === 0), 'Prepared task already has physical work') +} + +export function validatePreparedResume(report) { + need(report.runId === 'PARTIAL-ACTUAL-20261004-recovery-r3' && report.success === false && rows(report.cases).length === 0 + && report.recovery?.originalOwner?.inboundId === '2106706513799155714' && report.recovery?.originalSha256, + 'Only this original-source two-cabinet preparation may continue') + need(!rows(report.requests).some(item => item.method === 'POST' && /\/pda-loading\/tasks\/\d+\/(claim|batch|complete|evidence)$/.test(item.path)), + 'Preparation continuation cannot repeat any PDA work') + const prepared = rows(report.preparation).filter(item => item.stage === 'PDA_UNASSIGNED') + need(prepared.length === 2 && prepared[0].kind === 'A' && prepared[1].kind === 'B' + && prepared.every(item => item.source?.sourceMarker === `${report.runId}-${item.kind}` && item.spec?.kind === item.kind), 'Exactly the prepared A/B checkpoints required') + return prepared +} + +export function multipartBody(file) { + if (file instanceof FormData) return file + need(file?.content !== undefined, 'Multipart descriptor requires content bytes') + const content = file.content instanceof Uint8Array ? file.content : new Uint8Array(file.content) + const form = new FormData() + form.append('file', new Blob([content], { type: file.type || 'application/octet-stream' }), file.name || 'fixture.bin') + return form +} + +export function requestInit(method, body, token, extraHeaders = {}) { + const headers = new Headers({ Accept: 'application/json', ...extraHeaders }) + if (token) headers.set('Authorization', `Bearer ${token}`) + if (body instanceof FormData) headers.delete('Content-Type') // fetch supplies the actual boundary + else if (body !== undefined) headers.set('Content-Type', 'application/json') + return { method, redirect: 'error', signal: AbortSignal.timeout(30_000), headers, + body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body) } +} + +export function multipartAdapter(request, roleFor) { + return (method, path, file, token, headers = {}) => request(method, path, multipartBody(file), roleFor(token), headers) +} + +export function validateResumeProof(report, readback) { + const owner = { tenantId: '2106706477225971713', orderId: '2106706495895306242', inboundId: '2106706513799155714', + orderNo: 'FL2106706495895306242', inboundCode: 'NYEXX2610040025', marker: 'PARTIAL-ACTUAL-20261004-r1-A-REFUND' } + need(report.runId === 'PARTIAL-ACTUAL-20261004-r1' && report.success === false && rows(report.cases).length === 0, + 'Only the explicitly authorized failed original run may resume') + const rejected = rows(report.requests).filter(item => item.path === `/api/platform/v1/files/measurements/${owner.tenantId}/${owner.inboundId}`) + need(rejected.length === 1 && rejected[0].http === 200 && rejected[0].code === 'S0500' && rejected[0].requestId, 'Original one rejected upload required') + const summary = readback.inbound?.summary, measurement = readback.measurement + need(readback.verified === true && readback.sessionClosed === true && id(summary?.id) === owner.inboundId + && id(summary?.tenantId) === owner.tenantId && id(summary?.orderId) === owner.orderId && summary?.orderNo === owner.orderNo + && summary?.inboundCode === owner.inboundCode && summary?.processStatus === 'WAIT_MEASURE' + && measurement?.measurementStatus === 'DRAFT' && rows(measurement.weightResults).length === 0, 'Frozen readback must match original WAIT_MEASURE/DRAFT without weights') + return owner +} + +export function validateResumeLive(owner, frozen, inbound, measurement) { + const summary = inbound?.summary + need(id(summary?.id) === owner.inboundId && id(summary?.tenantId) === owner.tenantId && id(summary?.orderId) === owner.orderId + && summary?.orderNo === owner.orderNo && summary?.inboundCode === owner.inboundCode + && summary?.processStatus === 'WAIT_MEASURE' && measurement?.measurementStatus === frozen.measurement.measurementStatus + && ['DRAFT', 'RECEIVED'].includes(measurement?.measurementStatus) + && rows(measurement.weightResults).length === 0 && id(measurement.measurementId) === id(frozen.measurement.measurementId) + && measurement.version === frozen.measurement.version && summary.version === frozen.inbound.summary.version, + 'Original sample changed; stop before any recovery write') +} + +export async function resumeMeasurementDraft(context, owner, initial) { + const root = `${LEG}/inbounds/${owner.inboundId}`, query = `tenantId=${owner.tenantId}` + const ok = response => { need(response.http === 200 && String(response.body?.code) === '200', `Original measurement rejected ${response.path}`); return response.body.data } + const bytes = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64') + const uploaded = ok(await context.requestMultipart('POST', `/api/platform/v1/files/measurements/${owner.tenantId}/${owner.inboundId}`, + { content: bytes, type: 'image/png', name: `recovery-weight-${owner.inboundId}.png` })) + need(uploaded.fileId, 'Original recovery upload did not return a file') + const declarations = rows(initial.latestDeclaration?.items) + need(declarations.length === 2 && declarations.map(item => Number(item.packageCount)).sort().join(',') === '1,3', 'Original two-SKU declaration must remain 3+1') + // Save the two declared SKU identities separately. The generic single-SKU fixture + // had aggregated all four boxes into the first detail; no carton scanning is repeated. + const draft = ok(await context.request('PUT', `${root}/measurement/draft?${query}`, { + measurementId: initial.measurementId, version: initial.version, actualGoodsName: '原样本两SKU恢复测量', + items: declarations.map(item => ({ inboundDetailId: item.inboundDetailId, itemNo: String(item.itemNo), + boxSpecification: item.cargoName, packageCount: Number(item.packageCount), + lengthCm: Number(item.lengthCm), widthCm: Number(item.widthCm), heightCm: Number(item.heightCm), + intelligentImageFileId: uploaded.fileId, coefficientMode: 'ACTUAL', remark: owner.marker })), + })) + const options = ok(await context.request('GET', `${root}/weight-pallet-options?${query}&page=1&size=20&keyword=YW-TP-002`)) + const pallet = rows(options.records).find(item => item.palletCode === 'YW-TP-002') + need(pallet?.palletId, 'Original source warehouse needs the existing YW-TP-002 pallet') + const weight = ok(await context.request('POST', `${root}/weight-results?${query}`, { fileId: id(uploaded.fileId), + palletId: id(pallet.palletId), ocrProvider: 'ADMIN_ACCEPTANCE', ocrRawText: null, ocrWeightValue: null, ocrWeightUnit: null, confidence: null })) + const confirmed = ok(await context.request('POST', `${root}/weight-results/${weight.id}/confirm?${query}`, { + version: weight.version, adjustedWeightValue: 100, adjustedWeightUnit: 'KG', adjustmentReason: `${owner.marker} HTTP人工称重夹具` })) + need(confirmed.recognitionStatus === 'CONFIRMED', 'Original source weight must be confirmed') + const current = ok(await context.request('GET', `${root}/measurement?${query}`)) + need(id(current.measurementId) === id(draft.measurementId) && current.measurementStatus === 'DRAFT', 'Original draft identity retained') + const submitted = ok(await context.request('POST', `${root}/measurement/submit?${query}`, { measurementId: current.measurementId, version: current.version })) + need(submitted.processStatus === 'MEASURE_PENDING', 'Original draft must enter actual measurement review') + const pending = ok(await context.request('GET', `/api/platform/inbounds/${owner.inboundId}?${query}`)) + ok(await context.request('POST', `/api/platform/v1/inbounds/${owner.inboundId}/measurements/approve?${query}`, + { measurementId: current.measurementId, expectedVersion: pending.summary.version, reason: null }, context.token, + { 'Idempotency-Key': `${owner.marker}-recovery-approve` })) + let final + for (let attempt = 0; attempt < 30; attempt++) { + final = ok(await context.request('GET', `${root}/measurement?${query}`)) + if (final.processStatus === 'WAIT_SPLIT' && final.measurementStatus === 'APPROVED') break + await new Promise(done => setTimeout(done, 1000)) + } + need(final.processStatus === 'WAIT_SPLIT' && final.measurementStatus === 'APPROVED', 'Original recovery did not reach WAIT_SPLIT') + need(rows(final.items).length === 2 && rows(final.items).every(item => declarations.some(declaration => + id(declaration.inboundDetailId) === id(item.inboundDetailId) && Number(declaration.packageCount) === Number(item.packageCount))), 'Original SKU measurements retain declaration identities and 3+1 counts') + context.createdResources.inboundMeasurement = { marker: owner.marker, tenantId: owner.tenantId, inboundId: owner.inboundId, + orderId: owner.orderId, inboundCode: owner.inboundCode, measurementId: id(final.measurementId), + finalStatus: final.measurementStatus, processStatus: final.processStatus } +} + +export function redact(value) { + if (Array.isArray(value)) return value.map(redact) + if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, child]) => + [key, /password|token|secret|authorization/i.test(key) ? '[REDACTED]' : redact(child)])) + return value +} + +// This is a bounded producer, not a run of the full acceptance module list. +// The three reused modules only create qualified customer + submitted/approved/measured order. +export async function prepareFreshCases(runId, context, savePrepared, resume = null) { + const { run: qualification } = await import('./admin-test-env-modules/merchant-qualification-fixture.mjs') + const { run: orders } = await import('./admin-test-env-modules/orders.mjs') + const { run: measurement } = await import('./admin-test-env-modules/inbound-measurement.mjs') + const { selectSessionPdaOperator } = await import('./admin-test-env-modules/domestic-pda-operator.mjs') + const { publishPlatformLogisticsChannels, resolveOceanBookingAgent } = await import('./admin-test-env-modules/ocean-booking-fixture.mjs') + const { ensureMatsonCarrierPartner } = await import('./admin-test-env-modules/carrier-partner-fixture.mjs') + const { channelGroup } = await import('./admin-test-env-modules/smart-loading-channel-group.mjs') + const { iso6346ContainerNumber } = await import('./admin-test-env-modules/iso6346-container-number.mjs') + const ok = response => { need(response.http === 200 && String(response.body?.code) === '200', `Fresh fixture rejected ${response.path}`); return response.body.data } + const produced = [] + for (const [kind, definitions] of [['A', [['REFUND', 'TAX_REFUND', [3,1]], ['BUY', 'BUY_DECLARATION', [1]], ['SKIP', 'TAX_REFUND', [1]]]], + ['B', [['BUY', 'BUY_DECLARATION', [2]], ['SKIP', 'TAX_REFUND', [1]]]]]) { + const owners = [] + for (const [role, taxMode, counts] of definitions) { + context.marker = `${runId}-${kind}-${role}` + context.context = { orderProfile: { taxMode, fulfillmentType: 'STANDARD' }, orderFulfillmentType: 'STANDARD', + waitSplitVariant: { skuCount: counts.length, packageCounts: counts, packageCountPerSku: counts[0] } } + context.createdResources = {} + const originalRecovery = resume && kind === 'A' && role === 'REFUND' + let originalMeasurement + if (originalRecovery) { + const original = resume.owner + const inbound = ok(await context.request('GET', `/api/platform/inbounds/${original.inboundId}?tenantId=${original.tenantId}`)) + const measurementView = ok(await context.request('GET', `${LEG}/inbounds/${original.inboundId}/measurement?tenantId=${original.tenantId}`)) + validateResumeLive(original, resume.readback, inbound, measurementView) + context.marker = original.marker + context.createdResources.orders = { ...original, receivingWarehouseId: id(inbound.summary.warehouseId), + fulfillmentType: 'STANDARD', taxMode: 'TAX_REFUND' } + await savePrepared({ stage: 'ORIGINAL_RECOVERY_CHECKED', kind, role, ...original, measurementId: measurementView.measurementId, + inboundVersion: inbound.summary.version, measurementVersion: measurementView.version }) + originalMeasurement = measurementView + } else { + await qualification(context, { publishEnvironment: false }) + await orders(context) + } + await savePrepared({ stage: 'APPROVED_ORDER', kind, role, tenantId: id(context.createdResources.orders?.tenantId), + inboundId: id(context.createdResources.orders?.inboundId), orderId: id(context.createdResources.orders?.orderId) }) + if (originalRecovery) await resumeMeasurementDraft(context, resume.owner, originalMeasurement) + else await measurement(context) + const source = context.createdResources.inboundMeasurement + need(source?.inboundId && source.processStatus === 'WAIT_SPLIT' && source.finalStatus === 'APPROVED', 'New legal measurement did not reach WAIT_SPLIT') + owners.push({ role, taxMode, counts, tenantId: id(source.tenantId), inboundId: id(source.inboundId), + orderId: id(source.orderId || context.createdResources.orders?.orderId), orderNo: context.createdResources.orders?.orderNo, + inboundCode: source.inboundCode || context.createdResources.orders?.inboundCode }) + await savePrepared({ stage: 'WAIT_SPLIT', kind, owners: structuredClone(owners) }) + } + context.marker = `${runId}-${kind}` + const primary = owners[0] + const inbound = ok(await context.request('GET', `/api/platform/inbounds/${primary.inboundId}?tenantId=${primary.tenantId}`)) + const warehouseId = id(inbound.summary.warehouseId), channelCode = inbound.summary.channelCode + need(warehouseId && channelCode, 'Fresh source warehouse/channel missing') + await publishPlatformLogisticsChannels(context, warehouseId, 'partial-loading-prepare') + const agent = await resolveOceanBookingAgent(context, 'partial-loading-prepare', 'MATSON', { deploymentBlocker: true }) + const carrier = await ensureMatsonCarrierPartner(context, 'partial-loading-prepare', { deploymentBlocker: true }) + need(agent?.id && carrier?.id, 'Existing booking master data required') + const container = ok(await context.request('POST', '/api/platform/v1/containers', { + channelGroupCode: channelGroup(channelCode), warehouseId, containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500 })) + const bookingId = id(container.id) + let smartPlan = ok(await context.request('GET', `/api/platform/v1/first-leg/smart-loading/plans/by-booking/${bookingId}`)) + const planId = id(smartPlan.id) + await savePrepared({ stage: 'EDITING', kind, owners, containerBookingId: bookingId, loadingPlanId: planId }) + const candidateResponse = ok(await context.request('GET', `/api/platform/v1/first-leg/smart-loading/candidates?bookingId=${bookingId}&warehouseId=${warehouseId}`)) + const candidates = rows(candidateResponse) + for (const owner of owners) { + const own = candidates.filter(item => id(item.inboundId) === owner.inboundId && id(item.accountId) === owner.tenantId) + .sort((a,b) => id(a.inboundDetailId).localeCompare(id(b.inboundDetailId))) + need(own.length === owner.counts.length, 'Fresh owner candidate SKU set mismatch') + for (const candidate of own) { + const packages = Number(candidate.availablePackageCount) + need(owner.counts.includes(packages), 'Candidate count differs from fresh declared boxes') + smartPlan = ok(await context.request('POST', `/api/platform/v1/first-leg/smart-loading/plans/${planId}/assignments`, { + inboundId: owner.inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: packages, + expectedVersion: Number(smartPlan.version), operationId: `${context.marker}-assign-${candidate.inboundDetailId}` })) + } + } + const booking = ok(await context.request('POST', '/api/platform/v1/ocean-bookings', { expectedVersion: 0, data: { + channelCode: channelGroup(channelCode), warehouseId, billOfLadingNo: `BL-${context.marker}`, + shippingCompanyId: id(carrier.id), shippingCompanyName: carrier.name, carrierCode: 'MATSON', + bookingAgentId: id(agent.id), bookingAgentName: agent.name, vesselName: `测试船-${context.marker}`, voyageNo: 'PA-TEST', + departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', + estimatedSailingAt: '2026-10-05T08:00:00', estimatedArrivalAt: '2026-10-25T08:00:00', remark: context.marker, + parties: ['SHIPPER','CONSIGNEE','NOTIFY_PARTY'].map(partyType => ({ partyType, partyName: `${context.marker}-${partyType}` })), + containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', quantity: 1, capacityCbm: 68, maxWeightKg: 19500 }], + } }, context.token, { 'Idempotency-Key': `${context.marker}-booking` })) + const oceanId = id(booking.oceanBooking.id), form = new FormData() + form.append('expectedVersion', String(booking.oceanBooking.version)); form.append('documentType', 'BOOKING_CONFIRMATION') + form.append('file', new Blob([`${context.marker} controlled TEST booking confirmation`], { type: 'text/plain' }), `${context.marker}-booking.txt`) + const document = ok(await context.requestMultipart('POST', `/api/platform/v1/ocean-bookings/${oceanId}/documents/upload`, form, + context.token, { 'Idempotency-Key': `${context.marker}-booking-file` })) + const released = ok(await context.request('POST', `/api/platform/v1/ocean-bookings/${oceanId}/release`, + { expectedVersion: Number(document.bookingVersion), reason: context.marker }, context.token, { 'Idempotency-Key': `${context.marker}-release` })) + need(released.oceanBooking.status === 'RELEASED', 'New booking release required') + const currentContainer = ok(await context.request('GET', `/api/platform/v1/containers/domestic/${bookingId}`)).container + const containerNo = iso6346ContainerNumber(context.marker) + ok(await context.request('POST', `/api/platform/v1/containers/${bookingId}/ocean-booking-link`, { + oceanBookingId: oceanId, requirementId: id(released.containerRequirements[0].id), containerNo, expectedVersion: currentContainer.version })) + const preload = ok(await context.request('GET', `${LEG}/loading-plans/${planId}?warehouseId=${warehouseId}`)) + const operator = selectSessionPdaOperator(ok(await context.request('GET', `/api/platform/v1/iam/users/domestic-pda-operators?warehouseId=${warehouseId}`)), context.pdaToken) + const confirmation = ok(await context.request('POST', `${LEG}/loading-plans/${planId}/confirm`, + { expectedVersion: Number(preload.summary.version), assigneeId: id(operator.userId) }, context.token, { 'Idempotency-Key': `${context.marker}-confirm` })) + const taskId = id(confirmation.readiness.pdaTaskId) + const task = ok(await context.request('GET', `${PDA}/tasks/${taskId}?warehouseId=${warehouseId}`, undefined, context.pdaToken)) + const source = { schemaVersion: 1, sourceMarker: context.marker, preparedAt: new Date().toISOString(), stage: 'MANUAL_PDA_CLAIM_AND_BATCH_READY', + pdaTaskId: taskId, loadingPlanId: planId, containerBookingId: bookingId, warehouseId, containerNo, + cabinetNo: container.cabinetNo, oceanBookingId: oceanId, bookingNo: released.oceanBooking.bookingNo, planNo: smartPlan.planNo, + beforeTaskVersion: task.version, taskItems: task.items.map(item => ({ id: id(item.id), loadingPlanItemId: id(item.loadingPlanItemId), + tenantId: id(item.tenantId), inboundId: id(item.inboundId), inboundDetailId: id(item.inboundDetailId), plannedPackageCount: item.plannedPackageCount })) } + const actions = task.items.map(item => { + const owner = owners.find(owner => owner.inboundId === id(item.inboundId)) + need(owner, 'Task includes only fresh produced owners') + const partial = owner === primary && Number(item.plannedPackageCount) === (kind === 'A' ? 3 : 2) + return { itemId: id(item.id), action: partial ? 'PARTIAL' : owner.role === 'BUY' ? 'PICK' : 'SKIP', + ...(partial ? { finalPickedPackageCount: kind === 'A' ? 2 : 1 } : {}) } + }) + const spec = { kind, actions, sealNo: `SEAL-${context.marker}` } + const freshPreload = ok(await context.request('GET', `${LEG}/loading-plans/${planId}?warehouseId=${warehouseId}`)) + expectedRows(spec, task, freshPreload) + produced.push({ spec, source, owners }) + await savePrepared({ stage: 'PDA_UNASSIGNED', kind, source, owners, spec }) + } + return produced +} + +export function validateManifest(manifest, runId) { + need(manifest?.schemaVersion === 1 && manifest.environment === 'TEST' && manifest.runId === runId, 'Fixture run/environment mismatch') + need(rows(manifest.cases).length === 2 && manifest.cases[0].kind === 'A' && manifest.cases[1].kind === 'B', 'Exactly A then B required') + for (const item of manifest.cases) { + need(typeof item.sourceReport === 'string' && item.sourceReport.length > 0, 'New official fixture source report required') + need(rows(item.actions).length >= 2 && new Set(item.actions.map(row => row.itemId)).size === item.actions.length, 'Every PDA item needs one unique action') + for (const action of item.actions) need(positive(action.itemId) && ['PICK', 'PARTIAL', 'SKIP'].includes(action.action) + && (action.action !== 'PARTIAL' || Number.isSafeInteger(action.finalPickedPackageCount) && action.finalPickedPackageCount > 0), 'Invalid PDA action') + need(typeof item.sealNo === 'string' && item.sealNo.trim().length > 0 && item.sealNo.length <= 64, 'Seal required') + need(TYPES.every(type => typeof item.evidence?.[type] === 'string' && item.evidence[type]), 'Five supplied evidence paths required') + } + return manifest +} + +export function fixtureSource(report, runId, gateway, now = Date.now()) { + const value = report?.createdResources?.manualPdaBatchFixture + need(report.success === true && report.fatal == null && report.summary?.failed === 0 + && report.gateway?.replace(/\/$/, '') === gateway, 'Official fixture report did not pass in this TEST Gateway') + need(value?.schemaVersion === 1 && value.stage === 'MANUAL_PDA_CLAIM_AND_BATCH_READY' + && typeof value.sourceMarker === 'string' && value.sourceMarker.startsWith(`${runId}-`) + && /^[A-Za-z0-9_-]{1,80}$/.test(value.sourceMarker), 'Fresh run-bound PDA preparation required') + const age = now - Date.parse(value.preparedAt) + need(Number.isFinite(age) && age >= 0 && age <= 24 * 3600_000, 'Fixture must be prepared within this run/day') + for (const key of ['pdaTaskId', 'loadingPlanId', 'containerBookingId', 'warehouseId']) need(positive(value[key]), `Invalid source ${key}`) + need(rows(value.taskItems).length > 0, 'Missing original PDA identities') + return value +} + +// Decimal arithmetic is exact; no binary floating point at HALF_UP boundaries. +export function scale(value, actual, planned, precision) { + if (value == null) return null + const text = String(value) + need(/^\d+(?:\.\d+)?$/.test(text) && Number.isSafeInteger(actual) && actual > 0 + && Number.isSafeInteger(planned) && planned >= actual, 'Invalid nonnegative frozen decimal/ratio') + const [whole, fraction = ''] = text.split('.') + const numerator = BigInt(whole + fraction) * BigInt(actual) * 10n ** BigInt(precision) + const denominator = BigInt(planned) * 10n ** BigInt(fraction.length) + const rounded = (2n * numerator + denominator) / (2n * denominator) + const padded = rounded.toString().padStart(precision + 1, '0') + return precision ? `${padded.slice(0, -precision)}.${padded.slice(-precision)}` : padded +} + +export function expectedRows(caseSpec, task, preload) { + const byId = new Map(rows(task.items).map(item => [id(item.id), item])) + need(byId.size === task.items.length && byId.size === caseSpec.actions.length + && caseSpec.actions.every(item => byId.has(item.itemId)), 'Actions must cover every live PDA item exactly once') + const planById = new Map(rows(preload.items).map(item => [id(item.itemId), item])) + need(planById.size === byId.size, 'Preload and PDA scope mismatch') + const expected = caseSpec.actions.map(action => { + const pick = byId.get(action.itemId), frozen = planById.get(id(pick.loadingPlanItemId)) + need(frozen && id(frozen.accountId) === id(pick.tenantId) && id(frozen.inboundId) === id(pick.inboundId) + && id(frozen.inboundDetailId) === id(pick.inboundDetailId), 'Frozen source/customer/detail identity mismatch') + const planned = Number(pick.plannedPackageCount) + need(pick.itemStatus === 'PENDING' && Number(pick.pickedPackageCount) === 0 && pick.unitType === 'PACKAGE' + && !pick.palletId && planned > 0 && Number(frozen.packageCount) === planned, 'Require zero-picked PACKAGE sources') + const actual = action.action === 'SKIP' ? 0 : action.action === 'PICK' ? planned : action.finalPickedPackageCount + need(action.action !== 'PARTIAL' || actual < planned, 'PARTIAL must be less than original plan') + return { action: action.action, pickId: action.itemId, itemId: id(frozen.itemId), accountId: id(frozen.accountId), + inboundId: id(frozen.inboundId), inboundDetailId: id(frozen.inboundDetailId), taxMode: frozen.customsTaxMode, + planned, actual, frozen, orderId: id(frozen.orderId) } + }) + const partial = expected.filter(item => item.action === 'PARTIAL'), full = expected.filter(item => item.action === 'PICK') + const skipped = expected.filter(item => item.action === 'SKIP'), active = expected.filter(item => item.actual > 0) + need(expected.every(item => ['TAX_REFUND', 'BUY_DECLARATION'].includes(item.taxMode)), 'Authoritative tax mode required') + if (caseSpec.kind === 'A') { + need(partial.length === 1 && partial[0].taxMode === 'TAX_REFUND' && partial[0].planned === 3 && partial[0].actual === 2 + && full.length === 1 && full[0].taxMode === 'BUY_DECLARATION' && full[0].accountId !== partial[0].accountId + && skipped.some(item => item.inboundId === partial[0].inboundId) + && skipped.some(item => item.taxMode === 'TAX_REFUND' && !active.some(row => row.accountId === item.accountId)), 'A needs mixed customers, partial refund, same-source skip and entirely skipped refund customer') + } else { + need(active.length === 1 && partial.length === 1 && full.length === 0 + && partial[0].taxMode === 'BUY_DECLARATION' && partial[0].planned === 2 && partial[0].actual === 1 + && skipped.length > 0 && skipped.every(item => item.taxMode === 'TAX_REFUND'), 'B must have exactly one actual BUY PARTIAL row and only skipped refund rows') + } + return expected +} + +export function verifyActual(expected, detail, customs) { + const active = expected.filter(item => item.actual > 0) + assert.deepEqual(rows(detail.items).map(item => id(item.itemId)).sort(), active.map(item => item.itemId).sort(), 'Actual loading row set') + for (const item of active) { + const actual = detail.items.find(row => id(row.itemId) === item.itemId), frozen = item.frozen + assert.equal(Number(actual.packageCount), item.actual, 'Actual boxes') + assert.equal(id(actual.accountId), item.accountId, 'Actual customer') + assert.equal(id(actual.inboundId), item.inboundId, 'Actual source') + const units = Number(frozen.unitsPerPackage) + const quantity = item.actual === item.planned ? Number(frozen.productQuantity) + : units > 0 ? units * item.actual : Number(scale(frozen.productQuantity, item.actual, item.planned, 0)) + assert.equal(Number(actual.productQuantity), quantity, 'Actual units') + for (const [field, precision] of [['grossWeightKg', 3], ['netWeightKg', 3], ['dimensionalWeightKg', 3], ['volumeCbm', 4]]) + assert.equal(scale(actual[field], 1, 1, precision), scale(frozen[field], item.actual, item.planned, precision), `Actual ${field}`) + } + const boxes = active.reduce((sum, item) => sum + item.actual, 0) + assert.equal(Number(detail.summary.totalPackageCount), boxes, 'Actual total boxes') + assert.equal(Number(customs.actualBoxCount), boxes, 'Customs actual boxes') + const inboundKeys = values => [...new Set(values.map(item => `${id(item.tenantId ?? item.accountId)}:${id(item.inboundId)}`))].sort() + assert.deepEqual(inboundKeys(rows(customs.inbounds)), inboundKeys(active), 'Customs source/customer set') + assert.equal(Number(customs.taxRefundCount), new Set(active.filter(item => item.taxMode === 'TAX_REFUND').map(item => item.inboundId)).size, 'Refund coverage excludes skipped rows') + assert.equal(Number(detail.summary.totalQuantity), detail.items.reduce((sum, item) => sum + Number(item.productQuantity), 0), 'Quantity total') + for (const [field, summary, precision] of [['grossWeightKg', 'totalGrossWeightKg', 3], ['netWeightKg', 'totalNetWeightKg', 3], ['volumeCbm', 'totalVolumeCbm', 4]]) { + const sum = detail.items.reduce((value, item) => value + BigInt(scale(item[field] ?? 0, 1, 1, precision).replace('.', '')), 0n) + assert.equal(BigInt(scale(detail.summary[summary] ?? 0, 1, 1, precision).replace('.', '')), sum, `Actual ${summary}`) + } + return { actualBoxes: boxes, actualRows: active.length, taxRefundCount: Number(customs.taxRefundCount) } +} + +export async function runCase(spec, source, prepared, request, sleep = ms => new Promise(done => setTimeout(done, ms))) { + const { expected, task, images } = prepared + const path = `${PDA}/tasks/${source.pdaTaskId}`, key = suffix => `${source.sourceMarker}-${suffix}`.slice(0, 128) + const data = response => { need(response.http === 200 && String(response.body?.code) === '200', `API rejected ${response.path}: HTTP=${response.http}, code=${response.body?.code ?? 'missing'}`); return response.body.data } + const readTask = async () => data(await request('GET', `${path}?warehouseId=${source.warehouseId}`, undefined, 'pda')) + const claimed = data(await request('POST', `${path}/claim`, { expectedAssignmentVersion: Number(task.assignment?.version ?? 0), operationId: key('claim') }, 'pda')) + need(claimed.workflowStatus === 'IN_PROGRESS', 'Claim must enter IN_PROGRESS') + let picked + // Batch must contain every still-pending row, and forbids SKIP + physical picks + // for one owner in the same command. Resolve that owner's zero-picked SKU through + // the formal individual SKIP first; then one atomic batch handles all remaining rows. + const individualSkips = expected.filter(item => item.action === 'SKIP' + && expected.some(other => other.inboundId === item.inboundId && other.actual > 0)) + for (const item of individualSkips) { + const current = await readTask(), row = current.items.find(row => id(row.id) === item.pickId) + data(await request('POST', `${path}/items/${item.pickId}/skip`, { warehouseId: source.warehouseId, + version: Number(row.version), operationId: key(`skip-${item.pickId}`), reason: source.sourceMarker }, 'pda')) + } + const lines = expected.filter(item => !individualSkips.some(skip => skip.pickId === item.pickId)) + { + const current = await readTask() + const inventory = data(await request('GET', `${path}/inventory-snapshot?warehouseId=${source.warehouseId}`, undefined, 'pda')) + const batch = { warehouseId: source.warehouseId, loadingPlanId: source.loadingPlanId, containerBookingId: source.containerBookingId, + expectedVersion: Number(current.version), inventorySnapshotToken: inventory.snapshotToken, + items: lines.map(item => ({ itemId: item.pickId, action: item.action, + version: Number(current.items.find(row => id(row.id) === item.pickId)?.version), + ...(item.action === 'PARTIAL' ? { finalPickedPackageCount: item.actual } : item.action === 'PICK' ? { pickedPackageCount: item.actual } : { reason: source.sourceMarker }) })) } + need(inventory.snapshotToken && batch.items.every(item => Number.isSafeInteger(item.version)), 'Fresh inventory and item versions required') + picked = data(await request('POST', `${path}/batch`, batch, 'pda', { 'Idempotency-Key': key('resolved-batch') })) + } + need(picked.workflowStatus === 'AWAITING_REVIEW' && expected.every(item => { + const row = rows(picked.items).find(row => id(row.id) === item.pickId) + return row && Number(row.pickedPackageCount) === item.actual && row.itemStatus === ({ PICK: 'PICKED', PARTIAL: 'PARTIAL', SKIP: 'SKIPPED' })[item.action] + }), 'Resolved picks and all-PARTIAL B must be persisted before completion') + for (const type of TYPES) { + const image = images[type], form = new FormData() + for (const [name, value] of Object.entries({ warehouseId: source.warehouseId, expectedVersion: 0, evidenceType: type, + remark: source.sourceMarker, ...(type === 'SEAL' ? { sealNo: spec.sealNo } : {}) })) form.append(name, String(value)) + form.append('file', new Blob([image.bytes], { type: image.type }), `${source.sourceMarker}-${type}.${image.type === 'image/png' ? 'png' : 'jpg'}`) + data(await request('POST', `${path}/evidence`, form, 'pda', { 'Idempotency-Key': key(`evidence-${type}`) })) + } + const evidence = data(await request('GET', `${path}/evidence?warehouseId=${source.warehouseId}`, undefined, 'pda')) + need(TYPES.every(type => rows(evidence.evidences).some(item => item.evidenceType === type && item.status === 'ACTIVE' + && (type !== 'SEAL' || item.sealNo === spec.sealNo))), 'Five active evidence types and seal required') + const ready = await readTask(), body = { expectedTaskVersion: Number(ready.version), operationId: key('complete'), remark: source.sourceMarker } + data(await request('POST', `${path}/complete`, body, 'pda')) + let completed + for (let attempt = 0; attempt < 60; attempt++) { + completed = await readTask() + if (completed.taskStatus === 'COMPLETED' && completed.workflowStatus === 'COMPLETED') break + need(completed.taskStatus === 'COMPLETING', `Completion stopped: task=${completed.taskStatus}, command=${completed.completionCommand?.status ?? 'missing'}, stage=${completed.completionCommand?.failureStage ?? 'missing'}, code=${completed.completionCommand?.failureCode ?? 'missing'}`) + await sleep(1000) + } + need(completed.taskStatus === 'COMPLETED' && completed.workflowStatus === 'COMPLETED', 'Completion polling exhausted') + const snapshot = async () => ({ task: await readTask(), + detail: data(await request('GET', `${LEG}/loading-lists/${source.loadingPlanId}?warehouseId=${source.warehouseId}`)), + customs: data(await request('GET', `${CUSTOMS}/${source.loadingPlanId}`)), + operations: data(await request('GET', `${path}/operations`, undefined, 'pda')) }) + const before = await snapshot(), facts = verifyActual(expected, before.detail, before.customs) + need(rows(before.operations).filter(item => item.actionType === 'COMPLETE_LOADING').length === 1, 'One COMPLETE_LOADING audit required') + data(await request('POST', `${path}/complete`, body, 'pda')) // original key, version and body + const after = await snapshot() + assert.equal(stableSnapshot(after), stableSnapshot(before), 'Exact completion replay has zero projection/version/history drift') + if (spec.kind === 'B') { + need(before.customs.taxRefundCount === 0 && before.customs.status === 'READY_TO_START', 'Actual all-buy readiness') + const start = data(await request('POST', `${CUSTOMS}/${source.loadingPlanId}/start`, + { expectedVersion: before.customs.version, confirm: true, remark: source.sourceMarker }, 'admin', { 'Idempotency-Key': key('customs-start') })) + need(start.status === 'DECLARING', 'All-buy start') + const reject = await request('POST', `${CUSTOMS}/${source.loadingPlanId}/send-to-broker`, + { expectedVersion: start.version, confirm: true, remark: source.sourceMarker }, 'admin', { 'Idempotency-Key': key('refund-send-negative') }) + need(reject.http === 200 && String(reject.body?.code) === 'A0409', 'All-buy must reject refund send') + const unchanged = data(await request('GET', `${CUSTOMS}/${source.loadingPlanId}`)) + assert.equal(stableSnapshot(unchanged), stableSnapshot(start), 'Rejected refund send leaves customs unchanged') + const result = data(await request('POST', `${CUSTOMS}/${source.loadingPlanId}/complete-all-buy`, + { expectedVersion: unchanged.version, confirm: true, remark: source.sourceMarker }, 'admin', { 'Idempotency-Key': key('customs-complete') })) + need(result.status === 'COMPLETED', 'All-buy completion') + } + return { kind: spec.kind, ...facts, source, actions: spec.actions, allActualRowsPartial: expected.filter(item => item.actual > 0).every(item => item.action === 'PARTIAL'), + completed: true, completionReplayZeroDrift: true } +} + +export async function execute(args = process.argv.slice(2), execArgs = process.execArgv, env = process.env) { + const options = executionOptions(args, execArgs, env) + if (!options.execute) return plan() + const directory = resolve(ROOT, 'reports', `partial-loading-actual-${options.runId}`) + const bOnlyMode = typeof options.fixture === 'object' && options.fixture.bOnlyReport + const verifyAMode = typeof options.fixture === 'object' && options.fixture.verifyAReport + const completionMode = typeof options.fixture === 'object' && (options.fixture.completionReport || options.fixture.verifyAReport) + const preparedMode = typeof options.fixture === 'object' && options.fixture.preparedReport + const resumeMode = typeof options.fixture === 'object' && options.fixture.resumeReport + const fresh = options.fixture === 'NEW' || resumeMode + let resume + if (resumeMode) { + const originalBytes = await readFile(options.fixture.resumeReport) + const readbackPath = resolve(dirname(options.fixture.resumeReport), 'blocked-readback.json') + const readbackBytes = await readFile(readbackPath), readback = JSON.parse(readbackBytes) + const original = JSON.parse(originalBytes) + resume = { owner: validateResumeProof(original, readback), readback, + originalReport: options.fixture.resumeReport, originalSha256: sha(originalBytes), readbackSha256: sha(readbackBytes) } + const attemptedPath = resolve(ROOT, 'reports/partial-loading-actual-PARTIAL-ACTUAL-20261004-recovery-r1/report.json') + let attemptedBytes + try { attemptedBytes = await readFile(attemptedPath) } catch (error) { if (error.code !== 'ENOENT') throw error } + if (attemptedBytes) { + const attempted = JSON.parse(attemptedBytes) + const mutations = rows(attempted.requests).filter(item => item.method !== 'GET' && !item.path.startsWith('/api/auth/') && !item.path.includes('carton-label-pdf')) + need(attempted.success === false && attempted.recovery?.originalSha256 === resume.originalSha256 + && mutations.length === 1 && mutations[0].path === `${LEG}/inbounds/${resume.owner.inboundId}/measurement/carton-scans?tenantId=${resume.owner.tenantId}` + && mutations[0].http === 200 && mutations[0].code === '200' && mutations[0].response.data.created === false + && mutations[0].response.data.measurementVersion === readback.measurement.version + 1, + 'Prior recovery must prove its sole accepted original re-scan; no blind retry') + resume.readback = structuredClone(readback) + resume.readback.measurement.version = mutations[0].response.data.measurementVersion + resume.readback.measurement.measurementStatus = 'RECEIVED' // the proven scan changed this draft into RECEIVED + resume.priorRecovery = { report: attemptedPath, sha256: sha(attemptedBytes), + knownOriginalRescanRequestId: mutations[0].requestId, expectedMeasurementVersion: resume.readback.measurement.version, + expectedMeasurementStatus: 'RECEIVED' } + } + } + const manifestBytes = bOnlyMode ? await readFile(options.fixture.bOnlyReport) : completionMode ? await readFile(options.fixture.completionReport || options.fixture.verifyAReport) : preparedMode ? await readFile(options.fixture.preparedReport) : fresh ? Buffer.from(resumeMode ? 'RESUME' : 'NEW') : await readFile(options.fixture) + const manifest = fresh || preparedMode || completionMode || bOnlyMode ? null : validateManifest(JSON.parse(manifestBytes), options.runId) + const sources = [], specs = [] + let original + let originalBefore + let aEventProof + if (bOnlyMode) { + const eventProofBytes = await readFile(resolve(ROOT, 'reports/partial-A-completion-event-proof-20261004-r1.json')) + const eventProof = JSON.parse(eventProofBytes) + need(eventProof.readOnly === true && eventProof.verified === true && Object.values(eventProof.checks).every(value => value === true) + && rows(eventProof.rows).length === 2 && rows(eventProof.rows).every(row => row.status === 'PROCESSED' && row.eventVersion === 3), + 'Controller exact A Outbox/ACK/Inbox proof required before B') + aEventProof = { report: 'reports/partial-A-completion-event-proof-20261004-r1.json', sha256: sha(eventProofBytes), checks: eventProof.checks, events: eventProof.rows } + let evidence = JSON.parse(manifestBytes) + need(evidence.success === true && evidence.cases?.length === 1 && evidence.cases[0].kind === 'A' + && evidence.cases[0].originalCommandId === '2106716718935052290' && evidence.cases[0].actualPackages === 3 + && evidence.cases[0].oldOwnersAndDomesticAllocationSnapshotsUnchanged === true && evidence.cases[0].wholeSkippedOwnerNotLoaded === true, + 'B requires the verified original A recovery first') + for (let index = 0; index < 2; index++) { + const parent = await readFile(evidence.completionRecovery.originalReport) + need(sha(parent) === evidence.completionRecovery.originalSha256, 'A evidence chain SHA changed') + evidence = JSON.parse(parent) + } + originalCompletionCommand(evidence) + const preparationBytes = await readFile(evidence.preparedRecovery.report) + need(sha(preparationBytes) === evidence.preparedRecovery.sha256, 'Original prepared B SHA changed') + const preparation = JSON.parse(preparationBytes), checkpoint = validatePreparedResume(preparation)[1] + const taskPath = `${PDA}/tasks/${checkpoint.source.pdaTaskId}?warehouseId=${checkpoint.source.warehouseId}` + const preloadPath = `${LEG}/loading-plans/${checkpoint.source.loadingPlanId}?warehouseId=${checkpoint.source.warehouseId}` + const initialTask = rows(preparation.requests).filter(row => row.path === taskPath && row.method === 'GET').at(-1).response.data + const initialPreload = rows(preparation.requests).filter(row => row.path === preloadPath && row.method === 'GET').at(-1).response.data + const bytes = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64') + specs.push({ ...checkpoint, expected: expectedRows(checkpoint.spec, initialTask, initialPreload), + images: Object.fromEntries(TYPES.map(type => [type, { bytes, type: 'image/png', sha256: sha(bytes) }])) }); sources.push(checkpoint.source) + } + if (completionMode) { + let failed = JSON.parse(manifestBytes) + if (verifyAMode) { + originalBefore = rows(failed.checkpoints).find(row => row.stage === 'A_BEFORE_ORIGINAL_RECOVERY')?.before + need(originalBefore && failed.completionRecovery?.originalReport, 'Original recovery before snapshot required') + const originalBytes = await readFile(failed.completionRecovery.originalReport) + need(sha(originalBytes) === failed.completionRecovery.originalSha256, 'Original failed report SHA changed') + failed = JSON.parse(originalBytes) + } + original = originalCompletionCommand(failed) + const preparationBytes = await readFile(failed.preparedRecovery.report) + need(sha(preparationBytes) === failed.preparedRecovery.sha256, 'Original preparation report SHA changed') + const preparation = JSON.parse(preparationBytes) + for (const checkpoint of validatePreparedResume(preparation)) { + const taskPath = `${PDA}/tasks/${checkpoint.source.pdaTaskId}?warehouseId=${checkpoint.source.warehouseId}` + const preloadPath = `${LEG}/loading-plans/${checkpoint.source.loadingPlanId}?warehouseId=${checkpoint.source.warehouseId}` + const initialTask = rows(preparation.requests).filter(row => row.path === taskPath && row.method === 'GET').at(-1)?.response?.data + const initialPreload = rows(preparation.requests).filter(row => row.path === preloadPath && row.method === 'GET').at(-1)?.response?.data + specs.push({ ...checkpoint, expected: expectedRows(checkpoint.spec, initialTask, initialPreload) }); sources.push(checkpoint.source) + } + } + if (preparedMode) { + const bytes = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64') + for (const checkpoint of validatePreparedResume(JSON.parse(manifestBytes))) { + specs.push({ ...checkpoint, images: Object.fromEntries(TYPES.map(type => [type, { bytes, type: 'image/png', sha256: sha(bytes) }])) }) + sources.push(checkpoint.source) + } + } + for (const spec of manifest?.cases || []) { + const sourceBytes = await readFile(resolve(dirname(options.fixture), spec.sourceReport)) + const source = fixtureSource(JSON.parse(sourceBytes), options.runId, options.gateway) + const images = {} + for (const type of TYPES) { + const bytes = await readFile(resolve(dirname(options.fixture), spec.evidence[type])) + const png = bytes.subarray(0, 8).equals(Buffer.from([137,80,78,71,13,10,26,10])) + const jpeg = bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255 + need(bytes.length > 16 && bytes.length <= 20 * 1024 * 1024 && (png || jpeg), 'Evidence must be supplied bounded PNG/JPEG bytes') + images[type] = { bytes, type: png ? 'image/png' : 'image/jpeg', sha256: sha(bytes) } + } + sources.push(source); specs.push({ spec, source, sourceSha256: sha(sourceBytes), images }) + } + if (!fresh && !bOnlyMode) { + for (const key of ['pdaTaskId', 'loadingPlanId', 'containerBookingId']) need(new Set(sources.map(value => value[key])).size === 2, 'Two independent new cabinet identities required') + need(!sources[0].taskItems.some(a => sources[1].taskItems.some(b => id(a.inboundId) === id(b.inboundId))), 'Cases cannot share source inbound resources') + } + await mkdir(directory) // exclusive; never overwrite evidence or silently rerun business writes + const report = { success: false, runId: options.runId, startedAt: new Date().toISOString(), manifestSha256: sha(manifestBytes), requests: [], cases: [], sessions: {}, + ...(bOnlyMode ? { BContinuation: { verifiedAReport: options.fixture.bOnlyReport, verifiedASha256: sha(manifestBytes), readyCommit: options.readyCommit, aEventProof }, syntheticHttpEvidence: true } : {}), + ...(completionMode ? { completionRecovery: { originalReport: options.fixture.completionReport || options.fixture.verifyAReport, originalSha256: sha(manifestBytes), original, readyCommit: options.readyCommit, BDeferred: true, readOnly: Boolean(verifyAMode) } } : {}), + ...(preparedMode ? { preparedRecovery: { report: options.fixture.preparedReport, sha256: sha(manifestBytes) }, syntheticHttpEvidence: true } : {}), + ...(resume ? { recovery: { originalReport: resume.originalReport, originalSha256: resume.originalSha256, + readbackSha256: resume.readbackSha256, originalOwner: resume.owner, originalCreationSkipped: true, priorRecovery: resume.priorRecovery } } : {}), + scope: completionMode ? ['direct Outbox/ACK audit', 'raw Warehouse SQL audit', 'browser/device acceptance', 'B deferred in this dispatch'] : plan().excluded, + fixtures: specs.map(({ spec, source, sourceSha256, images }) => ({ kind: spec.kind, source, sourceSha256, + ...(images ? { imageHashes: Object.fromEntries(TYPES.map(type => [type, images[type].sha256])) } : {}) })) } + const sessions = {}, save = () => writeFile(resolve(directory, 'report.json'), JSON.stringify(redact(report), null, 2)) + const nativeFetch = globalThis.fetch + let requestQueue = Promise.resolve(), transportUnknown = false + const serialFetch = (url, init) => { + const pending = requestQueue.then(() => { need(!transportUnknown || init.method === 'GET' || new URL(url).pathname === '/api/auth/logout', 'Earlier unknown transport result; stop new commands and preserve samples'); return nativeFetch(url, init) }) + requestQueue = pending.then(() => {}, () => { transportUnknown = true }) + return pending + } + const recordSession = (payload, role) => { + if (payload?.data?.accessToken) { + const already = Object.entries(sessions).find(([, value]) => value.token === payload.data.accessToken) + const name = already?.[0] || (role === 'admin' || role === 'pda' ? role : `customer-${Object.keys(sessions).length}`) + sessions[name] = { token: payload.data.accessToken, refresh: payload.data.refreshToken } + } + } + async function request(method, path, body, role = 'admin', headers = {}) { + need(!verifyAMode || method === 'GET' || ['/api/auth/login','/api/auth/logout'].includes(path), 'A post-recovery verification is read-only') + const auth = path.startsWith('/api/auth/') + const entry = { method, path, role, intendedAt: new Date().toISOString(), state: 'PENDING', + ...(auth ? {} : body instanceof FormData ? { multipart: true } : body === undefined ? {} : { request: body }), + idempotencyKey: headers['Idempotency-Key'] } + if (body instanceof FormData) { + entry.multipartParts = [] + for (const [name, value] of body.entries()) entry.multipartParts.push(typeof value === 'string' ? { name, value } + : { name, fileName: value.name, size: value.size, type: value.type, sha256: sha(new Uint8Array(await value.arrayBuffer())) }) + } + report.requests.push(entry); await save() + try { + const response = await serialFetch(options.gateway + path, requestInit(method, body, sessions[role]?.token, headers)) + const payload = await response.json() + if (path === '/api/auth/login') recordSession(payload, role) + Object.assign(entry, { state: 'RECEIVED', http: response.status, code: payload.code, requestId: payload.requestId, traceId: payload.traceId, + ...(auth ? {} : { response: payload }) }); await save() + return { method, path, http: response.status, body: payload, requestId: payload.requestId, traceId: payload.traceId } + } catch { entry.state = 'UNKNOWN'; await save(); throw new CheckFailure(`Transport/response failed: ${method} ${path}; preserve original request, do not auto-retry`) } + } + try { + for (const [role, username, password] of [['admin', env.CHONGCHONG_ADMIN_USERNAME, env.CHONGCHONG_ADMIN_PASSWORD], ['pda', env.ADMIN_PDA_USERNAME, env.ADMIN_PDA_PASSWORD]]) { + const response = await request('POST', '/api/auth/login', { loginId: username, password }, role) + const data = response.body?.data + if (data?.accessToken) sessions[role] = { token: data.accessToken, refresh: data.refreshToken } + need(response.http === 200 && String(response.body?.code) === '200' && data?.accessToken && data?.refreshToken, 'Incomplete own login session') + report.sessions[role] = { loggedIn: true, closed: false } + } + if (fresh) { + // Reused producers have a few raw binary/multipart fetches. Instrument them too, + // serialize through the same queue, persist intent first, and never expose credentials. + globalThis.fetch = async (url, init = {}) => { + const parsed = new URL(url); need(parsed.origin === options.gateway, 'Producer network must stay in TEST Gateway') + const entry = { method: init.method || 'GET', path: parsed.pathname + parsed.search, producerRaw: true, intendedAt: new Date().toISOString(), state: 'PENDING' } + const form = init.body instanceof FormData + if (form) { + entry.multipart = [] + for (const [name, value] of init.body.entries()) entry.multipart.push(typeof value === 'string' + ? { name, value } : { name, size: value.size, type: value.type, sha256: sha(new Uint8Array(await value.arrayBuffer())) }) + } else if (init.body) { try { entry.request = redact(JSON.parse(init.body)) } catch { entry.requestSha256 = sha(String(init.body)) } } + report.requests.push(entry); await save() + let response + try { response = await serialFetch(url, { ...init, redirect: 'error' }) } + catch { entry.state = 'UNKNOWN'; await save(); throw new CheckFailure(`Producer transport unknown: ${entry.method} ${entry.path}`) } + const bytes = new Uint8Array(await response.clone().arrayBuffer()) + entry.http = response.status; entry.state = 'RECEIVED' + if ((response.headers.get('content-type') || '').includes('json')) { + const payload = JSON.parse(new TextDecoder().decode(bytes)); entry.code = payload.code + entry.requestId = payload.requestId; entry.traceId = payload.traceId; entry.response = redact(payload) + if (parsed.pathname === '/api/auth/login') recordSession(payload, 'producer') + } else { entry.size = bytes.length; entry.sha256 = sha(bytes) } + await save(); return response + } + const { AcceptanceRun, parseArguments } = await import('./admin-test-env-acceptance.mjs') + const context = new AcceptanceRun({ ...parseArguments(['--modules','orders,inbound-measurement','--quiet','true']), gateway: options.gateway, + username: env.CHONGCHONG_ADMIN_USERNAME, password: env.CHONGCHONG_ADMIN_PASSWORD, customerName: '', waitSplitCount: 1, + routeMode: 'fixed', orderProfileMode: 'fixed' }) + context.token = sessions.admin.token; context.pdaToken = sessions.pda.token + const originalStep = context.addStep.bind(context) + context.addStep = (...args) => { const passed = originalStep(...args); if (!passed) throw new CheckFailure(`Fresh producer gate failed: ${args[0]} / ${args[1]}`); return passed } + const roleFor = token => token ? Object.entries(sessions).find(([, value]) => value.token === token)?.[0] || 'admin' : 'producer' + context.request = (method, path, body, token = context.token, headers = {}) => { + need(method === 'GET' || !/^\/api\/platform\/v1\/(suppliers|business-partners|ocean-bookings\/channels)/.test(path), 'Shared booking master data writes require controller coordination') + return request(method, path, body, roleFor(token), headers) + } + const adapter = multipartAdapter(request, roleFor) + context.requestMultipart = (method, path, file, token = context.token, headers = {}) => adapter(method, path, file, token, headers) + const freshCases = await prepareFreshCases(options.runId, context, async checkpoint => { + report.preparation ||= []; report.preparation.push(checkpoint); await save() + }, resume) + globalThis.fetch = nativeFetch + const bytes = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64') + report.syntheticHttpEvidence = true // controlled HTTP fixture; never camera/device proof + for (const value of freshCases) specs.push({ ...value, images: Object.fromEntries(TYPES.map(type => [type, { bytes, type: 'image/png', sha256: sha(bytes) }])) }) + report.fixtures = specs.map(({ spec, source, owners }) => ({ kind: spec.kind, source, owners })) + await save() + } + const ok = response => { need(response.http === 200 && String(response.body?.code) === '200', `Preflight API rejected ${response.path}`); return response.body.data } + const ownPdaId = JSON.parse(Buffer.from(sessions.pda.token.split('.')[1], 'base64url').toString()).sub + need(positive(ownPdaId), 'Own PDA subject required') + // Both cabinets are checked before the first claim/batch/evidence/complete write. + if (completionMode) { + const checkpoint = async snapshot => { + report.checkpoints ||= []; report.checkpoints.push(snapshot); await save() + } + report.cases.push(verifyAMode ? await verifyRecoveredA(specs[0], original, originalBefore, request, checkpoint) + : await recoverOriginalA(specs[0], original, request, checkpoint)) + report.success = true // this dispatch is A-only; B is explicitly deferred and untouched + } else { + for (const item of specs) { + const { source, spec } = item, path = `${PDA}/tasks/${source.pdaTaskId}` + const task = ok(await request('GET', `${path}?warehouseId=${source.warehouseId}`, undefined, 'pda')) + validateZeroWorkTask(source, task, ownPdaId) + const originalIds = source.taskItems.map(row => id(row.id)).sort() + assert.deepEqual(task.items.map(row => id(row.id)).sort(), originalIds, 'Source fixture item set') + const preload = ok(await request('GET', `${LEG}/loading-plans/${source.loadingPlanId}?warehouseId=${source.warehouseId}`)) + need(['EDITING', 'LOADING'].includes(preload.summary.status), 'Zero-pick preload must be EDITING or own claimed LOADING') + const operations = ok(await request('GET', `${path}/operations`, undefined, 'pda')) + need(!rows(operations).some(row => ['BATCH', 'COMPLETE_LOADING'].includes(row.actionType)) + && rows(operations).filter(row => row.actionType === 'CLAIM').every(row => row.operationId === `${source.sourceMarker}-claim`), + 'No earlier physical picking/completion or foreign claim allowed') + const evidence = ok(await request('GET', `${path}/evidence?warehouseId=${source.warehouseId}`, undefined, 'pda')) + need(!rows(evidence.evidences).some(row => row.status === 'ACTIVE'), 'New task must have empty active evidence baseline') + item.task = task; item.expected = expectedRows(spec, task, preload) + } + for (const item of specs) { + const result = await runCase(item.spec, item.source, item, request) + if (bOnlyMode) { + const downstream = await waitDownstream(item, request, ms => new Promise(done => setTimeout(done, ms))) + result.downstream = downstream.facts; report.BAfter = { source: await sourceSnapshot(item, request), downstream } + } + report.cases.push(result); await save() + } + report.success = report.cases.length === (bOnlyMode ? 1 : 2) + } + } catch (error) { + report.failure = error instanceof CheckFailure || error instanceof assert.AssertionError ? error.message : 'Local response/fixture format failure' + } finally { + globalThis.fetch = nativeFetch + // A transport-unknown business command stops all further business writes; + // own session logout is still allowed and does not replay that command. + transportUnknown = false + for (const [role, session] of Object.entries(sessions).reverse()) { + try { + const response = await request('POST', '/api/auth/logout', { refreshToken: session.refresh }, role) + const closed = response.http === 200 && String(response.body?.code) === '200' + report.sessions[role] = { loggedIn: true, closed }; if (!closed) report.success = false + } catch { report.sessions[role] = { loggedIn: true, closed: false }; report.success = false } + } + report.finishedAt = new Date().toISOString(); await save() + } + return { success: report.success, report: resolve(directory, 'report.json') } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + execute().then(result => { console.log(JSON.stringify(result)); if (result.success === false) process.exitCode = 1 }) + .catch(() => { console.error('Partial-loading acceptance did not start; check TEST env-file, new run-id and legal fixture manifest.'); process.exitCode = 1 }) +} diff --git a/scripts/partial-loading-actual-test-env.test.mjs b/scripts/partial-loading-actual-test-env.test.mjs new file mode 100644 index 000000000..c8ddc93ca --- /dev/null +++ b/scripts/partial-loading-actual-test-env.test.mjs @@ -0,0 +1,349 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { plan, execute, executionOptions, validateManifest, fixtureSource, expectedRows, scale, verifyActual, runCase, redact, + multipartAdapter, requestInit, validateResumeProof, validateResumeLive, resumeMeasurementDraft, validateZeroWorkTask, validatePreparedResume, + originalCompletionCommand, verifyPhysicalFacts, verifyOldOwnersUnchanged, verifyDownstream } from './partial-loading-actual-test-env.mjs' +import { fileURLToPath } from 'node:url' +import { resolve } from 'node:path' +import { createServer } from 'node:http' + +const runId = 'PARTIAL-ACTUAL-offline' +const evidence = Object.fromEntries(['PHOTO_STEP_1','PHOTO_STEP_2','PHOTO_STEP_3','PHOTO_STEP_4','SEAL'].map(type => [type, 'sample.png'])) +function fixture(kind = 'B') { + const definitions = kind === 'B' ? [[1, 11, 2, 'BUY_DECLARATION'], [2, 22, 1, 'TAX_REFUND']] + : [[1, 11, 3, 'TAX_REFUND'], [2, 11, 1, 'TAX_REFUND'], [3, 22, 1, 'BUY_DECLARATION'], [4, 33, 1, 'TAX_REFUND']] + const task = { id: '91', loadingPlanId: '90', containerBookingId: '92', version: 0, + items: definitions.map(([n, tenant, count]) => ({ id: String(n), loadingPlanItemId: String(n + 100), tenantId: String(tenant), + inboundId: String(kind === 'A' && n === 2 ? 1 : n), inboundDetailId: String(n + 200), plannedPackageCount: count, + pickedPackageCount: 0, itemStatus: 'PENDING', unitType: 'PACKAGE', palletId: null, version: 0 })) } + const preload = { items: definitions.map(([n, tenant, count, tax]) => ({ itemId: String(n + 100), accountId: String(tenant), + inboundId: String(kind === 'A' && n === 2 ? 1 : n), inboundDetailId: String(n + 200), customsTaxMode: tax, orderId: String(n + 300), + packageCount: count, productQuantity: count * 7, unitsPerPackage: 7, grossWeightKg: '10.001', netWeightKg: '8.002', dimensionalWeightKg: '6.004', volumeCbm: '0.1001' })) } + const spec = { kind, sourceReport: `${kind}.json`, evidence, sealNo: 'SEAL-TEST', actions: definitions.map(([n]) => ({ itemId: String(n), + action: n === 1 ? 'PARTIAL' : kind === 'A' && n === 3 ? 'PICK' : 'SKIP', ...(n === 1 ? { finalPickedPackageCount: kind === 'A' ? 2 : 1 } : {}) })) } + const expected = expectedRows(spec, task, preload) + const actualItems = expected.filter(row => row.actual > 0).map(row => ({ ...row.frozen, packageCount: row.actual, + productQuantity: row.actual * 7, ...Object.fromEntries(['grossWeightKg','netWeightKg','dimensionalWeightKg','volumeCbm'].map(field => + [field, scale(row.frozen[field], row.actual, row.planned, field === 'volumeCbm' ? 4 : 3)])) })) + const sum = (field, digits) => Number(actualItems.reduce((value, item) => value + BigInt(scale(item[field], 1, 1, digits).replace('.', '')), 0n)) / 10 ** digits + const detail = { items: actualItems, summary: { totalPackageCount: actualItems.reduce((sum, row) => sum + row.packageCount, 0), + totalQuantity: actualItems.reduce((sum, row) => sum + row.productQuantity, 0), totalGrossWeightKg: sum('grossWeightKg', 3), + totalNetWeightKg: sum('netWeightKg', 3), totalVolumeCbm: sum('volumeCbm', 4) } } + const customs = { actualBoxCount: detail.summary.totalPackageCount, inbounds: expected.filter(row => row.actual > 0), + taxRefundCount: kind === 'B' ? 0 : 1, version: 0, status: kind === 'B' ? 'READY_TO_START' : 'WAITING_CUSTOMER_DOCUMENTS' } + const source = { pdaTaskId: '91', loadingPlanId: '90', containerBookingId: '92', warehouseId: '1', sourceMarker: `${runId}-${kind}` } + return { task, preload, spec, expected, detail, customs, source } +} + +test('default plan and --plan make zero network calls', async () => { + const original = globalThis.fetch + globalThis.fetch = () => { throw Error('No network in plan') } + try { assert.deepEqual(await execute([], [], {}), plan()); assert.equal((await execute(['--plan'], [], {})).networkRequests, 0) } + finally { globalThis.fetch = original } +}) + +test('execution refuses absent explicit TEST/profile/cwd', () => { + assert.throws(() => executionOptions(['--execute'], [], {})) + assert.throws(() => executionOptions(['--execute','--environment=TEST',`--run-id=${runId}`,'--fixture=x.json'], [], {}, 'C:/other')) +}) + +test('NEW mode uses the configured /api base and does not accept a bare origin', () => { + const cwd = fileURLToPath(new URL('..', import.meta.url)) + const env = { CHONGCHONG_ENV: 'test', CHONGCHONG_ADMIN_API_BASE_URL: 'https://test.invalid/api', + CHONGCHONG_ADMIN_USERNAME: 'admin', CHONGCHONG_ADMIN_PASSWORD: 'test-only', ADMIN_PDA_USERNAME: 'pda', ADMIN_PDA_PASSWORD: 'test-only' } + const args = ['--execute','--environment=TEST',`--run-id=${runId}`,'--fixture=NEW'] + const execArgs = [`--env-file=${resolve('/.codex/env/chongchong-global/test-acceptance.env')}`] + assert.equal(executionOptions(args, execArgs, env, cwd).fixture, 'NEW') + assert.equal(executionOptions(args, execArgs, env, cwd).gateway, 'https://test.invalid') + assert.throws(() => executionOptions(args, execArgs, { ...env, CHONGCHONG_ADMIN_API_BASE_URL: 'https://test.invalid' }, cwd), /end in/) +}) + +test('nested generated customer passwords and login/refresh tokens never reach saved evidence', () => { + const value = redact({ requests: [{ response: { data: { temporaryPassword: 'SECRET', nested: { accessToken: 'TOKEN', refreshToken: 'REFRESH' }, tenantId: '11' } } }] }) + const json = JSON.stringify(value) + assert.ok(!json.includes('SECRET') && !json.includes('TOKEN') && !json.includes('REFRESH')) + assert.ok(json.includes('"tenantId":"11"')) +}) + +test('real HTTP multipart adapter preserves descriptor bytes, existing form fields and generated boundary', async () => { + const received = [], original = Uint8Array.from([137,80,78,71,13,10,26,10,0,255,10,13,0,200]) + const server = createServer(async (request, response) => { + const chunks = []; for await (const chunk of request) chunks.push(chunk) + received.push({ headers: request.headers, body: Buffer.concat(chunks), method: request.method }) + response.writeHead(200, { 'Content-Type': 'application/json' }); response.end('{"code":"200"}') + }) + await new Promise(done => server.listen(0, '127.0.0.1', done)) + try { + const base = `http://127.0.0.1:${server.address().port}` + const adapter = multipartAdapter((method, path, body, role, headers) => { + assert.equal(role, 'admin'); return fetch(base + path, requestInit(method, body, 'test-token', headers)) + }, () => 'admin') + await adapter('POST', '/upload', { content: original, type: 'image/png', name: 'weight.png' }, 'token') + const form = new FormData(); form.append('expectedVersion', '4'); form.append('file', new Blob([original], { type: 'image/png' }), 'original.png') + await adapter('POST', '/upload', form, 'token', { 'Content-Type': 'application/json', 'Idempotency-Key': 'same-key' }) + assert.equal(received.length, 2) + for (const request of received) { + assert.match(request.headers['content-type'], /^multipart\/form-data; boundary=/) + const boundary = request.headers['content-type'].split('boundary=')[1] + assert.ok(request.body.subarray(0, boundary.length + 2).equals(Buffer.from(`--${boundary}`))) + assert.ok(request.body.includes(Buffer.from(original)), 'The original byte array reaches HTTP without JSON conversion') + assert.ok(request.body.includes(Buffer.from('name="file"'))) + assert.ok(!request.body.includes(Buffer.from('name="content"'))) + assert.equal(request.method, 'POST') + } + assert.ok(received[1].body.includes(Buffer.from('name="expectedVersion"\r\n\r\n4'))) + assert.equal(received[1].headers['idempotency-key'], 'same-key') + } finally { server.closeAllConnections(); await new Promise(done => server.close(done)) } +}) + +test('explicit resume proves the original failed sample and refuses state/identity drift before writes', () => { + const summary = { id: '2106706513799155714', tenantId: '2106706477225971713', orderId: '2106706495895306242', + orderNo: 'FL2106706495895306242', inboundCode: 'NYEXX2610040025', processStatus: 'WAIT_MEASURE', version: 4 } + const measurement = { measurementId: '2106706548955811842', measurementStatus: 'DRAFT', version: 4, weightResults: [] } + const readback = { verified: true, sessionClosed: true, inbound: { summary }, measurement } + const report = { runId: 'PARTIAL-ACTUAL-20261004-r1', success: false, cases: [], requests: [{ path: '/api/platform/v1/files/measurements/2106706477225971713/2106706513799155714', http: 200, code: 'S0500', requestId: 'original-id' }] } + const owner = validateResumeProof(report, readback) + validateResumeLive(owner, readback, readback.inbound, measurement) + assert.throws(() => validateResumeProof({ ...report, runId: 'unrelated' }, readback), /explicitly authorized/) + assert.throws(() => validateResumeLive(owner, readback, { summary: { ...summary, version: 5 } }, measurement), /changed/) + assert.throws(() => validateResumeLive(owner, readback, readback.inbound, { ...measurement, weightResults: [{ id: '1' }] }), /changed/) + assert.throws(() => validateResumeProof(report, { ...readback, measurement: { ...measurement, measurementStatus: 'APPROVED' } }), /Frozen/) +}) + +test('original draft resumes by upload/measurement only, with independent 3+1 SKU rows', async () => { + const owner = { tenantId: '11', inboundId: '12', orderId: '13', marker: 'ORIGINAL' } + const initial = { measurementId: '14', version: 5, latestDeclaration: { items: [ + { inboundDetailId: '21', itemNo: 1, cargoName: 'SKU1', packageCount: 3, lengthCm: 30, widthCm: 20, heightCm: 20 }, + { inboundDetailId: '22', itemNo: 2, cargoName: 'SKU2', packageCount: 1, lengthCm: 40, widthCm: 30, heightCm: 30 }] } } + const calls = []; let approved = false + const context = { token: 'own-token', createdResources: {}, request: async (method, path, body) => { + calls.push({ method, path, body }); let data + if (path.includes('/files/measurements/')) data = { fileId: '41' } + else if (path.includes('/measurement/draft?')) data = { measurementId: '14' } + else if (path.includes('/weight-pallet-options?')) data = { records: [{ palletId: '51', palletCode: 'YW-TP-002' }] } + else if (path.includes('/weight-results/61/confirm?')) data = { recognitionStatus: 'CONFIRMED' } + else if (path.includes('/weight-results?')) data = { id: '61', version: 0 } + else if (path.includes('/measurement/submit?')) data = { processStatus: 'MEASURE_PENDING' } + else if (path.includes('/measurements/approve?')) { approved = true; data = {} } + else if (path.includes('/measurement?')) data = { measurementId: '14', version: 6, measurementStatus: approved ? 'APPROVED' : 'DRAFT', + processStatus: approved ? 'WAIT_SPLIT' : 'WAIT_MEASURE', items: initial.latestDeclaration.items } + else if (path.startsWith('/api/platform/inbounds/')) data = { summary: { version: 7 } } + else throw Error('Unexpected original-recovery endpoint') + return { path, http: 200, body: { code: '200', data } } + } } + context.requestMultipart = multipartAdapter(context.request, () => 'admin') + await resumeMeasurementDraft(context, owner, initial) + assert.equal(context.createdResources.inboundMeasurement.inboundId, '12') + assert.equal(context.createdResources.inboundMeasurement.processStatus, 'WAIT_SPLIT') + assert.ok(calls[0].body instanceof FormData) + assert.deepEqual(calls.find(call => call.path.includes('/measurement/draft?')).body.items.map(item => [item.inboundDetailId, item.packageCount]), [['21',3],['22',1]]) + assert.ok(!calls.some(call => /carton-scans|\/iam\/tenants|\/orders/.test(call.path)), 'No qualification, creation, order submission or re-scan') +}) + +test('zero-work checkpoint accepts own preassignment but rejects another operator or any physical pick', () => { + const { source, task } = fixture() + task.taskStatus = 'LOADING'; task.workflowStatus = 'IN_PROGRESS'; task.assignment = { assigneeId: '99' } + source.beforeTaskVersion = 0 + validateZeroWorkTask(source, task, '99') + assert.throws(() => validateZeroWorkTask(source, task, '98'), /own PDA/) + task.items[0].pickedPackageCount = 1 + assert.throws(() => validateZeroWorkTask(source, task, '99'), /physical work/) +}) + +test('prepared continuation binds the two original-source checkpoints and refuses to redo PDA writes', () => { + const report = { runId: 'PARTIAL-ACTUAL-20261004-recovery-r3', success: false, cases: [], requests: [], + recovery: { originalOwner: { inboundId: '2106706513799155714' }, originalSha256: 'frozen-original-hash' }, + preparation: ['A','B'].map(kind => ({ kind, stage: 'PDA_UNASSIGNED', source: { sourceMarker: `PARTIAL-ACTUAL-20261004-recovery-r3-${kind}` }, spec: { kind } })) } + assert.equal(validatePreparedResume(report).length, 2) + assert.throws(() => validatePreparedResume({ ...report, requests: [{ method: 'POST', path: '/api/platform/v1/first-leg/pda-loading/tasks/91/batch' }] }), /repeat any PDA/) + assert.throws(() => validatePreparedResume({ ...report, cases: [{ completed: true }] }), /Only this/) +}) + +test('A recovery takes only the original command id and immutable version/operation/remark', () => { + const body = { expectedTaskVersion: 2, operationId: 'PARTIAL-ACTUAL-20261004-recovery-r3-A-complete', remark: 'PARTIAL-ACTUAL-20261004-recovery-r3-A' } + const report = { runId: 'PARTIAL-ACTUAL-20261004-recovery-r7', success: false, requests: [{ method: 'POST', + path: '/api/platform/v1/first-leg/pda-loading/tasks/2106713892758491138/complete', state: 'RECEIVED', http: 200, code: '200', request: body, + response: { data: { completionCommand: { id: '2106716718935052290', operationId: body.operationId, expectedTaskVersion: 2 } } } }] } + assert.deepEqual(originalCompletionCommand(report), { commandId: '2106716718935052290', body }) + const changed = structuredClone(report); changed.requests[0].request.expectedTaskVersion = 4 + assert.throws(() => originalCompletionCommand(changed), /immutable/) + const duplicated = structuredClone(report); duplicated.requests.push(duplicated.requests[0]) + assert.throws(() => originalCompletionCommand(duplicated), /Exactly one/) +}) + +test('physical A facts exclude skipped rows and unchanged owners/stock detect a repeated completion or deduction', () => { + const sample = fixture('A') + sample.task.items = sample.task.items.map(row => ({ ...row, itemStatus: { PARTIAL: 'PARTIAL', PICK: 'PICKED', SKIP: 'SKIPPED' }[sample.expected.find(item => item.pickId === row.id).action], + pickedPackageCount: sample.expected.find(item => item.pickId === row.id).actual })) + verifyPhysicalFacts(sample.expected, sample.task) + const drift = structuredClone(sample.task); drift.items[0].pickedPackageCount++ + assert.throws(() => verifyPhysicalFacts(sample.expected, drift), /drift/) + const before = { sources: [{ owner: { inboundId: '1' }, view: { summary: { id: '1', processStatus: 'LOADED', version: 12 }, statusHistory: [{ action: 'COMPLETE_LOADING' }], palletLocations: [] } }], inventory: { inbounds: [] } } + verifyOldOwnersUnchanged(before, structuredClone(before), sample.expected) + const repeated = structuredClone(before); repeated.sources[0].view.statusHistory.push({ action: 'COMPLETE_LOADING' }) + assert.throws(() => verifyOldOwnersUnchanged(before, repeated, sample.expected), /must not repeat/) + const deducted = structuredClone(before); deducted.inventory.inbounds.push({ availablePackageCount: 1 }) + assert.throws(() => verifyOldOwnersUnchanged(before, deducted, sample.expected), /deducted again/) +}) + +test('transit evidence uses raw balance identity and excludes transit from physical total, with no skipped source or duplicate ledger', () => { + const sample = fixture('A'); sample.source.containerNo = 'TEST-CONTAINER' + for (const row of sample.expected) { row.frozen.inboundCode = `IN-${row.inboundId}`; row.frozen.skuCode = `SKU-${row.itemId}` } + const snapshot = ['1','3','4'].map(inboundId => { + const active = sample.expected.filter(row => row.inboundId === inboundId && row.actual > 0), ownerRow = sample.expected.find(row => row.inboundId === inboundId) + const owner = { inboundId, tenantId: ownerRow.accountId }, count = active.reduce((sum,row) => sum + row.actual, 0) + if (!count) return { owner, orders: [], balances: [], ledger: [], details: [] } + const order = { id: `100${inboundId}`, orderNo: `LL-${inboundId}`, customerId: owner.tenantId, sourceInboundId: inboundId, + sourceContainerBookingId: sample.source.containerBookingId, containerNo: sample.source.containerNo, packageCount: count } + return { owner, orders: [order], details: [{ cargo: active.map(row => ({ skuCode: row.frozen.skuCode, packageCount: row.actual })), + statusHistory: [{ toStatus: 'PENDING_ASSIGNMENT', remark: '头程装柜完成自动生成' }] }], + balances: [{ customerId: owner.tenantId, inboundCode: `IN-${inboundId}`, inboundOrderId: order.id, inTransitPackages: count, + totalPackages: 0, availablePackages: 0, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0, procurementPackages: 0 }], + ledger: [{ customerId: owner.tenantId, inboundCode: `IN-${inboundId}`, inboundOrderId: order.id, businessOrderNo: order.orderNo, + beforePackages: 0, deltaPackages: count, afterPackages: count }] } + }) + assert.equal(verifyDownstream(sample, snapshot).actualPackages, 3) + const doubled = structuredClone(snapshot); doubled[0].ledger.push(doubled[0].ledger[0]) + assert.throws(() => verifyDownstream(sample, doubled), /inventory movement/) + const grouped = structuredClone(snapshot); grouped[0].balances[0].inboundOrderId = null + assert.throws(() => verifyDownstream(sample, grouped), /transit inventory/) + const skipped = structuredClone(snapshot); skipped[2].orders.push(snapshot[0].orders[0]) + assert.throws(() => verifyDownstream(sample, skipped), /Skipped owner/) +}) + +test('manifest needs two ordered cases, unique actions and all five photos', () => { + const manifest = { schemaVersion: 1, runId, environment: 'TEST', cases: [fixture('A').spec, fixture('B').spec] } + assert.equal(validateManifest(manifest, runId), manifest) + const duplicate = structuredClone(manifest); duplicate.cases[1].actions[1].itemId = '1' + assert.throws(() => validateManifest(duplicate, runId), /unique/) + const missing = structuredClone(manifest); delete missing.cases[0].evidence.SEAL + assert.throws(() => validateManifest(missing, runId), /Five/) +}) + +test('official source must be fresh, passing, unclaimed and run-bound', () => { + const now = Date.parse('2026-10-04T12:00:00Z'), report = { success: true, fatal: null, summary: { failed: 0 }, gateway: 'https://test.invalid', + createdResources: { manualPdaBatchFixture: { ...fixture().source, schemaVersion: 1, stage: 'MANUAL_PDA_CLAIM_AND_BATCH_READY', + preparedAt: new Date(now).toISOString(), taskItems: fixture().task.items } } } + assert.ok(fixtureSource(report, runId, report.gateway, now)) + assert.throws(() => fixtureSource(report, runId, report.gateway, now + 25 * 3600_000), /within/) + const old = structuredClone(report); old.createdResources.manualPdaBatchFixture.sourceMarker = 'old' + assert.throws(() => fixtureSource(old, runId, report.gateway, now), /run-bound/) + const failed = structuredClone(report); failed.summary.failed = 1 + assert.throws(() => fixtureSource(failed, runId, report.gateway, now), /did not pass/) +}) + +test('exact HALF_UP handles non-half ratios, ties, nullable metrics and invalid ratios', () => { + assert.equal(scale('10.001', 2, 3, 3), '6.667') + assert.equal(scale('8.002', 2, 3, 3), '5.335') + assert.equal(scale('0.1001', 2, 3, 4), '0.0667') + assert.equal(scale('1', 1, 2, 0), '1') + assert.equal(scale(null, 1, 2, 3), null) + assert.throws(() => scale('10', 4, 3, 3)) +}) + +test('A and B require the exact actual/skip shapes before any mutation', () => { + assert.equal(fixture('A').expected.filter(row => row.actual > 0).length, 2) + assert.ok(fixture('B').expected.filter(row => row.actual > 0).every(row => row.action === 'PARTIAL')) + const { spec, task, preload } = fixture('B') + spec.actions[0] = { itemId: '1', action: 'PICK' } + assert.throws(() => expectedRows(spec, task, preload), /exactly one/) + spec.actions[0] = { itemId: '1', action: 'PARTIAL', finalPickedPackageCount: 2 } + assert.throws(() => expectedRows(spec, task, preload), /less than/) +}) + +test('duplicate, missing, already picked and wrong-customer source rows reject preflight', () => { + const a = fixture('A'); a.spec.actions.pop() + assert.throws(() => expectedRows(a.spec, a.task, a.preload), /cover every/) + const b = fixture(); b.task.items[0].pickedPackageCount = 1 + assert.throws(() => expectedRows(b.spec, b.task, b.preload), /zero-picked/) + const c = fixture(); c.preload.items[0].accountId = '999' + assert.throws(() => expectedRows(c.spec, c.task, c.preload), /identity/) +}) + +test('actual scope, metrics and refund customer/coverage are independently checked', () => { + for (const kind of ['A', 'B']) { + const sample = fixture(kind) + assert.equal(verifyActual(sample.expected, sample.detail, sample.customs).actualRows, kind === 'A' ? 2 : 1) + const contaminated = structuredClone(sample.detail); contaminated.items.push(sample.preload.items.at(-1)) + assert.throws(() => verifyActual(sample.expected, contaminated, sample.customs), /Actual loading row set/) + const stale = structuredClone(sample.customs); stale.actualBoxCount++ + assert.throws(() => verifyActual(sample.expected, sample.detail, stale), /Customs actual boxes/) + const wrong = structuredClone(sample.customs); wrong.inbounds[0].accountId = '999' + assert.throws(() => verifyActual(sample.expected, sample.detail, wrong), /Customs source/) + } +}) + +function transport(sample, { drift = false, failCompletion = false, incomplete = false } = {}) { + const calls = [], task = structuredClone(sample.task), customs = structuredClone(sample.customs) + const history = [], pda = `/api/platform/v1/first-leg/pda-loading/tasks/${sample.source.pdaTaskId}` + let completes = 0 + return { calls, request: async (method, path, body, role, headers) => { + calls.push({ method, path, body: body instanceof FormData ? 'multipart' : structuredClone(body), role, headers }) + let data + if (method === 'POST' && path === `${pda}/claim`) { task.workflowStatus = 'IN_PROGRESS'; data = task } + else if (method === 'POST' && /\/items\/\d+\/skip$/.test(path)) { + const itemId = path.split('/').at(-2) + task.items = task.items.map(row => row.id === itemId ? { ...row, itemStatus: 'SKIPPED', pickedPackageCount: 0 } : row) + data = task + } + else if (method === 'POST' && path === `${pda}/batch`) { + task.workflowStatus = 'AWAITING_REVIEW'; task.items = task.items.map(row => { + const expected = sample.expected.find(item => item.pickId === row.id) + return { ...row, itemStatus: { PICK: 'PICKED', PARTIAL: 'PARTIAL', SKIP: 'SKIPPED' }[expected.action], pickedPackageCount: expected.actual } + }); data = task + } else if (method === 'POST' && path === `${pda}/evidence`) data = {} + else if (path.startsWith(`${pda}/evidence?`)) data = { evidences: Object.keys(evidence).map(type => ({ evidenceType: type, status: 'ACTIVE', sealNo: sample.spec.sealNo })) } + else if (method === 'POST' && path === `${pda}/complete`) { + completes++ + if (failCompletion) throw Error('Response lost') + task.taskStatus = incomplete ? 'COMPLETING' : 'COMPLETED'; task.workflowStatus = incomplete ? 'COMPLETING' : 'COMPLETED' + if (completes === 1) history.push({ actionType: 'COMPLETE_LOADING' }) + if (drift && completes > 1) task.version++ + data = task + } else if (path.startsWith(`${pda}/inventory-snapshot?`)) data = { snapshotToken: 'fresh-snapshot' } + else if (path.startsWith(`${pda}?`)) data = task + else if (path === `${pda}/operations`) data = history + else if (path.includes('/loading-lists/')) data = sample.detail + else if (path.endsWith('/send-to-broker')) return { path, http: 200, body: { code: 'A0409' } } + else if (path.endsWith('/start')) { customs.status = 'DECLARING'; customs.version++; data = customs } + else if (path.endsWith('/complete-all-buy')) { customs.status = 'COMPLETED'; customs.version++; data = customs } + else if (path.includes('/customs/loading-plans/')) data = customs + else throw Error(`Unexpected ${method} ${path}`) + return { path, http: 200, body: { code: '200', data: structuredClone(data) } } + } } +} + +test('both cases execute actual completion; B is all PARTIAL and follows all-buy gates', async () => { + for (const kind of ['A', 'B']) { + const sample = fixture(kind), http = transport(sample), images = Object.fromEntries(Object.keys(evidence).map(type => [type, { bytes: new Uint8Array([1,2,3]), type: 'image/png' }])) + const result = await runCase(sample.spec, sample.source, { ...sample, images }, http.request, async () => {}) + assert.equal(result.completed, true); assert.equal(result.allActualRowsPartial, kind === 'B') + const completes = http.calls.filter(call => call.path.endsWith('/complete')) + assert.equal(completes.length, 2); assert.deepEqual(completes[0].body, completes[1].body) + const batches = http.calls.filter(call => call.path.endsWith('/batch')) + assert.equal(batches.length, 1) + if (kind === 'A') { + const skipIndex = http.calls.findIndex(call => /\/items\/\d+\/skip$/.test(call.path)) + assert.ok(skipIndex >= 0 && skipIndex < http.calls.findIndex(call => call.path.endsWith('/batch'))) + assert.ok(!batches[0].body.items.some(item => item.itemId === '2'), 'Resolved same-owner SKIP does not repeat in full pending batch') + } + if (kind === 'B') assert.ok(http.calls.some(call => call.path.endsWith('/complete-all-buy'))) + } +}) + +test('replay version/history drift is a hard failure', async () => { + const sample = fixture(), http = transport(sample, { drift: true }), images = Object.fromEntries(Object.keys(evidence).map(type => [type, { bytes: new Uint8Array([1]), type: 'image/png' }])) + await assert.rejects(runCase(sample.spec, sample.source, { ...sample, images }, http.request), /zero projection/) +}) + +test('unknown completion result is never retried and no later customs write is attempted', async () => { + const sample = fixture(), http = transport(sample, { failCompletion: true }), images = Object.fromEntries(Object.keys(evidence).map(type => [type, { bytes: new Uint8Array([1]), type: 'image/png' }])) + await assert.rejects(runCase(sample.spec, sample.source, { ...sample, images }, http.request), /Response lost/) + assert.equal(http.calls.filter(call => call.path.endsWith('/complete')).length, 1) + assert.equal(http.calls.filter(call => call.method === 'POST' && call.path.includes('/customs/')).length, 0) +}) + +test('accepted completion without terminal state fails rather than claiming success', async () => { + const sample = fixture(), http = transport(sample, { incomplete: true }), images = Object.fromEntries(Object.keys(evidence).map(type => [type, { bytes: new Uint8Array([1]), type: 'image/png' }])) + await assert.rejects(runCase(sample.spec, sample.source, { ...sample, images }, http.request, async () => {}), /polling exhausted/) +}) diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs index c21e5a98f..9d8498e37 100644 --- a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -2,10 +2,78 @@ import test from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { resolve } from 'node:path' -import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs' +import { AcceptanceRun, configureReadOnlyEvidence, unloadRecoveryWasVerified, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs' const source = readFileSync(resolve('scripts/admin-test-env-acceptance.mjs'), 'utf8') +test('full CLI evidence preflight attaches readers and fails before business writes when unavailable', async () => { + const run = { options: { modules: ['manual-state-matrix'] }, context: {} } + const readReceiving = () => {}, readCompletion = () => {} + let checked = false + await configureReadOnlyEvidence(run, {}, () => ({ readReceiving, readCompletion, + preflight: async () => { checked = true } })) + assert.equal(checked, true) + assert.equal(run.context.phase4ReceivingEvidenceReader, readReceiving) + assert.equal(run.context.f40ReadOnlyOutboxEvidence, readCompletion) + const unavailable = { options: run.options, context: {} } + await assert.rejects(configureReadOnlyEvidence(unavailable, {}, () => ({ + preflight: async () => { throw Error('evidence unavailable') } })), /unavailable/) + assert.deepEqual(unavailable.context, {}) +}) + +test('explicit unloading fault uses a separate first-container hook and restores both directions on failure', async () => { + const calls = [] + const run = { marker: 'TEST-UNLOAD', options: { modules: ['phase4-overseas-pda', 'phase4-cross-region-events'], + verifyUnloadEventRecovery: true }, context: {} } + const fault = name => ({ prepare: async scope => calls.push([name, 'prepare', scope]), + readProof: async scope => ({ name, scope, restored: true }), + cleanup: async () => { calls.push([name, 'cleanup']); if (name === 'reverse') throw Error('recovery failed') } }) + const cleanup = await configureReadOnlyEvidence(run, {}, () => ({ readReceiving() {}, readCompletion() {}, preflight: async () => {} }), + { createTestFaultExecutor: () => fault('forward'), createTestUnloadFaultExecutor: () => fault('reverse') }) + assert.equal(calls.length, 0) + await run.context.beforeReceivingComplete({ customerId: '7', bookingId: '66' }) + await run.context.beforeReceivingComplete({ customerId: '8', bookingId: '77' }) + assert.deepEqual(calls, [['reverse', 'prepare', { marker: 'TEST-UNLOAD', customerId: '7', containerBookingId: '66' }]]) + assert.equal(await run.context.afterReceivingComplete({ customerId: '8', bookingId: '77' }), null) + const proof = await run.context.afterReceivingComplete({ customerId: '7', bookingId: '66' }) + assert.equal(proof.name, 'reverse') + await assert.rejects(cleanup.cleanup(), /恢复失败/) + assert.deepEqual(calls.slice(-2), [['reverse', 'cleanup'], ['forward', 'cleanup']]) +}) + +test('unloading recovery is opt-in and cannot run against unrelated modules', () => { + const args = ['--modules', 'phase4-overseas-return-followups', '--gateway', 'http://localhost:9000', + '--username', 'contract', '--password', 'contract'] + assert.equal(parseArguments(args).verifyUnloadEventRecovery, false) + assert.throws(() => validateExecutionOptions(parseArguments([...args, '--verify-unload-event-recovery', 'true'])), /收货链/) +}) + +test('requested unloading fault cannot pass without exact current-run recovery and inventory proof', () => { + const run = { marker: 'CURRENT', createdResources: {} } + assert.equal(unloadRecoveryWasVerified(run), false) + const row = { status: 'VERIFIED', eventRecoveryInventoryUnchanged: true, + eventRecovery: { restored: true, marker: 'CURRENT', containerBookingId: '66' } } + run.createdResources.phase4OverseasPda = { receivingCompletionEvidence: { '66': row } } + assert.equal(unloadRecoveryWasVerified(run), true) + for (const change of [r => r.eventRecoveryInventoryUnchanged = false, r => r.status = 'UNPROVEN', + r => r.eventRecovery.restored = false, r => r.eventRecovery.marker = 'OLD', r => r.eventRecovery.containerBookingId = '77']) { + const changed = structuredClone(run) + change(changed.createdResources.phase4OverseasPda.receivingCompletionEvidence['66']) + assert.equal(unloadRecoveryWasVerified(changed), false) + } + run.createdResources.phase4OverseasPda.receivingCompletionEvidence['77'] = structuredClone(row) + assert.equal(unloadRecoveryWasVerified(run), false) +}) + +test('F40 optional chain always creates its customer and rejects absent domestic PDA credentials', () => { + const options = parseArguments(['--modules', 'phase5-root-fulfillment', + '--gateway', 'http://localhost:9000', '--username', 'contract-user', '--password', 'contract-password', + '--merchant-username', 'prior-client', '--merchant-password', 'prior-password']) + assert.deepEqual(options.modules, ['merchant-qualification-fixture', 'phase5-root-fulfillment']) + options.pdaUsername = ''; options.pdaPassword = '' + assert.throws(() => validateExecutionOptions(options), /PDA/) +}) + test('phase-four HTTP acceptance modules are registered in the Admin acceptance entry', () => { for (const module of [ 'phase4-overseas-warehouse', @@ -45,28 +113,15 @@ test('overseas inbound commands, client services and dispatch follow completed P } }) -test('entry accepts a separate overseas Gateway without exposing token state', () => { - const options = parseArguments([ - '--gateway', 'http://localhost:9000/', - '--overseas-gateway', 'http://localhost:9300/', - '--modules', 'phase4-overseas-warehouse', - ]) +test('entry uses one TEST gateway and rejects retired regional options', () => { + const options = parseArguments(['--gateway', 'http://localhost:9000/', '--modules', 'phase4-overseas-warehouse']) assert.equal(options.gateway, 'http://localhost:9000') - assert.equal(options.overseasGateway, 'http://localhost:9300') - assert.match(source, /this\.overseasToken = login\.overseasAccessToken \|\| ''/) - assert.match(source, /this\.refreshToken = login\.refreshToken \|\| ''/) - assert.match(source, /this\.overseasExpiresAt = Number\(login\.overseasExpiresIn\)/) - assert.match(source, /async refreshSession\(reason\)/) - assert.match(source, /refreshToken: this\.refreshToken/) - assert.match(source, /const replacement = \{[\s\S]*token: next\.accessToken[\s\S]*refreshToken: next\.refreshToken[\s\S]*overseasToken: next\.overseasAccessToken/) - assert.match(source, /module === 'system' && laterOverseasModule/) - assert.match(source, /system后新admin海外Token只读探针可用/) - assert.match(source, /module\.startsWith\('phase4-overseas-'\)[\s\S]*Date\.now\(\) \+ 120_000 >= this\.overseasExpiresAt/) - assert.match(source, /海外模块 \$\{module\} 执行前Token临近到期/) - assert.match(source, /async requestOverseas\(/) - assert.match(source, /async requestOverseasBinary\(/) - assert.match(source, /async requestOverseasMultipart\(/) - assert.match(source, /forbiddenValues = \[this\.options\.password, this\.options\.merchantPassword,[\s\S]*this\.token, this\.refreshToken, this\.overseasToken\]/) + assert.equal(options.environment, 'test') + assert.throws(() => parseArguments(['--overseas-gateway', 'http://localhost:9300']), /one TEST gateway/) + assert.throws(() => parseArguments(['--environment', 'cn']), /TEST|test/) + assert.doesNotMatch(source, /async requestOverseas|this\.overseasToken|overseasAccessToken/) + assert.match(source, /this\.refreshToken = login\.refreshToken/) + assert.match(source, /system后新admin统一Token只读探针可用/) }) test('overseas business keeps its marker-scoped customer fixture with reusable credentials configured', () => { @@ -96,32 +151,31 @@ test('a fixed origin route does not require randomized order fixtures', () => { assert.match(source, /this\.context\.allowAnyExistingPallet = true/) }) -test('dual-token refresh replaces the whole session only after a complete response', async () => { - const run = new AcceptanceRun({ gateway: 'http://localhost:9000', overseasGateway: 'http://localhost:9300', +test('unified-session refresh replaces the whole session only after a complete response', async () => { + const run = new AcceptanceRun({ gateway: 'http://localhost:9000', username: 'contract-user', password: 'contract-password', modules: [], output: '' }) - Object.assign(run, { token: 'old-access', refreshToken: 'old-refresh', overseasToken: 'old-overseas', overseasExpiresAt: 1 }) + Object.assign(run, { token: 'old-access', refreshToken: 'old-refresh', expiresAt: 1 }) run.request = async (method, path, body, token) => { assert.equal(method, 'POST'); assert.equal(path, '/api/auth/refresh') assert.deepEqual(body, { refreshToken: 'old-refresh' }); assert.equal(token, '') return { method, path, http: 200, body: { code: '200', data: { - accessToken: 'new-access', refreshToken: 'new-refresh', overseasAccessToken: 'new-overseas', - overseasExpiresIn: 900, + accessToken: 'new-access', refreshToken: 'new-refresh', + expiresIn: 900, } }, requestId: 'refresh-request', traceId: 'refresh-trace' } } assert.equal(await run.refreshSession('contract'), true) assert.equal(run.token, 'new-access') assert.equal(run.refreshToken, 'new-refresh') - assert.equal(run.overseasToken, 'new-overseas') - assert.ok(run.overseasExpiresAt > Date.now()) + assert.ok(run.expiresAt > Date.now()) const frozen = { token: run.token, refreshToken: run.refreshToken, - overseasToken: run.overseasToken, overseasExpiresAt: run.overseasExpiresAt } + expiresAt: run.expiresAt } run.request = async (method, path) => ({ method, path, http: 200, body: { code: '200', data: { accessToken: 'partial-only' } }, requestId: 'partial-request', traceId: 'partial-trace' }) assert.equal(await run.refreshSession('incomplete-contract'), false) assert.deepEqual({ token: run.token, refreshToken: run.refreshToken, - overseasToken: run.overseasToken, overseasExpiresAt: run.overseasExpiresAt }, frozen) + expiresAt: run.expiresAt }, frozen) }) test('entry has no built-in environment address or password and requires explicit execution options', () => { @@ -129,7 +183,7 @@ test('entry has no built-in environment address or password and requires explici assert.throws(() => validateExecutionOptions(parseArguments(['--modules', 'phase4-overseas-pda'])), /缺少必填执行参数/) const options = parseArguments([ - '--gateway', 'http://localhost:9000', '--overseas-gateway', 'http://localhost:9300', + '--gateway', 'http://localhost:9000', '--username', 'contract-user', '--password', 'contract-password', '--modules', 'phase4-overseas-pda', '--pda-username', 'contract-picker', '--pda-password', 'contract-picker-password', ]) @@ -139,7 +193,7 @@ test('entry has no built-in environment address or password and requires explici test('loading dependency chains require PDA credentials before making any HTTP requests', () => { for (const module of ['phase3-loading', 'phase3-container-loading', 'phase5-overseas-fulfillment']) { const options = parseArguments(['--gateway', 'http://localhost:9000', - '--overseas-gateway', 'http://localhost:9300', '--username', 'admin', '--password', 'fixture', + '--username', 'admin', '--password', 'fixture', '--modules', module]) options.pdaUsername = '' options.pdaPassword = '' @@ -152,7 +206,7 @@ test('loading dependency chains require PDA credentials before making any HTTP r }) test('core coverage gaps fail closed while device and external-provider gaps remain report-only', () => { - const run = new AcceptanceRun({ gateway: 'http://localhost:9000', overseasGateway: 'http://localhost:9300', + const run = new AcceptanceRun({ gateway: 'http://localhost:9000', username: 'contract-user', password: 'contract-password', modules: [], output: '' }) run.addCoverageGap('phase4', 'core', 'fixture', 'missing fixture', 'fixture') assert.equal(run.steps.at(-1)?.passed, false) diff --git a/scripts/prod-release-acceptance.mjs b/scripts/prod-release-acceptance.mjs index 3720cc4db..77b12c81c 100644 --- a/scripts/prod-release-acceptance.mjs +++ b/scripts/prod-release-acceptance.mjs @@ -88,7 +88,7 @@ export function client(settings, fetcher = fetch, policy = allowedRequest) { return { get requests() { return requests }, async request(region, method, path, token, body, idempotencyKey) { - if (!policy(method, path, region)) throw new Error('请求不在生产前置检查白名单内') + if (!policy(method, path, region, body, idempotencyKey)) throw new Error('请求不在生产前置检查白名单内') if (++requests > 100) throw new Error('超过单次前置检查请求上限') let response try { @@ -185,7 +185,7 @@ export async function executeOrder(settings, input, journalPath, resume, fetcher await preflight(settings, base, value => { context = value }) if (context.warehouses['CN-YIWU-01'] !== bound.origin.id) throw new Error('下单始发仓与义乌主档不符') const root = '/merchant/v1/orders/first-leg' - const policy = (method, path, region) => region === 'cn' && ( + const policy = (method, path, region, body, idempotencyKey) => region === 'cn' && ( method === 'GET' && (path === `${root}/inbound-code-policy` || state.orderId && path === `${root}/${state.orderId}` || path === `/merchant/v1/warehouses/${bound.destination.id}` @@ -193,6 +193,8 @@ export async function executeOrder(settings, input, journalPath, resume, fetcher || method === 'POST' && (path === '/merchant/v1/first-leg/pricing/estimate' || path === root && state.phase === 'CREATE_INTENT' || state.orderId && path === `${root}/${state.orderId}/submit` && state.phase === 'SUBMIT_INTENT' + && Number.isSafeInteger(state.submitCommand?.body?.expectedVersion) + && JSON.stringify(body) === JSON.stringify(state.submitCommand.body) && idempotencyKey === state.submitCommand.idempotencyKey || state.orderId && path === `/platform/v1/orders/first-leg/${state.orderId}/approve?tenantId=${settings.customer.id}` && state.phase === 'APPROVE_INTENT')) const http = client(settings, fetcher, policy) const merchant = context.logins.MERCHANT.accessToken diff --git a/scripts/prod-release-order-stage.mjs b/scripts/prod-release-order-stage.mjs index 19f1d2315..5d54f8ea8 100644 --- a/scripts/prod-release-order-stage.mjs +++ b/scripts/prod-release-order-stage.mjs @@ -10,6 +10,13 @@ const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value export const fingerprint = value => createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex') +export function validateSubmitCheckpoint(state) { + if (['SUBMIT_INTENT', 'SUBMITTED', 'APPROVE_INTENT', 'APPROVED'].includes(state.phase)) { + assert(Number.isSafeInteger(state.submitCommand?.body?.expectedVersion) && state.submitCommand.body.expectedVersion >= 0 + && typeof state.submitCommand.idempotencyKey === 'string' && state.submitCommand.idempotencyKey.length > 0, + '提交检查点缺少首次版本或原幂等键,禁止续跑时补取新版') + } +} export function validateOrderInput(input) { const { batch, customer, origin, destination, payload, operator } = input @@ -66,6 +73,7 @@ export async function withOrderJournal(path, input, action) { assert(state.version === 1 && state.binding === binding(input), '批次配置、环境、客户、账号或载荷已变化') assert(['NEW', 'CREATE_INTENT', 'CREATED', 'SUBMIT_INTENT', 'SUBMITTED', 'APPROVE_INTENT', 'APPROVED'].includes(state.phase), '检查点阶段无效') assert(['NEW', 'CREATE_INTENT'].includes(state.phase) ? state.orderId === null : longId(state.orderId), '检查点订单身份无效') + validateSubmitCheckpoint(state) const save = async patch => { const next = { ...state, ...patch } const file = await open(temp, 'wx', 0o600) @@ -114,8 +122,10 @@ export function assertOrderDetail(detail, input, orderId) { export async function runOrderStage({ input, state, save, transport, authorize, resume = false }) { validateOrderInput(input) assert(state.binding === binding(input), '检查点绑定不符') + validateSubmitCheckpoint(state) await authorize(input) const key = op => `prod-release-${fingerprint({ binding: state.binding, op }).slice(0, 48)}` + if (state.submitCommand) assert(state.submitCommand.idempotencyKey === key('submit'), '提交原幂等键与批次不符') const request = (role, method, path, body, idempotencyKey) => transport({ role, method, path, body, idempotencyKey }) if (state.phase === 'CREATE_INTENT') assert(resume, '创建结果待核对;需要显式续跑') if (['NEW', 'CREATE_INTENT'].includes(state.phase)) { @@ -132,15 +142,16 @@ export async function runOrderStage({ input, state, save, transport, authorize, let order = await read() if (state.phase === 'CREATED') { assert(order.status === 'DRAFT', '创建后状态被外部修改') - await save({ phase: 'SUBMIT_INTENT' }) - await request('MERCHANT', 'POST', `${path}/submit`, undefined, key('submit')) + assert(Number.isSafeInteger(order.version) && order.version >= 0, '提交前详情缺少有效版本') + await save({ phase: 'SUBMIT_INTENT', submitCommand: { body: { expectedVersion: order.version }, idempotencyKey: key('submit') } }) + await request('MERCHANT', 'POST', `${path}/submit`, state.submitCommand.body, state.submitCommand.idempotencyKey) order = await read() assert(order.status === 'SUBMITTED', '提交后未进入待审核') await save({ phase: 'SUBMITTED', orderNo: order.orderNo, inboundCode: order.inboundCode }) } else if (state.phase === 'SUBMIT_INTENT') { assert(resume, '提交结果待核对;需要显式续跑') if (order.status === 'DRAFT') { - await request('MERCHANT', 'POST', `${path}/submit`, undefined, key('submit')) + await request('MERCHANT', 'POST', `${path}/submit`, state.submitCommand.body, state.submitCommand.idempotencyKey) order = await read() } assert(order.status === 'SUBMITTED', '提交结果无法安全核对') diff --git a/scripts/prod-release-order-stage.test.mjs b/scripts/prod-release-order-stage.test.mjs index ca5241db2..afa9b0ced 100644 --- a/scripts/prod-release-order-stage.test.mjs +++ b/scripts/prod-release-order-stage.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict' import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { fingerprint, validateOrderInput, withOrderJournal, runOrderStage } from './prod-release-order-stage.mjs' +import { fingerprint, validateOrderInput, withOrderJournal, runOrderStage, validateSubmitCheckpoint } from './prod-release-order-stage.mjs' import { executeOrder } from './prod-release-acceptance.mjs' function fixture() { @@ -28,13 +28,13 @@ function server(input, fail) { let status = null let failed = false const calls = [] - const state = { calls, created: 0, approved: 0, + const state = { calls, created: 0, approved: 0, orderVersion: 5, async transport(request) { calls.push(request) const { method, path, idempotencyKey } = request if (path.endsWith('/inbound-code-policy')) return { mode: 'AUTO', manualEntryRequired: false } if (method === 'GET') return { order: { ...input.payload, id: '5', tenantId: '1', customerId: '1', - status, orderNo: 'FIXTURE-ORDER', inboundCode: status === 'DRAFT' ? null : 'FIXTURE-INBOUND' }, cargoes: input.payload.cargoes } + status, version:state.orderVersion, orderNo: 'FIXTURE-ORDER', inboundCode: status === 'DRAFT' ? null : 'FIXTURE-INBOUND' }, cargoes: input.payload.cargoes } let op if (path.endsWith('/first-leg')) { op = 'create' @@ -43,6 +43,8 @@ function server(input, fail) { } else if (path.endsWith('/submit')) { op = 'submit' assert.ok(idempotencyKey) + assert.deepEqual(request.body,{expectedVersion:state.orderVersion}) + state.orderVersion++ status = 'SUBMITTED' } else { assert.match(path, /^\/platform\/v1\/orders\/first-leg\/5\/approve\?tenantId=1$/) @@ -67,6 +69,34 @@ async function run(path, input, api, resume = false, authorize = async () => {}) test('fingerprint is stable for reordered keys', () => { assert.equal(fingerprint({ a: 1, b: 2 }), fingerprint({ b: 2, a: 1 })) }) + +test('submit-intent without original numeric version/key is blocked rather than repaired from current detail',()=>{ + for(const submitCommand of [undefined,{body:{},idempotencyKey:'k'},{body:{expectedVersion:'5'},idempotencyKey:'k'},{body:{expectedVersion:5}}]) { + assert.throws(()=>validateSubmitCheckpoint({phase:'SUBMIT_INTENT',submitCommand}),/首次版本/) + } + validateSubmitCheckpoint({phase:'SUBMIT_INTENT',submitCommand:{body:{expectedVersion:0},idempotencyKey:'k'}}) +}) + +test('uncertain submit persists request before transport and resume retains original version even after external edit',async t=>{ + const path=await sandbox(t),input=fixture(),api=server(input) + const original=api.transport;let attempted=false + api.transport=async request=>{ + if(request.path.endsWith('/submit')&&!attempted){ + attempted=true + const disk=JSON.parse(await readFile(path,'utf8')) + assert.equal(disk.phase,'SUBMIT_INTENT');assert.deepEqual(disk.submitCommand.body,request.body) + assert.equal(disk.submitCommand.idempotencyKey,request.idempotencyKey) + throw new Error('uncertain-before-send') + } + return original(request) + } + await assert.rejects(run(path,input,api),/uncertain-before-send/) + const first=JSON.parse(await readFile(path,'utf8')).submitCommand + api.orderVersion=99 + await assert.rejects(run(path,input,api,true)) // original version 5 must not be refreshed to 99 + assert.deepEqual(api.calls.filter(call=>call.path.endsWith('/submit')).at(-1).body,first.body) + assert.deepEqual(JSON.parse(await readFile(path,'utf8')).submitCommand,first) +}) test('rejects multiple SKUs, excess cartons, incorrect company and volume', () => { for (const change of [i => i.payload.cargoes.push(i.payload.cargoes[0]), i => i.payload.cargoes[0].packageCount = 300, diff --git a/scripts/publish-local-nacos-config.ps1 b/scripts/publish-local-nacos-config.ps1 deleted file mode 100644 index 7f249c4ce..000000000 --- a/scripts/publish-local-nacos-config.ps1 +++ /dev/null @@ -1,49 +0,0 @@ -param( - [string]$ServerAddr = '127.0.0.1:8848', - [string]$Group = 'BAIYUE', - [string]$Namespace = 'public' -) - -$ErrorActionPreference = 'Stop' -$projectRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) -$configRoot = Join-Path $projectRoot 'config\nacos\local' -$healthUrl = "http://$ServerAddr/nacos/v1/console/health/readiness" -$publishUrl = "http://$ServerAddr/nacos/v1/cs/configs" - -$health = Invoke-WebRequest -UseBasicParsing -Uri $healthUrl -TimeoutSec 5 -if ($health.StatusCode -ne 200 -or $health.Content.Trim() -ne 'OK') { - throw "Nacos not ready: $healthUrl" -} - -$files = Get-ChildItem -LiteralPath $configRoot -File -Filter '*.yml' | Sort-Object Name -if ($files.Count -eq 0) { - throw "No local Nacos configuration found: $configRoot" -} - -$published = 0 -foreach ($file in $files) { - $body = @{ - dataId = $file.Name - group = $Group - content = Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8 - type = 'yaml' - } - if ($Namespace -and $Namespace -ne 'public') { - $body.tenant = $Namespace - } - - $pairs = foreach ($entry in $body.GetEnumerator()) { - [uri]::EscapeDataString([string]$entry.Key) + '=' + [uri]::EscapeDataString([string]$entry.Value) - } - $form = $pairs -join '&' - $result = Invoke-RestMethod -Method Post -Uri $publishUrl ` - -ContentType 'application/x-www-form-urlencoded; charset=UTF-8' ` - -Body $form -TimeoutSec 10 - if ([string]$result -ne 'true') { - throw "Publish failed: $($file.Name), Nacos response: $result" - } - $published++ - Write-Host "Published $($file.Name) -> $Namespace / $Group" -} - -Write-Host "Published local Nacos configuration: $published items." diff --git a/scripts/publish-nacos-config-contract.test.mjs b/scripts/publish-nacos-config-contract.test.mjs new file mode 100644 index 000000000..176d28e2f --- /dev/null +++ b/scripts/publish-nacos-config-contract.test.mjs @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import test from 'node:test' + +const publisher = fileURLToPath(new URL('./publish-nacos-config.ps1', import.meta.url)) +const source = readFileSync(publisher, 'utf8') +const quote = value => `'${value.replaceAll("'", "''")}'` + +function run(mode) { + // Override the HTTP cmdlet before invoking the actual publisher: no network is possible. + const command = ` +$ErrorActionPreference = 'Stop' +$global:mode = ${quote(mode)} +$global:calls = 0 +$global:published = 0 +function Invoke-RestMethod { + param($Method, $Uri, $Body, $TimeoutSec, $ContentType) + $global:calls++ + if ($global:mode -eq 'dry') { throw 'DryRun attempted HTTP' } + if ($Uri -eq 'http://offline.invalid/nacos/v3/auth/user/login') { + if ($Method -ne 'Post' -or $Body.username -ne 'fixture-user' -or $Body.password -ne 'fixture-password') { throw 'Bad login request' } + if ($global:mode -eq 'login-failure') { return @{} } + return @{accessToken='fixture-token'} + } + if ($Uri -eq 'http://offline.invalid/nacos/v3/admin/core/namespace/list') { + if ($Method -ne 'Get' -or $Body.accessToken -ne 'fixture-token') { throw 'Bad namespace request' } + if ($global:mode -eq 'missing-namespace') { return @{code=0;data=@(@{namespace='other';namespaceShowName='fixture-id'})} } + if ($global:mode -eq 'namespace-failure') { return @{code=500;data=@(@{namespace='fixture-id'})} } + return @{code=0;data=@(@{namespace='fixture-id';namespaceShowName='TEST'})} + } + if ($Uri -ne 'http://offline.invalid/nacos/v3/admin/cs/config' -or $Method -ne 'Post') { throw 'Unexpected endpoint' } + $form = @{} + foreach ($pair in $Body.Split('&')) { + $parts = $pair.Split('=',2) + $form[[uri]::UnescapeDataString($parts[0])] = [uri]::UnescapeDataString($parts[1]) + } + if ($ContentType -ne 'application/x-www-form-urlencoded; charset=UTF-8' -or + $form.namespaceId -ne 'fixture-id' -or $form.groupName -ne 'BAIYUE' -or + $form.accessToken -ne 'fixture-token' -or $form.type -ne 'yaml' -or + $form.ContainsKey('tenant') -or $form.ContainsKey('group')) { throw 'Bad config form' } + $expected = Get-Content -LiteralPath (Join-Path ${quote(fileURLToPath(new URL('../config/nacos/test/', import.meta.url)))} $form.dataId) -Raw -Encoding UTF8 + if ($form.content -cne $expected) { throw 'YAML changed during encoding' } + if ($global:published -eq 0 -and $form.dataId -ne 'baiyue-common.yml') { throw 'Common config must publish first' } + $global:published++ + switch ($global:mode) { + 'false-data' { return @{code=0;data=$false} } + 'error-code' { return @{code=500;data=$true} } + 'missing-code' { return @{data=$true} } + 'string-data' { return @{code=0;data='true'} } + 'legacy-response' { return $true } + default { return @{code=0;data=$true} } + } +} +$failed = $false +try { + & ${quote(publisher)} -ServerAddr offline.invalid -Namespace fixture-id -Username fixture-user -Password fixture-password -DryRun:($global:mode -eq 'dry') +} catch { $failed = $true } +[ordered]@{failed=$failed; calls=$global:calls; published=$global:published} | ConvertTo-Json -Compress +` + const result = spawnSync(process.platform === 'win32' ? 'powershell.exe' : 'pwsh', + ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(command, 'utf16le').toString('base64')], + { encoding: 'utf8', timeout: 30_000 }) + assert.equal(result.status, 0, result.stderr || result.error?.message) + return JSON.parse(result.stdout.trim().split(/\r?\n/).at(-1)) +} + +test('publisher uses v3 only and keeps TEST namespace validation', () => { + assert.doesNotMatch(source, /\/v1\/|\/v2\/|tenant=|\bgroup=/) + assert.match(source, /ValidateSet\('test'\)/) + assert.match(source, /Namespace -eq 'public'/) +}) + +test('DryRun makes zero HTTP calls without publishing', () => { + assert.deepEqual(run('dry'), { failed: false, calls: 0, published: 0 }) +}) + +test('v3 publishes all 14 original YAML documents with common first', () => { + assert.deepEqual(run('success'), { failed: false, calls: 16, published: 14 }) +}) + +for (const mode of ['login-failure', 'missing-namespace', 'namespace-failure']) { + test(`${mode} prevents all configuration writes`, () => { + assert.deepEqual(run(mode), { failed: true, calls: mode === 'login-failure' ? 1 : 2, published: 0 }) + }) +} + +for (const mode of ['false-data', 'error-code', 'missing-code', 'string-data', 'legacy-response']) { + test(`${mode} stops at the first failed publish`, () => { + assert.deepEqual(run(mode), { failed: true, calls: 3, published: 1 }) + }) +} diff --git a/scripts/publish-nacos-config.ps1 b/scripts/publish-nacos-config.ps1 index 0231e4130..f20627105 100644 --- a/scripts/publish-nacos-config.ps1 +++ b/scripts/publish-nacos-config.ps1 @@ -1,68 +1,32 @@ param( - [ValidateSet('local', 'dev', 'development', 'test', 'production')] - [string]$Environment, - [ValidateSet('cn', 'overseas')] - [string]$Region = 'cn', - [string]$ServerAddr = '127.0.0.1:8848', + [ValidateSet('test')][string]$Environment = 'test', + [Parameter(Mandatory)][string]$ServerAddr, [string]$Group = 'BAIYUE', - [string]$Namespace + [Parameter(Mandatory)][string]$Namespace, + [string]$Username = $env:NACOS_USERNAME, + [string]$Password = $env:NACOS_PASSWORD, + [switch]$DryRun ) - $ErrorActionPreference = 'Stop' +if ([string]::IsNullOrWhiteSpace($Namespace) -or $Namespace -eq 'public') { throw 'Supply the actual TEST Namespace ID, not public or a display name' } $projectRoot = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path) - -if ($Environment -eq 'local') { - if ($Namespace -and $Namespace -ne 'public') { - throw 'local 环境只能发布到 public namespace' - } - $Namespace = 'public' - $configProfile = 'local' -} else { - if (-not $Namespace -or $Namespace -eq 'public') { - throw "$Environment/$Region 必须显式指定非 public 的 Nacos namespace" - } - $configProfile = $Region +$configRoot = Join-Path $projectRoot 'config/nacos/test' +$files = Get-ChildItem -LiteralPath $configRoot -File -Filter '*.yml' | Sort-Object @{Expression={if ($_.Name -eq 'baiyue-common.yml') {0} else {1}}}, Name +if ($files.Count -ne 14) { throw 'Expected common configuration and 13 retained services' } +if ($DryRun) { $files | ForEach-Object { Write-Host "Would publish $($_.Name) -> $Namespace / $Group" }; return } +if (-not $Username -or -not $Password) { throw 'NACOS_USERNAME and NACOS_PASSWORD are required' } +$base = "http://$ServerAddr/nacos" +$login = Invoke-RestMethod -Method Post -Uri "$base/v3/auth/user/login" -Body @{username=$Username; password=$Password} -TimeoutSec 10 +if ([string]::IsNullOrWhiteSpace($login.accessToken)) { throw 'Nacos authentication failed' } +$namespaces = Invoke-RestMethod -Method Get -Uri "$base/v3/admin/core/namespace/list" -Body @{accessToken=$login.accessToken} -TimeoutSec 10 +if ($null -eq $namespaces.code -or $namespaces.code -ne 0 -or + -not @($namespaces.data | Where-Object { $_.namespace -ceq $Namespace }).Count) { + throw 'Nacos TEST namespace ID was not found or namespace query failed' } - -$configRoot = (Resolve-Path (Join-Path $projectRoot "config\nacos\$configProfile")).Path -$healthUrl = "http://$ServerAddr/nacos/v1/console/health/readiness" -$publishUrl = "http://$ServerAddr/nacos/v1/cs/configs" -$health = Invoke-WebRequest -UseBasicParsing -Uri $healthUrl -TimeoutSec 5 -if ($health.StatusCode -ne 200 -or $health.Content.Trim() -ne 'OK') { - throw "Nacos not ready: $healthUrl" -} - -$files = Get-ChildItem -LiteralPath $configRoot -File -Filter '*.yml' | Sort-Object Name -if ($files.Count -eq 0) { - throw "No Nacos configuration found: $configRoot" -} - -$published = 0 foreach ($file in $files) { - $resolvedFile = (Resolve-Path -LiteralPath $file.FullName).Path - if (-not $resolvedFile.StartsWith($configRoot, [System.StringComparison]::OrdinalIgnoreCase)) { - throw "Unexpected config path: $resolvedFile" - } - $body = @{ - dataId = $file.Name - group = $Group - content = Get-Content -LiteralPath $resolvedFile -Raw -Encoding UTF8 - type = 'yaml' - } - if ($Namespace -ne 'public') { - $body.tenant = $Namespace - } - $pairs = foreach ($entry in $body.GetEnumerator()) { - [uri]::EscapeDataString([string]$entry.Key) + '=' + [uri]::EscapeDataString([string]$entry.Value) - } - $result = Invoke-RestMethod -Method Post -Uri $publishUrl ` - -ContentType 'application/x-www-form-urlencoded; charset=UTF-8' ` - -Body ($pairs -join '&') -TimeoutSec 10 - if ([string]$result -ne 'true') { - throw "Publish failed: $($file.Name), Nacos response: $result" - } - $published++ + $body = @{dataId=$file.Name; groupName=$Group; namespaceId=$Namespace; type='yaml'; content=(Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8); accessToken=$login.accessToken} + $pairs = foreach ($entry in $body.GetEnumerator()) { [uri]::EscapeDataString([string]$entry.Key) + '=' + [uri]::EscapeDataString([string]$entry.Value) } + $result = Invoke-RestMethod -Method Post -Uri "$base/v3/admin/cs/config" -ContentType 'application/x-www-form-urlencoded; charset=UTF-8' -Body ($pairs -join '&') -TimeoutSec 10 + if ($null -eq $result.code -or $result.code -ne 0 -or $result.data -isnot [bool] -or $result.data -ne $true) { throw "Publish failed: $($file.Name)" } Write-Host "Published $($file.Name) -> $Namespace / $Group" } - -Write-Host "Published $Environment/$Region Nacos configuration: $published items." diff --git a/scripts/real-order-xlsx-test-env.mjs b/scripts/real-order-xlsx-test-env.mjs index 7cf881ead..859fbd5e8 100644 --- a/scripts/real-order-xlsx-test-env.mjs +++ b/scripts/real-order-xlsx-test-env.mjs @@ -416,8 +416,10 @@ export async function executeCandidates({ candidates, limit, concurrency = 1, st entry.orderNo = draft.order.orderNo entry.declarationVerified = true if (['pending-review', 'wait-split'].includes(stage)) { + if (!Number.isSafeInteger(draft.order.version) || draft.order.version < 0) throw Error('SUBMIT_VERSION_MISSING') + entry.submitCommand = { body: { expectedVersion: draft.order.version }, headers: { 'Idempotency-Key': `${marker}-submit` } } entry.stage = 'submit'; await save() - await api('POST', `${clientPath}/submit`, undefined, merchant, { 'Idempotency-Key': `${marker}-submit` }) + await api('POST', `${clientPath}/submit`, entry.submitCommand.body, merchant, entry.submitCommand.headers) } if (stage === 'pending-review') { const submitted = await api('GET', clientPath, undefined, merchant) diff --git a/scripts/real-order-xlsx-test-env.test.mjs b/scripts/real-order-xlsx-test-env.test.mjs index 1bf1fdfb5..9b31bf1f9 100644 --- a/scripts/real-order-xlsx-test-env.test.mjs +++ b/scripts/real-order-xlsx-test-env.test.mjs @@ -6,6 +6,32 @@ import { normalizeRows, channelName, matchRoute, measurementShape, buildSourceDr const header = ['进仓编号', '客户', '渠道', '亚马逊仓', '实际件数', '实际毛重', '实际立方', '预报件数', '买单/退税', '品名', 'SKU', 'FBA货件编号', '跟踪编号'] const row = ['ABCD2606010001', 'Example', '合德海卡包税', 'ABC1', 31, 100, 2, 31, '买单', 'Goods', 'SKU1', 'SHIP1', 'REF1'] const order = () => normalizeRows([header, row]).orders[0] + +test('Excel submit saves original key/version before uncertain transport and never auto-refreezes failed entries',async t=>{ + const originalExitCode=process.exitCode + t.after(()=>{process.exitCode=originalExitCode}) + const value=order(),route=matchRoute(value,catalog),state={identity:{batchId:'frozen-submit'},orders:{}} + const saved=[] + const api=async(method,path,body,_token,headers)=>{ + if(path.endsWith('/pricing/estimate'))return {channelCode:route.channel.channelCode,pricingTaxMode:value.pricingTaxMode} + if(method==='POST'&&path.endsWith('/first-leg'))return 'order-frozen' + if(method==='GET')return {order:{id:'order-frozen',version:8,orderNo:'FL',customerRemark:state.orders[value.key].marker},cargoes:[{packageCount:value.packageCount,grossWeightKg:value.weightKg,volumeCbm:value.volumeCbm}]} + if(path.endsWith('/submit')){ + const checkpoint=saved.at(-1).orders[value.key] + assert.equal(checkpoint.stage,'submit');assert.deepEqual(checkpoint.submitCommand,{body,headers}) + assert.deepEqual(body,{expectedVersion:8});throw new Error('transport lost') + } + throw new Error('unexpected call') + } + const options={candidates:[{order:value,route}],limit:1,state,save:async()=>saved.push(structuredClone(state)),api, + ctx:{},merchant:'merchant',profile:{tenantName:'Example'},sharedResources:{productImageFileId:'image'},stage:'pending-review'} + await executeCandidates(options) + assert.equal(process.exitCode,1) + assert.equal(state.orders[value.key].status,'FAILED') + assert.equal(state.orders[value.key].submitCommand.body.expectedVersion,8) + await executeCandidates({...options,api:async()=>{assert.fail('failed checkpoint must not issue another request')}}) + assert.equal(state.orders[value.key].submitCommand.body.expectedVersion,8) +}) const catalog = { receiving: { id: '1', warehouseName: 'Origin' }, channels: [{ channelName: '合德海卡', channelCode: 'CHANNEL', pricingTaxModes: ['TAX_INCLUDED'] }], destinations: [{ id: '2', warehouseType: 'FBA', warehouseCode: 'ABC1', warehouseName: 'ABC1', countryCode: 'US' }] } @@ -150,7 +176,7 @@ test('pending-review stage submits but never calls approval or measurement APIs' if (path === '/api/merchant/v1/orders/first-leg/order-1' && method === 'GET') { detailReads++ return { order: { id: 'order-1', orderNo: 'FL-1', customerRemark: Object.values(state.orders)[0].marker, - status: detailReads > 1 ? 'SUBMITTED' : 'DRAFT' }, + status: detailReads > 1 ? 'SUBMITTED' : 'DRAFT', version: 7 }, cargoes: [{ packageCount: value.packageCount, grossWeightKg: value.weightKg, volumeCbm: value.volumeCbm }] } } if (path === '/api/merchant/v1/orders/order-1') return { summary: { mainStatus: 'PENDING_REVIEW' } } @@ -175,7 +201,7 @@ test('pending-review retries an estimate-only pricing failure and records it as if (path === '/api/merchant/v1/orders/first-leg/order-2' && method === 'GET') { detailReads++ return { order: { id: 'order-2', orderNo: 'FL-2', customerRemark: Object.values(state.orders)[0].marker, - status: detailReads > 1 ? 'SUBMITTED' : 'DRAFT' }, + status: detailReads > 1 ? 'SUBMITTED' : 'DRAFT', version: 7 }, cargoes: [{ packageCount: value.packageCount, grossWeightKg: value.weightKg, volumeCbm: value.volumeCbm }] } } if (path === '/api/merchant/v1/orders/order-2') return { summary: { mainStatus: 'PENDING_REVIEW' } } diff --git a/scripts/synchronous-dependency-baseline.mjs b/scripts/synchronous-dependency-baseline.mjs index 1c7845f0f..4ccc1769c 100644 --- a/scripts/synchronous-dependency-baseline.mjs +++ b/scripts/synchronous-dependency-baseline.mjs @@ -15,6 +15,51 @@ const http = (path, source, target, contexts, access, idempotency, note) => ({ }) export const SYNCHRONOUS_CLIENTS = Object.freeze([ + feign('finance/src/main/java/com/baiyue/finance/fleet/VehicleCostFileClient.java', + 'finance-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', + '公司车辆付款凭证按平台及费用ID校验;上传创建不可变对象且无远端幂等键,读取可重复,禁止传输层自动重试上传'), + feign('lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceClient.java', + 'lastleg-service', 'finance-service', ['REQUEST'], 'READ', 'SAFE_READ', + '按维修/救援来源批量只读查询公司费用状态;POST为查询,不触发入账或付款'), + feign('lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareSupplierClient.java', + 'lastleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', + '维修/救援登记读取并校验权威供应商状态,不创建或修改供应商'), + feign('lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteSupplierClient.java', + 'lastleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', + '采购报价读取启用卡车供应商及服务范围;内部令牌保护,只读供应商主档'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/HttpTripOperationsEvidenceGateway.java', + 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', + '出车实测凭证按平台/车次独立归属;上传无远端幂等键,当前/历史版本引用经Lastleg校验后读取,不自动重试上传'), + feign('lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceFeeCoverageClient.java', + 'lastleg-service', 'finance-service', ['REQUEST'], 'READ', 'SAFE_READ', + '原运费覆盖只读:按实际客户与头程入仓ID批量读取Finance已接受来源;目标端只查本库,无下游调用;不提供费用写入或资金命令'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/HttpVehicleCareFileGateway.java', + 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', + '运维图片上传无远端幂等键,调用方保留请求结果;元数据与下载按平台/记录复合归属核对,禁止客户端自动重试'), + feign('lastleg-service/src/main/java/com/baiyue/lastleg/fleet/customer/FleetCustomerClient.java', + 'lastleg-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', + '外接登记读取权威有效客户与下单资格,内部服务令牌保护;不依据采购历史或创建客户主档'), + feign('lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/FleetWarehouseClient.java', + 'lastleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', + '司机仓绑定候选只读权威仓库目录,使用内部服务令牌,不传用户JWT或修改仓库主档'), + feign('lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceBusinessEventClient.java', + 'lastleg-service', 'finance-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '费用及资金指令按eventId/hash直投对应Finance内部入口,严格DeliveryAck'), + http('common-security/src/main/java/com/baiyue/common/security/HttpSessionStateVerifier.java', + 'common-security', 'auth-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'Servlet认证读取权威会话状态;内部令牌、总超时、拒绝重定向、失败关闭'), + http('gateway/src/main/java/com/baiyue/gateway/config/RedisTokenSessionWebFilter.java', + 'gateway', 'auth-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', '统一入口非阻塞查询权威会话;内部令牌与超时,失败关闭'), + http('firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingBrowserSessionReader.java', + 'firstleg-service', 'external-lingxing-browser', ['EXTERNAL'], 'READ_WRITE', 'MIXED', 'CDP读取浏览器会话及执行登录/验证码;人工验证不可自动绕过'), + http('firstleg/src/main/java/com/baiyue/firstleg/lingxing/LingxingDirectClient.java', + 'firstleg-service', 'external-lingxing', ['EXTERNAL'], 'READ_WRITE', 'MIXED', '领星SKU查询与新品维护写入;已维护状态防止重复提交,不假定外部幂等'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/DomesticPdaOperatorClient.java', + 'firstleg-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', '按作业仓与账号查询有效国内 PDA 操作人,内部服务令牌保护'), + feign('order/src/main/java/com/baiyue/order/client/LegacyImportPricingClient.java', + 'order-service', 'firstleg-service', ['REQUEST'], 'READ', 'SAFE_READ', '历史订单导入按客户与始发仓只读匹配渠道'), + feign('order/src/main/java/com/baiyue/order/client/LegacyImportWarehouseClient.java', + 'order-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '历史订单导入只读匹配仓库目录'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/HttpTransferMeasurementPhotoFileGateway.java', + 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '转仓测量照片上传无幂等键,元数据与下载按客户、业务类型和转仓单校验归属'), feign('lastleg-service/src/main/java/com/baiyue/lastleg/client/PrintPdfClient.java', 'lastleg-service', 'print-service', ['REQUEST'], 'READ', 'SAFE_READ', '根据已授权排车快照生成 BOL PDF,内部令牌保护,无业务状态写入'), feign('auth/src/main/java/com/baiyue/auth/client/IamAuthorizationClient.java', @@ -46,15 +91,15 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([ '事件消费回读快照;平台作废命令携带 Idempotency-Key 与请求哈希'), feign('finance/src/main/java/com/baiyue/finance/client/IamCustomerClient.java', 'finance-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取客户主档快照'), - feign('finance/src/main/java/com/baiyue/finance/client/WalletResultEventBridgeClient.java', - 'finance-service', 'event-bridge-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '钱包结果 Outbox 按 eventId 回传海外业务'), + feign('finance/src/main/java/com/baiyue/finance/client/WalletResultLastlegClient.java', + 'finance-service', 'lastleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '钱包结果 Outbox 按 eventId 回传海外业务'), feign('finance/src/main/java/com/baiyue/finance/client/WarehouseSupplierClient.java', 'finance-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取并校验供应商快照'), feign('firstleg/src/main/java/com/baiyue/firstleg/client/CarrierScheduleIntegrationClient.java', 'firstleg-service', 'integration-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '船节点定时任务查询,不属于用户请求'), - feign('firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java', - 'firstleg-service', 'event-bridge-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '装柜/船节点 Outbox 按 eventId 投递'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/LastlegEventClient.java', + 'firstleg-service', 'lastleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '装柜/船节点 Outbox 按 eventId 投递'), feign('firstleg/src/main/java/com/baiyue/firstleg/client/FeePaymentVoucherFileClient.java', 'firstleg-service', 'file-service', ['REQUEST'], 'READ', 'SAFE_READ', '校验付款凭证元数据'), feign('firstleg/src/main/java/com/baiyue/firstleg/client/InboundHoldFileClient.java', @@ -110,11 +155,9 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([ 'IAM 持久化任务租约投递;初始化/补偿复用稳定 initializationKey,不在客户创建请求内调用'), feign('iam/src/main/java/com/baiyue/iam/client/OverseasStorageFeeClient.java', 'iam-service', 'warehouse-service', ['REQUEST'], 'WRITE', 'BUSINESS_KEY', '初始化/补偿使用 initializationKey'), - feign('iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java', - 'iam-service', 'event-bridge-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'IAM 安全 Outbox 按 eventId 投递'), - feign('lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java', - 'lastleg-service', 'event-bridge-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '卸柜完成 Outbox 按 eventId 投递'), + feign('lastleg-service/src/main/java/com/baiyue/lastleg/client/FirstlegEventClient.java', + 'lastleg-service', 'firstleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '卸柜完成 Outbox 按 eventId 投递'), feign('lastleg-service/src/main/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClient.java', 'lastleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取国内共享仓库权威目录'), feign('lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClient.java', @@ -151,20 +194,11 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([ http('common-audit/src/main/java/com/baiyue/common/audit/HttpAuditSink.java', 'common-audit', 'audit-service', ['INFRASTRUCTURE'], 'WRITE', 'NONE', '同步审计投递失败当前仅记录告警'), - http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFinanceEventClient.java', - 'event-bridge-service', 'finance-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '跨区费用 Inbox 按 eventId 投递 Finance'), - http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java', - 'event-bridge-service', 'firstleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'Inbox 租约投递,目标按 eventId 幂等'), - http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java', - 'event-bridge-service', 'lastleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'Inbox 租约投递,目标按 eventId 幂等'), - http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java', - 'event-bridge-service', 'lastleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'IAM 安全事件投影按 eventId/版本单调幂等'), - http('gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java', - 'gateway-overseas', 'lastleg-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'POST 实时校验,失败关闭'), - http('gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java', - 'gateway-overseas', 'auth-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'POST PDA 会话校验,失败关闭'), http('lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/gateway/HttpCustomerVisitFileGateway.java', 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '回访附件上传无幂等键,归属校验可重复'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/HttpDriverFileGateway.java', + 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', + '内部服务令牌上传驾驶证,无上传幂等键;读取按平台公共作用域、司机业务键及文件ID核对类型/大小/归属,不自动重放上传'), http('lastleg-service/src/main/java/com/baiyue/lastleg/delivery/gateway/HttpDeliveryDocumentFileGateway.java', 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '派送资料上传无幂等键,元数据按客户/派送单/类别校验,当前与历史下载只读'), http('lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/gateway/HttpExceptionEvidenceFileGateway.java', diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index bb70439d8..7ccbb4500 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -10,13 +10,11 @@ import { const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') -const serviceModules = [ - 'auth', 'iam', 'order', 'firstleg', 'finance', 'file', 'warehouse', - 'integration-service', 'print', 'event-bridge-service', 'lastleg-service', - 'warehouse-overseas-service' -] +// Discover all active Maven modules; unreviewed clients must fail equality checks. +const serviceModules = [...(await readFile(path.join(root, 'pom.xml'), 'utf8')) + .matchAll(/([^<]+)<\/module>/g)].map(match => match[1]) -const canonicalSource = new Map(serviceModules.map(name => [name, `${name}${name.endsWith('-service') ? '' : '-service'}`])) +const canonicalSource = new Map(serviceModules.map(name => [name, `${name}${name.endsWith('-service') || name.startsWith('common-') || name === 'gateway' || name === 'platform-bom' ? '' : '-service'}`])) const expectedOnlineEdges = [ 'auth-service->iam-service', @@ -34,6 +32,8 @@ const expectedOnlineEdges = [ 'iam-service->auth-service', 'iam-service->warehouse-service', 'lastleg-service->file-service', + 'lastleg-service->finance-service', + 'lastleg-service->iam-service', 'lastleg-service->print-service', 'lastleg-service->warehouse-overseas-service', 'lastleg-service->warehouse-service', @@ -47,25 +47,22 @@ const expectedOnlineEdges = [ ] const expectedBackgroundEdges = [ - 'event-bridge-service->finance-service', - 'event-bridge-service->firstleg-service', - 'event-bridge-service->lastleg-service', 'file-service->finance-service', 'file-service->firstleg-service', 'file-service->iam-service', 'file-service->order-service', 'file-service->warehouse-service', - 'finance-service->event-bridge-service', + 'finance-service->lastleg-service', 'finance-service->firstleg-service', - 'firstleg-service->event-bridge-service', + 'firstleg-service->lastleg-service', 'firstleg-service->finance-service', 'firstleg-service->integration-service', 'firstleg-service->order-service', - 'iam-service->event-bridge-service', 'iam-service->firstleg-service', - 'lastleg-service->event-bridge-service', + 'lastleg-service->finance-service', + 'lastleg-service->firstleg-service', 'order-service->firstleg-service' -] +].sort() const rawHttpPattern = /\b(?:RestClient|WebClient|HttpClient)\b/ @@ -78,7 +75,7 @@ async function javaFiles(directory) { else if (entry.isFile() && entry.name.endsWith('.java')) result.push(target) } } - await visit(directory) + if (await readdir(directory).catch(error => { if (error.code === 'ENOENT') return null; throw error })) await visit(directory) return result } @@ -101,27 +98,27 @@ async function discoverFeignEdges() { return clients.sort((left, right) => left.path.localeCompare(right.path)) } -test('all 60 Feign clients have reviewed context, access and idempotency metadata', async () => { +test('all Feign clients have reviewed context, access and idempotency metadata', async () => { const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'FEIGN') .map(({ path, source, target }) => ({ path, source, target })) .sort((left, right) => left.path.localeCompare(right.path)) - assert.equal(reviewed.length, 60) + assert.equal(reviewed.length, 70) assert.deepEqual(await discoverFeignEdges(), reviewed) }) -test('all 23 raw HTTP transports have reviewed context, access and idempotency metadata', async () => { +test('all raw HTTP transports have reviewed context, access and idempotency metadata', async () => { const discovered = [] - for (const module of ['common-audit', 'event-bridge-service', 'gateway-overseas', 'lastleg-service', 'integration-service']) { + for (const module of serviceModules) { for (const file of await javaFiles(path.join(root, module, 'src', 'main', 'java'))) { const source = await readFile(file, 'utf8') if (!rawHttpPattern.test(source)) continue - if (!/(?:private|final)\s+(?:RestClient|WebClient|HttpClient)\b|HttpClient\s+client\s*=/.test(source)) continue + if (!/(?:private|final)\s+(?:[\w.]+\.)?(?:RestClient|WebClient|HttpClient)\b|HttpClient\s+client\s*=/.test(source)) continue discovered.push(path.relative(root, file).replaceAll('\\', '/')) } } const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'HTTP') .map(client => client.path).sort() - assert.equal(reviewed.length, 23) + assert.equal(reviewed.length, 25) assert.deepEqual(discovered.sort(), reviewed) }) @@ -175,7 +172,7 @@ test('all six Lastleg RestClient gateways use explicit bounded transports', asyn for (const configFile of [ 'lastleg-service/src/main/resources/application.yml', - 'config/nacos/overseas/lastleg-service.yml' + 'config/nacos/test/lastleg-service.yml' ]) { const source = await readFile(path.join(root, configFile), 'utf8') for (const key of ['unload-photo-file', 'relabel-photo-file', 'customer-visit-file', 'procurement-file', 'warehouse-resource', 'pallet-physical']) { @@ -191,26 +188,35 @@ test('REQUEST, BACKGROUND and full graphs remain separate executable baselines', assert.deepEqual(graphEdges('BACKGROUND'), expectedBackgroundEdges) assert.deepEqual(graphEdges('INFRASTRUCTURE'), [ 'common-audit->audit-service', - 'gateway-overseas->auth-service', - 'gateway-overseas->lastleg-service' + 'common-security->auth-service', + 'gateway->auth-service' ]) assert.deepEqual(stronglyConnectedComponents(graphEdges('REQUEST')), [ ['auth-service', 'iam-service'], ['finance-service', 'firstleg-service', 'order-service'] ]) assert.deepEqual(stronglyConnectedComponents(graphEdges('BACKGROUND')), [[ - 'event-bridge-service', 'finance-service', 'firstleg-service', 'lastleg-service', 'order-service' + 'finance-service', 'firstleg-service', 'lastleg-service', 'order-service' ]]) assert.deepEqual(stronglyConnectedComponents(graphEdges('ALL')), [[ - 'auth-service', 'event-bridge-service', 'file-service', 'finance-service', + 'auth-service', 'file-service', 'finance-service', 'firstleg-service', 'iam-service', 'lastleg-service', 'order-service' ]]) }) -test('overseas security projection stays in Lastleg infrastructure and background edges', () => { - assert.ok(!graphEdges('REQUEST').some(edge => edge === 'gateway-overseas->lastleg-service')) - assert.ok(graphEdges('INFRASTRUCTURE').includes('gateway-overseas->lastleg-service')) - assert.ok(graphEdges('BACKGROUND').includes('event-bridge-service->lastleg-service')) +test('unified authority checks remain infrastructure edges and direct events remain background', () => { + assert.ok(graphEdges('INFRASTRUCTURE').includes('gateway->auth-service')) + assert.ok(graphEdges('INFRASTRUCTURE').includes('common-security->auth-service')) + assert.ok(graphEdges('BACKGROUND').includes('lastleg-service->finance-service')) + const financeReads = SYNCHRONOUS_CLIENTS.filter(client => client.source === 'lastleg-service' + && client.target === 'finance-service' && client.contexts.includes('REQUEST')) + assert.deepEqual(financeReads.map(client => [client.path, client.access, client.idempotency]) + .sort((left, right) => left[0].localeCompare(right[0])), [[ + 'lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceFeeCoverageClient.java', 'READ', 'SAFE_READ' + ], [ + 'lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceClient.java', 'READ', 'SAFE_READ' + ]]) + assert.ok(!SYNCHRONOUS_CLIENTS.some(client => ['event-bridge-service','gateway-overseas'].includes(client.source) || ['event-bridge-service','gateway-overseas'].includes(client.target))) }) test('Order, Firstleg and Finance form the reviewed REQUEST SCC', () => { @@ -224,6 +230,7 @@ test('Order, Firstleg and Finance form the reviewed REQUEST SCC', () => { 'order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java', 'order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java', 'order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java', + 'order/src/main/java/com/baiyue/order/client/LegacyImportPricingClient.java', 'order/src/main/java/com/baiyue/order/client/MerchantPricingCatalogClient.java' ]) assert.deepEqual(firstlegToOrder.map(client => client.path).sort(), [ @@ -266,9 +273,9 @@ test('known user entries expose every longest simple REQUEST path over the two-h 'file-service->iam-service->auth-service', 'file-service->iam-service->warehouse-service' ] }, - 'lastleg-service': { hops: 3, paths: [ - 'lastleg-service->file-service->iam-service->auth-service', - 'lastleg-service->file-service->iam-service->warehouse-service' + 'lastleg-service': { hops: 6, paths: [ + 'lastleg-service->finance-service->firstleg-service->order-service->file-service->iam-service->auth-service', + 'lastleg-service->finance-service->firstleg-service->order-service->file-service->iam-service->warehouse-service' ] }, 'warehouse-service': { hops: 0, paths: ['warehouse-service'] }, 'warehouse-overseas-service': { hops: 1, paths: [ @@ -282,8 +289,18 @@ test('known user entries expose every longest simple REQUEST path over the two-h } }) +test('delivery fee coverage is a single readonly Finance hop and cannot write financial state', async () => { + const client = await readFile(path.join(root, 'lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceFeeCoverageClient.java'), 'utf8') + const service = await readFile(path.join(root, 'finance/src/main/java/com/baiyue/finance/service/DeliveryFeeCoverageQueryService.java'), 'utf8') + assert.equal([...client.matchAll(/@(?:Get|Post|Put|Delete|Patch)Mapping/g)].length, 1) + assert.ok(client.includes('/internal/finance/v1/delivery-fee-coverage/sources')) + assert.ok(service.includes('@Transactional(readOnly = true)')) + assert.ok(!/\.client\.|Feign|RestClient|WebClient|HttpClient|jdbc\.update|\.execute\(/.test(service)) + assert.ok(service.includes("f.customer_id=:customerId AND f.inbound_id IN (:inboundIds)")) +}) + test('shared Feign timeout defaults and long-running export overrides do not drift silently', async () => { - for (const profile of ['local', 'cn', 'overseas']) { + for (const profile of ['test']) { const source = await readFile(path.join(root, 'config', 'nacos', profile, 'baiyue-common.yml'), 'utf8') assert.match(source, /connect-timeout:\s*3000/) assert.match(source, /read-timeout:\s*5000/) diff --git a/scripts/test-delivery-fault-proof.mjs b/scripts/test-delivery-fault-proof.mjs new file mode 100644 index 000000000..5ceb5a71f --- /dev/null +++ b/scripts/test-delivery-fault-proof.mjs @@ -0,0 +1,76 @@ +import { createHash } from 'node:crypto' +import { isDeepStrictEqual } from 'node:util' + +export const deliveryFaultScopeKeys = ['marker','customerId','deliveryId','sourceInboundId','sourceOrderId','rootOrderId'] +const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object' + ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value +export function assertTestDeliveryFaultProof(proof, scope) { + const need = (value, code) => { if (!value) throw Error('Invalid delivery fault proof: ' + code) } + need(proof?.readOnly === true && deliveryFaultScopeKeys.every(key => proof[key] === scope[key]), 'SCOPE') + const row=proof.producer, before=proof.beforeRetry, after=proof.afterRetry, env=row?.envelope, payload=env?.payload + need(row && /^[1-9][0-9]{0,19}$/.test(row.entryId) && /^(?:[0-9a-fA-F-]{36}|[0-9A-HJKMNP-TV-Z]{26})$/.test(row.eventId), 'IDENTITY') + need(row.eventType==='OverseasDeliveryCompleted' && row.customerId===scope.customerId && row.aggregateType==='INBOUND_ORDER' + && row.aggregateId===scope.sourceInboundId && payload?.deliveryOrderId===scope.deliveryId + && payload.sourceInboundId===scope.sourceInboundId && payload.sourceOrderId===scope.sourceOrderId + && payload.rootOrderId===scope.rootOrderId && payload.deliveryNo && payload.inboundCode && payload.sourceOrderNo + && payload.fbaWarehouseCode && payload.lastlegOrderId && payload.lastlegOrderNo && Number.isFinite(Date.parse(payload.completedAt)) + && row.eventKey===`OverseasDeliveryCompleted:${payload.deliveryNo}:${scope.sourceInboundId}`, 'PRODUCER') + need(env.eventId===row.eventId && env.eventType===row.eventType && env.tenantId===scope.customerId + && Number(env.eventVersion)===1 && env.aggregateType==='INBOUND_ORDER' && env.aggregateId===scope.sourceInboundId + && Number(env.aggregateVersion)>0, 'ENVELOPE') + const hash='sha256:'+createHash('sha256').update(JSON.stringify(canonical(payload))).digest('hex') + need(row.payloadHash===hash && env.payloadHash===hash, 'HASH') + need(before?.publishStatus==='DEAD' && before.failedAttempts===12 && row.publishStatus==='PUBLISHED' + && after?.publishStatus==='PUBLISHED' && [before,after].every(value => value.entryId===row.entryId + && value.eventId===row.eventId && value.payloadHash===hash && isDeepStrictEqual(value.envelope,env)), 'ORIGINAL_RETRY') + const inbox=proof.consumer + need(inbox?.count===1 && inbox.eventId===row.eventId && inbox.eventType===row.eventType && inbox.aggregateId===scope.sourceInboundId + && inbox.status==='PROCESSED' && inbox.payloadHash===hash && isDeepStrictEqual(inbox.payload,payload), 'INBOX') + const attempts=proof.attempts, matches=proof.proxyRecords?.filter(value=>value.matched===true) + need(Array.isArray(attempts) && attempts.length===13 && new Set(attempts.map(value=>value.attemptId)).size===13 + && attempts.every((value,index)=>value.entryId===row.entryId && value.eventId===row.eventId && value.serviceName==='lastleg' + && value.outcome===(index<11?'RETRY':index===11?'DEAD':'DUPLICATE')), 'ATTEMPTS') + need(matches?.length===13 && matches.every((value,index)=>value.eventId===row.eventId && value.payloadHash===hash + && value.attempt===index+1 && value.outcome===(index===0?'APPLIED':'DUPLICATE') + && (index<12?value.errorCode==='ACK_LOSS_INJECTED':value.errorCode===null)), 'REAL_ACK_LOSS') + const audit=proof.retryAudit + need(proof.retryAuditCount===1 && audit?.requestId===proof.retryRequestId && audit.entryId===row.entryId + && audit.eventId===row.eventId && audit.payloadHash===hash && audit.previousFailures===12, 'RETRY_AUDIT') + const facts=before.business + need(facts && isDeepStrictEqual(facts,after.business), 'BUSINESS_UNCHANGED') + for (const key of ['inbound','inboundHistory','inbox','order','orderHistory','completionEvent']) need(Array.isArray(facts[key]), 'BUSINESS_SHAPE') + need(facts.inbox.length===1 && facts.inbox[0].eventId===row.eventId && facts.inbox[0].status==='PROCESSED' + && facts.inbox[0].payloadHash===hash && facts.inbox[0].processedAt && isDeepStrictEqual(facts.inbox[0].payload,payload), 'PERSISTED_INBOX') + const inbounds=facts.inbound.filter(value=>value.id===scope.sourceInboundId) + need(inbounds.length===1 && inbounds[0].customerId===scope.customerId && inbounds[0].orderId===scope.sourceOrderId + && inbounds[0].orderNo===payload.sourceOrderNo && inbounds[0].inboundCode===payload.inboundCode + && inbounds[0].processStatus==='DELIVERED' && Number.isInteger(inbounds[0].version), 'INBOUND') + need(facts.inboundHistory.filter(value=>value.inboundId===scope.sourceInboundId && value.action==='COMPLETE_DELIVERY' + && value.fromStatus==='LOADED' && value.toStatus==='DELIVERED').length===1, 'INBOUND_HISTORY') + const instant=payload.completedAt.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?Z$/) + const completedMillis=instant?Date.parse(instant[1]+'Z')+Math.floor((Number((instant[2]||'').padEnd(9,'0'))+500000)/1000000):NaN + const storedMillis=value=>Date.parse(String(value).replace(' ','T').replace(/Z$/,'')+'Z') + need(facts.inboundHistory.filter(value=>value.action==='COMPLETE_DELIVERY') + .every(value=>storedMillis(value.occurredAt)===completedMillis), 'COMPLETION_TIME') + need(facts.order.length>0 && new Set(facts.order.map(value=>value.id)).size===facts.order.length + && facts.order.every(value=>value.customerId===scope.customerId && value.rootOrderId===scope.rootOrderId && Number.isInteger(value.version)), 'ORDER_SCOPE') + const source=facts.order.find(value=>value.id===scope.sourceOrderId), root=facts.order.find(value=>value.id===scope.rootOrderId) + need(source && root && root.orderRole==='MASTER' && source.orderNo===payload.sourceOrderNo && source.inboundCode===payload.inboundCode, 'ORDER_IDENTITY') + const historyCount=(id,action)=>facts.orderHistory.filter(value=>value.orderId===id && value.action===action).length + need(historyCount(source.id,'OVERSEAS_DELIVERED')===1, 'SOURCE_SIGNED') + need(facts.orderHistory.filter(value=>value.orderId===source.id && value.action==='OVERSEAS_DELIVERED') + .every(value=>storedMillis(value.occurredAt)===completedMillis), 'SOURCE_SIGNED_TIME') + const rootCanComplete=historyCount(root.id,'OVERSEAS_DELIVERED')===1 + && facts.order.filter(value=>value.id!==root.id && value.status!=='CANCELLED').every(value=>value.status==='COMPLETED') + need(rootCanComplete ? root.status==='COMPLETED' : ['APPROVED','ACCEPTED','PROCESSING'].includes(root.status), 'ROOT_GATE') + need(source.id===root.id || source.status==='COMPLETED', 'SOURCE_COMPLETION') + need(source.status==='COMPLETED'?inbounds[0].orderStatusSnapshot==='COMPLETED':inbounds[0].orderStatusSnapshot!=='COMPLETED', 'INBOUND_ORDER_SNAPSHOT') + for (const order of facts.order) { + const events=facts.completionEvent.filter(value=>value.orderId===order.id) + need(events.length===(order.status==='COMPLETED'?1:0) && events.every(value=>value.status==='PUBLISHED' + && value.eventType==='FIRST_LEG_ORDER_FULFILLMENT_COMPLETED'), 'COMPLETION_EVENTS') + if(order.status==='COMPLETED') need(order.completedAt && historyCount(order.id,order.id===root.id?'FULFILLMENT_COMPLETED':'OVERSEAS_DELIVERED')===1, 'COMPLETION_HISTORY') + } + need(facts.completionEvent.every(value=>facts.order.some(order=>order.id===value.orderId)), 'FOREIGN_EVENT') + return proof +} diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index 606d5925c..c37dcd5b7 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -14,34 +14,25 @@ const serviceDockerfile = (await readFile(new URL('../deploy/docker/Dockerfile', .replaceAll('\r\n', '\n') const serviceCompose = (await readFile(new URL('../deploy/docker-compose.yml', import.meta.url), 'utf8')) .replaceAll('\r\n', '\n') -const overseasCompose = (await readFile(new URL('../deploy/docker-compose.overseas.yml', import.meta.url), 'utf8')) - .replaceAll('\r\n', '\n') test('service pipeline keeps boolean operators on parseable Groovy continuation lines', () => { assert.doesNotMatch(pipeline, /^\s*(?:&&|\|\|)/m) }) -test('test deployments compile packages without rerunning the full unit test suite', () => { - assert.match(pipeline, /env\.RESOLVED_ENVIRONMENT == 'test' \|\| params\.SKIP_TESTS/) +test('test compilation is preserved even when execution is explicitly skipped', () => { + assert.match(pipeline, /boolean skipTestExecution = params\.SKIP_TESTS/) assert.match(pipeline, /skipTestExecution \? '-DskipTests' : ''/) assert.match(pipeline, /-pl \$\{env\.RESOLVED_SERVICE\} -am clean package/) assert.doesNotMatch(pipeline, /-Dmaven\.test\.skip=true/) }) test('coordinator and service jobs enforce their own deployment environments', () => { - assert.match(coordinatorPipeline, /case 'backend-java-dev':[\s\S]*?return 'development'/) - assert.match(coordinatorPipeline, /case 'backend-java':[\s\S]*?return 'test'/) - assert.match(coordinatorPipeline, /case 'backend-java-prod':[\s\S]*?return 'production'/) - assert.match(coordinatorPipeline, /environmentChoicesForJob\(env\.JOB_BASE_NAME\)/) - assert.match(coordinatorPipeline, /env\.RESOLVED_ENVIRONMENT = resolvedEnvironment/) - assert.match(coordinatorPipeline, /env\.RESOLVED_JOB_PREFIX = resolvedJobPrefix/) - assert.match(pipeline, /startsWith\('baiyue-firstleg-dev-'\)[\s\S]*?return 'development'/) - assert.match(pipeline, /startsWith\('baiyue-firstleg-prod-'\)[\s\S]*?return 'production'/) - assert.match(pipeline, /startsWith\('baiyue-firstleg-'\)[\s\S]*?return 'test'/) - assert.match(pipeline, /environmentChoicesForServiceJob\(env\.JOB_BASE_NAME\)/) - assert.match(pipeline, /env\.RESOLVED_ENVIRONMENT = resolvedEnvironment/) - assert.doesNotMatch(pipeline, /params\.ENVIRONMENT ==/) - assert.doesNotMatch(pipeline, /contains\(params\.ENVIRONMENT\)/) + assert.match(coordinatorPipeline, /case 'backend-java':[\s\S]*?return 'test'/) + assert.match(coordinatorPipeline,/case 'backend-java-prod':[\s\S]*?return 'production'/) + assert.doesNotMatch(coordinatorPipeline,/return 'development'/) + assert.match(pipeline,/startsWith\('baiyue-firstleg-'\)/) + assert.match(pipeline,/!jobBaseName.startsWith\('baiyue-firstleg-dev-'\)/) + assert.match(pipeline,/!jobBaseName.startsWith\('baiyue-firstleg-prod-'\)/) }) test('AUTO advances interrupted baselines and retries only their selected services', () => { @@ -72,67 +63,35 @@ test('AUTO maps shared modules to their real consumers instead of rebuilding eve assert.doesNotMatch(securityImpact, /gateway|print|integration-service|event-bridge-service/) }) -test('production deployments cannot skip tests', () => { - assert.match(coordinatorPipeline, /env\.RESOLVED_ENVIRONMENT == 'production' && params\.SKIP_TESTS/) - assert.match(coordinatorPipeline, /error\('生产环境禁止跳过测试'\)/) - assert.match(pipeline, /生产环境手工构建禁止跳过测试/) - assert.match(coordinatorPipeline, /refs\/heads\/main:refs\/remotes\/origin\/main/) - assert.match(coordinatorPipeline, /test "\$\(git rev-parse HEAD\)" = "\$\(git rev-parse origin\/main\)"/) - assert.match(pipeline, /git merge-base --is-ancestor "\$\{RESOLVED_SOURCE_COMMIT\}" origin\/main/) +test('TEST builds respect explicit test execution controls', () => { + assert.match(coordinatorPipeline,/case 'backend-java-prod':[\s\S]*?return 'production'/) + assert.match(coordinatorPipeline,/!params.SKIP_TESTS/) + assert.match(pipeline,/boolean skipTestExecution = params.SKIP_TESTS/) }) test('Java builds use the shared builder pool and serialize incremental target deployments', () => { - assert.match(pipeline, /agent \{ label 'java-builder' \}/) - assert.match(pipeline, /\['development', 'test'\]\.contains\(env\.RESOLVED_ENVIRONMENT\)/) - assert.match(pipeline, /env\.RESOLVED_ENVIRONMENT == 'development' \? 'java-dev' : 'java-test'/) - assert.match(pipeline, /'TARGET_IS_LOCAL=true'/) - assert.match(pipeline, /String deploySlotLabel = "firstleg-\$\{env\.RESOLVED_ENVIRONMENT\}-deploy-slot"/) - assert.match(pipeline, /lock\(label: deploySlotLabel, quantity: 1, inversePrecedence: true\)/) - assert.match(pipeline, /deploy-built-service\.sh/) - assert.doesNotMatch(pipeline, /dev_deploy|test_deploy/) - assert.match(pipeline, /firstleg-production-ssh/) - assert.match(pipeline, /TARGET_SSH_KEY/) - assert.match(directDeployScript, /docker pull "\$IMAGE"/) - assert.match(directDeployScript, /run_ssh docker pull "\$IMAGE"/) - assert.doesNotMatch(directDeployScript, /docker save "\$IMAGE"/) - assert.match(directDeployScript, /up -d --no-deps --pull never/) - assert.doesNotMatch(directDeployScript, /--remove-orphans/) - assert.match(directDeployScript, /--project-name "\$PROJECT_NAME"/) - assert.doesNotMatch(directDeployScript, /overseas-security-service|remove_legacy_overseas_security_container/) - assert.match(directDeployScript, /--pull never/) + assert.match(pipeline,/agent \{ label 'java-builder' \}/) + assert.match(pipeline,/node\('java-test'\)/) + assert.match(pipeline,/lock\(resource: "firstleg-test-\$\{env.RESOLVED_SERVICE\}-deploy"/) + assert.match(pipeline,/'TARGET_IS_LOCAL=true'/) + assert.match(directDeployScript,/up -d --no-deps --pull never/) + assert.doesNotMatch(directDeployScript,/--remove-orphans|docker save/) }) -test('production only accepts skipped downstream tests for a coordinator-pinned commit', () => { - assert.match(pipeline, /boolean coordinatorPinnedCommit = params\.SOURCE_COMMIT\?\.trim\(\) as boolean/) - assert.match(pipeline, /env\.RESOLVED_ENVIRONMENT == 'production' && params\.SKIP_TESTS && !coordinatorPinnedCommit/) - assert.match(pipeline, /生产环境手工构建禁止跳过测试/) +test('downstream skips tests only by explicit flag and keeps test compilation',()=>{ + assert.match(pipeline,/boolean skipTestExecution = params.SKIP_TESTS/) + assert.doesNotMatch(pipeline,/maven.test.skip/) }) -test('development and test deployment locks are acquired before target executors', () => { - const directDeployStage = pipeline.slice( - pipeline.indexOf("stage('部署开发或测试环境')"), - pipeline.indexOf("stage('部署单个服务')") - ) - const serviceLockIndex = directDeployStage.indexOf('lock(resource: serviceDeployResource') - const lockIndex = directDeployStage.indexOf('lock(label: deploySlotLabel') - const nodeIndex = directDeployStage.indexOf('node(targetLabel)') - - assert.match(directDeployStage, /agent none/) - assert.match(directDeployStage, /String targetLabel = env\.RESOLVED_ENVIRONMENT == 'development' \? 'java-dev' : 'java-test'/) - assert.match(directDeployStage, /String serviceDeployResource = "firstleg-\$\{env\.RESOLVED_ENVIRONMENT\}-\$\{env\.RESOLVED_SERVICE\}-deploy"/) - assert.ok(serviceLockIndex >= 0, 'per-service deployment lock must exist') - assert.ok(lockIndex >= 0, 'deployment slot lock must exist') - assert.ok(nodeIndex >= 0, 'target node allocation must exist') - assert.ok(serviceLockIndex < lockIndex, 'per-service lock must be acquired before the shared deployment slot') - assert.ok(lockIndex < nodeIndex, 'deployment lock must be acquired before target node allocation') +test('TEST deployment lock is acquired before target executor',()=>{ + const stage=pipeline.slice(pipeline.indexOf("stage('部署 TEST')")) + assert.match(stage,/agent none/) + assert.ok(stage.indexOf('lock(resource:') < stage.indexOf("node('java-test')")) }) -test('development publishes every service on loopback without changing test defaults', () => { - assert.match(serviceCompose, /\$\{PUBLISHED_BIND_ADDRESS:-0\.0\.0\.0\}:\$\{GATEWAY_PUBLISHED_PORT:-\$\{GATEWAY_PORT:-9000\}\}:9000/) - assert.match(overseasCompose, /\$\{PUBLISHED_BIND_ADDRESS:-0\.0\.0\.0\}:\$\{GATEWAY_OVERSEAS_PUBLISHED_PORT:-\$\{GATEWAY_OVERSEAS_PORT:-9300\}\}:9300/) - assert.match(directDeployScript, /if \[ "\$DEPLOY_ENV" = "development" \]; then\s+PUBLISHED_BIND_ADDRESS=127\.0\.0\.1/) - assert.match(directDeployScript, /if \[ "\$deploy_env" = "development" \]; then\s+export PUBLISHED_BIND_ADDRESS=127\.0\.0\.1/) - assert.doesNotMatch(directDeployScript, /gateway\|gateway-overseas\) (?:export )?PUBLISHED_BIND_ADDRESS=0\.0\.0\.0/) +test('only Gateway publishes to host loopback',()=>{ + assert.equal([...serviceCompose.matchAll(/^ ports:/gm)].length,1) + assert.match(directDeployScript,/export PUBLISHED_BIND_ADDRESS=127\.0\.0\.1/) }) test('Firstleg deployment passes terminal drayage SMTP settings into the container', () => { @@ -158,7 +117,7 @@ test('Firstleg deployment passes terminal drayage SMTP settings into the contain test('parallel image publications isolate registry credentials per Jenkins job', () => { const imageStage = pipeline.slice( pipeline.indexOf("stage('构建并发布单服务镜像')"), - pipeline.indexOf("stage('部署开发或测试环境')") + pipeline.indexOf("stage('部署 TEST')") ) const loginIndex = imageStage.indexOf('docker login') const buildIndex = imageStage.indexOf('docker build') @@ -175,25 +134,11 @@ test('parallel image publications isolate registry credentials per Jenkins job', assert.doesNotMatch(imageStage, /\/home\/deploy\/\.docker\/config\.json/) }) -test('parallel remote deployments keep env and compose assets isolated by project', () => { - assert.match(directDeployScript, /REMOTE_ENV_NAME="\.env\.\$PROJECT_NAME"/) - assert.match(directDeployScript, /REMOTE_COMPOSE_NAME="docker-compose\.\$PROJECT_NAME\.yml"/) - assert.match(directDeployScript, /REMOTE_ENV_TMP="\$REMOTE_DIR\/\$REMOTE_ENV_NAME\.\$SUFFIX\.tmp"/) - assert.match(directDeployScript, /REMOTE_COMPOSE_TMP="\$REMOTE_DIR\/\$REMOTE_COMPOSE_NAME\.\$SUFFIX\.tmp"/) - assert.match(directDeployScript, /env_target="\$4"/) - assert.match(directDeployScript, /compose_target="\$5"/) - assert.match(directDeployScript, /blue_green_target="\$6"/) - assert.match(directDeployScript, /mv -f "\$env_tmp" "\$env_target"/) - assert.match(directDeployScript, /mv -f "\$compose_tmp" "\$compose_target"/) - assert.match(directDeployScript, /mv -f "\$blue_green_tmp" "\$blue_green_target"/) - assert.match(directDeployScript, /env_file="\$7"/) - assert.match(directDeployScript, /compose_file="\$8"/) - assert.match(directDeployScript, /--env-file "\$env_file"/) - assert.match(directDeployScript, /-f "\$compose_file"/) - assert.doesNotMatch(directDeployScript, /mv -f "\$env_tmp" "\$remote_dir\/\.env"/) - assert.doesNotMatch(directDeployScript, /mv -f "\$compose_tmp" "\$remote_dir\/docker-compose\.yml"/) - assert.doesNotMatch(directDeployScript, /--env-file \.env/) - assert.doesNotMatch(directDeployScript, /-f docker-compose\.yml/) +test('TEST deployments use explicit local env and Compose files', () => { + assert.match(directDeployScript,/TEST deploy must run on the target host/) + assert.match(directDeployScript,/--env-file "\$ENV_FILE"/) + assert.match(directDeployScript,/-f "\$COMPOSE_FILE"/) + assert.match(directDeployScript,/if \[ "\$DEPLOY_ENV" = "test" \] && \[ "\$\{TARGET_IS_LOCAL:-false\}" != "true" \]/) }) test('shared builders use a reachable public Maven mirror instead of the retired private proxy', () => { @@ -202,8 +147,8 @@ test('shared builders use a reachable public Maven mirror instead of the retired assert.doesNotMatch(ciMavenSettings, /:18081/) }) -test('Java service images use the canonical Gitea registry base image by default', () => { - const internalBaseImage = 'git.buggoglobal.com/java-project/eclipse-temurin:17-jre-alpine' +test('Java service images use configurable Java 17 base images', () => { + const internalBaseImage = 'eclipse-temurin:17-jre-alpine' assert.match(coordinatorPipeline, new RegExp(internalBaseImage.replaceAll('.', '\\.'))) assert.match(pipeline, new RegExp(internalBaseImage.replaceAll('.', '\\.'))) @@ -214,20 +159,10 @@ test('Java service images use the canonical Gitea registry base image by default }) test('source workspaces are preserved for incremental Git fetches', () => { - const buildStage = pipeline.slice( - pipeline.indexOf("stage('构建并发布单服务')"), - pipeline.indexOf("stage('部署开发或测试环境')") - ) - const directDeployStage = pipeline.slice( - pipeline.indexOf("stage('部署开发或测试环境')"), - pipeline.indexOf("stage('部署单个服务')") - ) - - assert.match(pipeline, /checkout scm/) - assert.doesNotMatch(buildStage, /cleanWs\(/) - assert.match(directDeployStage, /cleanWs\(/) - assert.equal((pipeline.match(/cleanWs\(/g) ?? []).length, 2) - assert.doesNotMatch(coordinatorPipeline, /cleanWs\(/) + const buildStage=pipeline.slice(0,pipeline.indexOf("stage('部署 TEST')")) + assert.match(buildStage,/checkout scm/) + assert.doesNotMatch(buildStage,/cleanWs\(|deleteDir\(/) + assert.match(pipeline.slice(pipeline.indexOf("stage('部署 TEST')")),/deleteDir\(\)/) }) test('direct deployments wait for health and automatically restore the previous image', () => { @@ -240,15 +175,18 @@ test('direct deployments wait for health and automatically restore the previous assert.match(directDeployScript, /return 1/) }) -test('one event bridge build deploys both regional instances in every environment', () => { - assert.match(pipeline, /choice\(name: 'EVENT_BRIDGE_REGION', choices: \['BOTH', 'CN', 'OVERSEAS', 'SELECT'\]/) - assert.match(pipeline, /\['BOTH', 'SELECT'\]\.contains\(params\.EVENT_BRIDGE_REGION\)/) - assert.match(pipeline, /\? \['CN', 'OVERSEAS'\]/) - assert.match(pipeline, /bridgeRegions\.each \{ bridgeRegion ->/) - assert.match(pipeline, /EVENT_BRIDGE_REGION_OVERRIDE=\$\{bridgeRegion\}/) - assert.match(pipeline, /部署事件桥实例:/) - assert.match(pipeline, /imageTag\?\.endsWith\('-both'\)/) - assert.match(pipeline, /imageTag\.substring\(0, imageTag\.length\(\) - '-both'\.length\(\)\)/) - assert.match(pipeline, /env\.DEPLOY_BOTH_EVENT_BRIDGES = deployBothBridges \? 'true' : 'false'/) - assert.match(pipeline, /部署生产事件桥实例:/) +test('retired bridge jobs cannot be dispatched', () => { + assert.doesNotMatch(pipeline,/EVENT_BRIDGE|event-bridge-service|gateway-overseas/) + assert.doesNotMatch(coordinatorPipeline,/EVENT_BRIDGE|event-bridge-service|gateway-overseas/) +}) + + +test('gray publication reuses verified SSH credentials and the unified health executor', () => { + for (const credential of ['firstleg-production-env', 'gitea-production-pull-token', 'firstleg-production-ssh', 'firstleg-production-host', 'firstleg-production-known-hosts']) assert.ok(pipeline.includes(credential)) + assert.match(pipeline, /git merge-base --is-ancestor "\$RESOLVED_SOURCE_COMMIT" origin\/main/) + assert.match(directDeployScript, /StrictHostKeyChecking=yes/) + assert.match(directDeployScript, /trap cleanup_remote EXIT HUP INT TERM/) + assert.match(serviceCompose, /name: firstleg-\$\{DEPLOY_ENV:-test\}-network/) + assert.match(serviceCompose, /SPRING_PROFILES_ACTIVE: \$\{DEPLOY_ENV:-test\},cn/) + assert.doesNotMatch(directDeployScript, /deploy-blue-green|worker|StrictHostKeyChecking=no/) }) diff --git a/scripts/test-event-fault-executor.mjs b/scripts/test-event-fault-executor.mjs new file mode 100644 index 000000000..c3fbab69e --- /dev/null +++ b/scripts/test-event-fault-executor.mjs @@ -0,0 +1,179 @@ +// Internal single TEST fault lifecycle. Uses the existing ACK-loss proxy and +// authenticated original-DEAD retry endpoint, never direct business SQL writes. +import { readFileSync } from 'node:fs' +import { createHash } from 'node:crypto' +import { isDeepStrictEqual } from 'node:util' +import { assertTestDeliveryFaultProof, deliveryFaultScopeKeys } from './test-delivery-fault-proof.mjs' +import { assertTestFeeVoidFaultProof, feeVoidScopeKeys, feeSourceTypes } from './test-fee-void-fault-proof.mjs' +export { assertTestFeeVoidFaultProof } from './test-fee-void-fault-proof.mjs' +export { assertTestDeliveryFaultProof } from './test-delivery-fault-proof.mjs' +import { validateEvidenceConfig } from './fba-core-regression-evidence.mjs' + +const runtime = readFileSync(new URL('./test-event-fault-runtime.py', import.meta.url), 'utf8') +const proxySource = readFileSync(new URL('./event-delivery-fault-proxy.py', import.meta.url)).toString('base64') +const encode = value => Buffer.from(value).toString('base64') +const expression = value => '[' + encode(value).match(/.{1,4000}/g).map(part => `'${part}'`).join(',') + "].join('')" + +export function renderTestFaultAction(action, scope, agent) { + return renderAction(action,scope,agent,'LOADING') +} +export function renderTestUnloadFaultAction(action, scope, agent) { + return renderAction(action,scope,agent,'UNLOAD') +} +function renderAction(action,scope,agent,direction) { + const keys=direction==='FEE_VOID'?feeVoidScopeKeys:direction==='DELIVERY'?deliveryFaultScopeKeys:direction==='UNLOAD'?['marker','customerId','containerBookingId']:['marker','customerId','loadingPlanId','containerBookingId'] + if (!(action==='preflight'&&direction==='FEE_VOID')&&!['prepare', 'activate', 'proxy-ready', 'health', 'status', 'recover', 'proof', 'restore', 'finish'].includes(action) + || !/^[A-Za-z0-9_.-]{1,128}$/.test(agent) || !/^[A-Za-z0-9_-]{1,96}$/.test(scope?.marker || '') + || keys.filter(key=>!['marker','sourceType'].includes(key)).some(key => !/^[1-9][0-9]{0,19}$/.test(scope?.[key] || '')) + || (direction==='FEE_VOID'&&(!feeSourceTypes.includes(scope?.sourceType)||Number(scope?.sourceVersion)>2147483647)) + || (direction==='FEE_VOID'&&keys.some(key=>typeof scope?.[key]!=='string')) + || (direction!=='LOADING'&&Object.keys(scope).some(key=>!keys.includes(key)))) { + throw Error('Invalid TEST fault action or scope') + } + const config = encode(JSON.stringify({ action, scope, direction, ...(action === 'prepare' ? { proxySource } : {}) })) + const groovy = `def p=new ProcessBuilder(['python3','-c',new String(java.util.Base64.decoder.decode(${expression(runtime)}),'UTF-8'),'${config}']).redirectErrorStream(true).start();def output=p.inputStream.getText('UTF-8');p.waitFor();print(output)` + return `def c=jenkins.model.Jenkins.instance.getComputer('${agent}');if(c==null || c.channel==null)throw new RuntimeException('TEST fault agent unavailable');print(hudson.util.RemotingDiagnostics.executeGroovy(new String(java.util.Base64.decoder.decode(${expression(groovy)}),'UTF-8'),c.channel))` +} + +export function createTestFaultExecutor(env, fetcher = globalThis.fetch) { + return createExecutor(env,fetcher,'LOADING') +} +export function createTestDeliveryFaultExecutor(env, fetcher = globalThis.fetch) { + return createExecutor(env,fetcher,'DELIVERY') +} +export function createTestFeeVoidFaultExecutor(env, fetcher = globalThis.fetch) { + return createExecutor(env,fetcher,'FEE_VOID') +} +export function createTestUnloadFaultExecutor(env, fetcher = globalThis.fetch) { + return createExecutor(env,fetcher,'UNLOAD') +} +const canonical=value=>Array.isArray(value)?value.map(canonical):value&&typeof value==='object' + ?Object.fromEntries(Object.keys(value).sort().map(key=>[key,canonical(value[key])])):value +export function mysqlDatetimeMillisFromInstant(value) { + const match=typeof value==='string'&&value.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?Z$/) + if(!match)return NaN + const seconds=Date.parse(match[1]+'Z') + const nanos=Number((match[2]||'').padEnd(9,'0')) + // DATETIME(3) rounds fractional seconds; Date.parse alone truncates them. + return seconds+Math.floor((nanos+500000)/1000000) +} +export function assertTestUnloadFaultProof(proof,scope) { + const require=(passed,code)=>{if(!passed)throw Error('Invalid unload fault proof: '+code)} + require(proof?.readOnly===true&&Object.keys(scope).every(key=>proof[key]===scope[key]),'SCOPE') + const before=proof.beforeRetry,after=proof.afterRetry,row=proof.producer,env=row?.envelope + require(row&&env&&/^[1-9][0-9]{0,19}$/.test(row.entryId)&&/^(?:[0-9a-fA-F-]{36}|[0-9A-HJKMNP-TV-Z]{26})$/.test(row.eventId),'IDENTITY') + require(row.eventType==='OverseasContainerUnloaded'&&row.customerId==='0'&&row.aggregateId===scope.containerBookingId + &&row.eventKey===`OverseasContainerUnloaded:${scope.containerBookingId}`&&row.aggregateType==='CONTAINER_BOOKING','PRODUCER') + require(env.eventId===row.eventId&&env.eventType===row.eventType&&Number(env.eventVersion)===1&&env.tenantId==='0' + &&env.aggregateId===scope.containerBookingId&&env.aggregateType==='CONTAINER_BOOKING' + &&env.payload?.sourceContainerBookingId===scope.containerBookingId&&Boolean(env.payload.containerNo),'ENVELOPE') + const hash='sha256:'+createHash('sha256').update(JSON.stringify(canonical(env.payload))).digest('hex') + require(row.payloadHash===hash&&env.payloadHash===hash,'HASH') + require(before?.publishStatus==='DEAD'&&before.failedAttempts===12&&row.publishStatus==='PUBLISHED' + &&before.entryId===row.entryId&&before.eventId===row.eventId&&before.payloadHash===hash + &&isDeepStrictEqual(before.envelope,env)&&after?.publishStatus==='PUBLISHED'&&after.entryId===row.entryId + &&after.eventId===row.eventId&&after.payloadHash===hash&&isDeepStrictEqual(after.envelope,env),'ORIGINAL_RETRY') + const inbox=proof.consumer + require(inbox?.count===1&&inbox.status==='PROCESSED'&&inbox.eventId===row.eventId&&inbox.payloadHash===hash + &&inbox.eventType===row.eventType&&inbox.aggregateId===scope.containerBookingId&&isDeepStrictEqual(inbox.payload,env.payload),'INBOX') + const attempts=proof.attempts||[],proxy=proof.proxyRecords||[] + require(attempts.length===13&&new Set(attempts.map(a=>a.attemptId)).size===13&&attempts.every(a=>a.eventId===row.eventId&&a.entryId===row.entryId&&a.serviceName==='lastleg') + &&attempts.slice(0,11).every(a=>a.outcome==='RETRY')&&attempts[11].outcome==='DEAD'&&attempts[12].outcome==='DUPLICATE','ATTEMPTS') + const matches=proxy.filter(p=>p.matched===true) + require(matches.length===13&&matches.every((p,i)=>p.eventId===row.eventId&&p.payloadHash===hash&&p.attempt===i+1) + &&matches.slice(0,12).every((p,i)=>p.errorCode==='ACK_LOSS_INJECTED'&&p.outcome===(i===0?'APPLIED':'DUPLICATE')) + &&matches[12].outcome==='DUPLICATE'&&matches[12].errorCode==null,'REAL_ACK_LOSS') + const audit=proof.retryAudit + require(audit&&proof.retryAuditCount===1&&audit.requestId===proof.retryRequestId&&audit.entryId===row.entryId + &&audit.eventId===row.eventId&&audit.payloadHash===hash&&audit.previousFailures===12,'RETRY_AUDIT') + const container=before.container + require(container&&container.id===scope.containerBookingId&&container.containerNo===env.payload.containerNo + &&isDeepStrictEqual(container,after.container)&&container.version!=null&&container.emptyReportSourceVersion!=null,'CONTAINER_UNCHANGED') + const time=value=>Date.parse(String(value).replace(' ','T').replace(/Z$/,'')+'Z') + require(time(container.unloadingCompletedAt)===mysqlDatetimeMillisFromInstant(env.payload.unloadingCompletedAt) + &&time(container.unloadingPhotoUploadedAt)===mysqlDatetimeMillisFromInstant(env.payload.unloadingPhotoUploadedAt) + &&time(container.estimatedEmptyReportedAt)-time(container.unloadingCompletedAt)===72*3600000 + &&(env.payload.actualEmptyReportedAt==null?container.actualEmptyReportedAt===null:time(container.actualEmptyReportedAt)===Date.parse(env.payload.actualEmptyReportedAt)) + &&String(container.emptyReportSourceVersion)===String(env.aggregateVersion),'CONTAINER_FACTS') + return proof +} +function createExecutor(env,fetcher,direction) { + validateEvidenceConfig(env) + if (env.CHONGCHONG_ENV !== 'test') throw Error('TEST fault requires CHONGCHONG_ENV=test') + let scope = null, finished = false, preparedEvidence=null, restoration=null, preparationAttempted=false, ready=false + async function action(name) { + const script = renderAction(name, scope, env.FBA_CORE_JENKINS_AGENT,direction) + const response = await fetcher(new URL('scriptText', env.JENKINS_URL.replace(/\/?$/, '/')), { + method: 'POST', redirect: 'error', headers: { Authorization: 'Basic ' + encode(env.JENKINS_USER + ':' + env.JENKINS_API_TOKEN), + 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ script }), signal: AbortSignal.timeout(55000) }) + if (!response.ok) throw Error('TEST fault HTTP ' + response.status) + const rows = (await response.text()).split(/\r?\n/).filter(row => row.startsWith('TEST_EVENT_JSON=')) + if (rows.length !== 1) throw Error('TEST fault evidence missing or ambiguous') + const result = JSON.parse(rows[0].slice('TEST_EVENT_JSON='.length)) + if (!result?.ok) throw Error('TEST fault action failed: ' + (/^[A-Z_0-9]{1,80}$/.test(result?.error) ? result.error : 'UNKNOWN')) + return result.data + } + async function wait(name, check, attempts = 120, tolerateStartup = false) { + for (let index = 0; index < attempts; index++) { + let value + try { value = await action(name) } catch (error) { + if (!tolerateStartup || !String(error.message).endsWith('TEST_EVENT_ACTION_FAILED')) throw error + } + if (value && check(value)) return value + await new Promise(resolve => setTimeout(resolve, 1000)) + } + throw Error('TEST fault observation timed out: ' + name) + } + async function cleanup() { + if(direction==='FEE_VOID'&&!preparationAttempted)return {attempted:false} + if (!scope || finished) return restoration + await action('restore') + await wait('health', row => row.healthy) + restoration=await action('finish') + if(direction!=='LOADING'&&!(/^sha256:[0-9a-f]{64}$/.test(preparedEvidence?.originalImageId||'') + &&/^[0-9a-f]{64}$/.test(preparedEvidence?.originalFingerprint||'') + &&restoration?.restored===true&&restoration.healthy===true&&restoration.proxyStopped===true + &&restoration.producerName==='firstleg-test-lastleg'&&restoration.originalImageId===preparedEvidence?.originalImageId + &&restoration.currentImageId===preparedEvidence?.originalImageId&&restoration.originalFingerprint===preparedEvidence?.originalFingerprint + &&restoration.currentFingerprint===preparedEvidence?.originalFingerprint))throw Error((direction==='FEE_VOID'?'Fee void':direction==='DELIVERY'?'Delivery':'Unload')+' restoration evidence mismatch') + finished = true + return restoration + } + return { + async prepare(next) { + if (scope) { + if(direction!=='LOADING'&&!isDeepStrictEqual(scope,next))throw Error('TEST unload fault scope drift') + if(direction==='FEE_VOID'&&!ready)throw Error('Fee void preparation did not complete') + return // Pin only the first actual current-run plan; other events pass through. + } + if(direction!=='LOADING')renderAction('prepare',next,env.FBA_CORE_JENKINS_AGENT,direction) + scope = direction!=='LOADING'?{...next}:{ marker: next.marker, customerId: next.customerId, + loadingPlanId: next.loadingPlanId, containerBookingId: next.containerBookingId } + if(direction==='FEE_VOID')await wait('preflight', row=>row.ready===true,60) + preparationAttempted=true + preparedEvidence=await action('prepare') + await wait('proxy-ready', row => row.ready, 10, true) + await action('activate') + await wait('health', row => row.healthy) + ready=true + }, + async readProof(expected) { + if (!scope || (direction!=='LOADING'?!isDeepStrictEqual(scope,expected):Object.keys(scope).some(key => expected?.[key] !== scope[key]))) throw Error('TEST fault scope drift') + try { + await wait('status', row => row.producer?.publishStatus === 'DEAD' && row.producer.failedAttempts === 12, 180) + await action('recover') + await wait('status', row => row.producer?.publishStatus === 'PUBLISHED', 90) + const proof = await action('proof') + if(direction==='UNLOAD')assertTestUnloadFaultProof(proof,scope) + if(direction==='DELIVERY')assertTestDeliveryFaultProof(proof,scope) + if(direction==='FEE_VOID') { + if(!isDeepStrictEqual(proof.prerequisite,preparedEvidence?.prerequisite))throw Error('Fee prerequisite evidence drift') + assertTestFeeVoidFaultProof(proof,scope) + } + await cleanup() + return { ...proof, restored: true, ...(direction!=='LOADING'?{restoration}:{}) } + } finally { if(direction!=='LOADING')await cleanup() } + }, + cleanup, + } +} diff --git a/scripts/test-event-fault-executor.test.mjs b/scripts/test-event-fault-executor.test.mjs new file mode 100644 index 000000000..53d4c8c0c --- /dev/null +++ b/scripts/test-event-fault-executor.test.mjs @@ -0,0 +1,358 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createHash } from 'node:crypto' +import { spawnSync } from 'node:child_process' +import { createTestFaultExecutor, createTestUnloadFaultExecutor, createTestDeliveryFaultExecutor, assertTestDeliveryFaultProof, assertTestUnloadFaultProof, renderTestFaultAction } from './test-event-fault-executor.mjs' +import { withDeliveryEventRecovery } from './admin-test-env-modules/phase5-root-fulfillment.mjs' + +const scope = { marker: 'TEST-ROOT', customerId: '7', loadingPlanId: '99', containerBookingId: '66' } +const environment = { CHONGCHONG_ENV: 'test', JENKINS_URL: 'https://jenkins.example.invalid/', + JENKINS_USER: 'offline', JENKINS_API_TOKEN: 'offline-secret', FBA_CORE_JENKINS_AGENT: 'java-test-agent' } +function requestedAction(options) { + const script = options.body.get('script') + const chunks = [...script.matchAll(/'([A-Za-z0-9+/=]+)'/g)].map(match => match[1]).join('') + const groovy = Buffer.from(chunks, 'base64').toString('utf8') + const argument = groovy.match(/'UTF-8'\),'([A-Za-z0-9+/=]+)'\]\)/)?.[1] + return JSON.parse(Buffer.from(argument, 'base64').toString('utf8')) +} +function transport(calls, failActivate = false) { + let recovered = false + return async (_url, options) => { + assert.equal(options.redirect, 'error') + const { action } = requestedAction(options) + calls.push(action) + if (action === 'recover') recovered = true + const data = action === 'proxy-ready' ? { ready: true } : action === 'health' ? { healthy: true } + : action === 'status' ? { producer: { publishStatus: recovered ? 'PUBLISHED' : 'DEAD', failedAttempts: 12 } } + : action === 'proof' ? { ...scope, restored: false } : {} + const result = action === 'activate' && failActivate ? { ok: false, error: 'DOCKER_409' } : { ok: true, data } + return { ok: true, text: async () => 'TEST_EVENT_JSON=' + JSON.stringify(result) } + } +} +test('scope and actions are bounded and renderer never embeds caller credentials', () => { + const rendered = renderTestFaultAction('status', scope, environment.FBA_CORE_JENKINS_AGENT) + assert.ok(!rendered.includes(environment.JENKINS_API_TOKEN)) + for (const change of [{ ...scope, customerId: '7;DROP' }, { ...scope, marker: '../other' }]) { + assert.throws(() => renderTestFaultAction('status', change, environment.FBA_CORE_JENKINS_AGENT)) + } + assert.throws(() => renderTestFaultAction('arbitrary', scope, 'java-test-agent')) + assert.throws(() => createTestFaultExecutor({ ...environment, CHONGCHONG_ENV: 'prod' })) +}) + +const unloadScope = { marker: 'TEST-ROOT', customerId: '7', containerBookingId: '66' } +const originalImageId = 'sha256:' + 'a'.repeat(64), originalFingerprint = 'b'.repeat(64) +function unloadProof(nanoseconds = false) { + const payload = { actualEmptyReportedAt: null, containerNo: 'OFFLINE66', sourceContainerBookingId: '66', + unloadingCompletedAt: nanoseconds ? '2026-10-02T01:00:00.136827515Z' : '2026-10-02T01:00:00Z', unloadingPhotoUploadedAt: '2026-10-02T00:59:00Z' } + const payloadHash = 'sha256:' + createHash('sha256').update(JSON.stringify(payload)).digest('hex') + const eventId = '00000000-0000-4000-8000-000000000001', entryId = '123' + const envelope = { eventId, eventType: 'OverseasContainerUnloaded', eventVersion: 1, tenantId: '0', + aggregateId: '66', aggregateType: 'CONTAINER_BOOKING', aggregateVersion: 3, payloadHash, payload } + const row = { entryId, eventId, payloadHash, envelope, eventType: envelope.eventType, customerId: '0', + aggregateId: '66', aggregateType: 'CONTAINER_BOOKING', eventKey: 'OverseasContainerUnloaded:66', publishStatus: 'PUBLISHED' } + const container = { id: '66', containerNo: 'OFFLINE66', version: '9', emptyReportSourceVersion: '3', + unloadingCompletedAt: nanoseconds ? '2026-10-02 01:00:00.137' : '2026-10-02 01:00:00', unloadingPhotoUploadedAt: '2026-10-02 00:59:00', + estimatedEmptyReportedAt: nanoseconds ? '2026-10-05 01:00:00.137' : '2026-10-05 01:00:00', actualEmptyReportedAt: null } + return JSON.parse(JSON.stringify({ ...unloadScope, readOnly: true, producer: row, + beforeRetry: { ...row, publishStatus: 'DEAD', failedAttempts: 12, container }, afterRetry: { ...row, container }, + consumer: { count: 1, status: 'PROCESSED', eventId, payloadHash, eventType: envelope.eventType, aggregateId: '66', payload }, + attempts: Array.from({ length: 13 }, (_, i) => ({ attemptId: `attempt-${i}`, entryId, eventId, serviceName: 'lastleg', outcome: i < 11 ? 'RETRY' : i === 11 ? 'DEAD' : 'DUPLICATE' })), + proxyRecords: Array.from({ length: 13 }, (_, i) => ({ eventId, payloadHash, attempt: i + 1, matched: true, + outcome: i ? 'DUPLICATE' : 'APPLIED', errorCode: i < 12 ? 'ACK_LOSS_INJECTED' : null })), + retryRequestId: '00000000-0000-4000-8000-000000000002', retryAuditCount: 1, + retryAudit: { requestId: '00000000-0000-4000-8000-000000000002', entryId, eventId, payloadHash, previousFailures: 12 } })) +} +function unloadTransport(calls, proof = unloadProof(), finish = {}) { + let recovered = false + return async (_url, options) => { + const config = requestedAction(options), { action } = config + assert.equal(config.direction, 'UNLOAD') + assert.deepEqual(config.scope, unloadScope) + calls.push(action) + if (action === 'recover') recovered = true + const data = action === 'prepare' ? { originalImageId, originalFingerprint } + : action === 'proxy-ready' ? { ready: true } : action === 'health' ? { healthy: true } + : action === 'status' ? { producer: { publishStatus: recovered ? 'PUBLISHED' : 'DEAD', failedAttempts: 12 } } + : action === 'proof' ? proof : action === 'finish' ? { restored: true, healthy: true, proxyStopped: true, + producerName: 'firstleg-test-lastleg', originalImageId, currentImageId: originalImageId, + originalFingerprint, currentFingerprint: originalFingerprint, ...finish } : {} + return { ok: true, text: async () => 'TEST_EVENT_JSON=' + JSON.stringify({ ok: true, data }) } + } +} +test('unload factory pins exactly three scope fields and verifies restoration before success', async () => { + const calls = [], executor = createTestUnloadFaultExecutor(environment, unloadTransport(calls)) + await assert.rejects(executor.prepare({ ...unloadScope, eventType: 'Other' }), /Invalid/) + await executor.prepare(unloadScope) + await assert.rejects(executor.prepare({ ...unloadScope, containerBookingId: '67' }), /scope drift/) + await assert.rejects(executor.readProof({ ...unloadScope, eventType: 'Other' }), /scope drift/) + const proof = await executor.readProof(unloadScope) + assert.equal(proof.restored, true) + assert.equal(proof.restoration.currentImageId, originalImageId) + await executor.cleanup() + assert.deepEqual(calls, ['prepare', 'proxy-ready', 'activate', 'health', 'status', 'recover', 'status', 'proof', 'restore', 'health', 'finish']) +}) +test('unload proof rejects changed original event, fabricated ACK, nonunique audit and changed business facts', () => { + assertTestUnloadFaultProof(unloadProof(), unloadScope) + for (const mutate of [ + p => { p.producer.customerId = '7' }, p => { p.producer.eventKey = 'other' }, + p => { p.producer.envelope.tenantId = '7' }, p => { p.producer.envelope.payload.containerNo = 'CHANGED' }, + p => { p.beforeRetry.entryId = '124' }, p => { p.beforeRetry.envelope.aggregateVersion = 4 }, + p => { p.beforeRetry.failedAttempts = 11 }, p => { p.afterRetry.eventId = 'OTHER' }, + p => { p.consumer.count = 2 }, p => { p.consumer.status = 'RECEIVED' }, p => { p.consumer.payload = {} }, + p => { p.attempts.pop() }, p => { p.attempts[0].serviceName = 'firstleg' }, + p => { p.proxyRecords[0].outcome = 'DUPLICATE' }, p => { p.proxyRecords[0].errorCode = null }, + p => { p.proxyRecords[12].attempt = 1 }, p => { p.retryAuditCount = 2 }, + p => { p.retryAudit.requestId = 'OTHER' }, p => { p.afterRetry.container.version = '10' }, + p => { p.beforeRetry.container.estimatedEmptyReportedAt = p.afterRetry.container.estimatedEmptyReportedAt = '2026-10-04 01:00:00' }, + ]) { + const proof = unloadProof(); mutate(proof) + assert.throws(() => assertTestUnloadFaultProof(proof, unloadScope), /Invalid unload fault proof/) + } +}) + +test('unload timestamps follow DATETIME(3) rounding rather than allowing time drift', () => { + const proof=unloadProof(true) + assertTestUnloadFaultProof(proof,unloadScope) + for(const row of [proof.beforeRetry,proof.afterRetry]) { + row.container.unloadingCompletedAt='2026-10-02 01:00:00.138' + row.container.estimatedEmptyReportedAt='2026-10-05 01:00:00.138' + } + assert.throws(()=>assertTestUnloadFaultProof(proof,unloadScope),/CONTAINER_FACTS/) +}) +test('invalid unload proof still restores and incomplete restoration never becomes success', async () => { + const invalid = unloadProof(); invalid.consumer.count = 0 + const calls = [], executor = createTestUnloadFaultExecutor(environment, unloadTransport(calls, invalid)) + await executor.prepare(unloadScope) + await assert.rejects(executor.readProof(unloadScope), /INBOX/) + assert.deepEqual(calls.slice(-3), ['restore', 'health', 'finish']) + for (const change of [{ restored: false }, { healthy: false }, { proxyStopped: false }, + { currentImageId: 'sha256:' + 'c'.repeat(64) }, { currentFingerprint: 'c'.repeat(64) }]) { + const failed = createTestUnloadFaultExecutor(environment, unloadTransport([], unloadProof(), change)) + await failed.prepare(unloadScope) + await assert.rejects(failed.readProof(unloadScope), /restoration evidence mismatch/) + } +}) +test('Python reverse SQL routing, scope isolation and full configuration recovery fail closed offline', () => { + const result = spawnSync('python', ['-c', String.raw` +import ast, copy, importlib.util, io, pathlib, sys +from unittest.mock import patch +sys.dont_write_bytecode = True +path = pathlib.Path('scripts/test-event-fault-runtime.py') +ast.parse(path.read_text(encoding='utf-8')) +spec = importlib.util.spec_from_file_location('fault_runtime', path) +m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m) +scope = {'marker':'TEST-ROOT','customerId':'7','containerBookingId':'66'} +assert m.target_setting({'direction':'UNLOAD'}) == ('FIRSTLEG_SERVICE_URL','http://firstleg:9202') +assert m.scope_directory(scope, 'UNLOAD') != m.scope_directory(dict(scope, loadingPlanId='99')) +try: m.scope_directory(dict(scope, eventType='Other'), 'UNLOAD'); assert False +except RuntimeError as error: assert str(error) == 'INVALID_SCOPE' +queries=[] +with patch.object(m, 'inspect', side_effect=lambda name: name), patch.object(m, 'read_sql', side_effect=lambda name, sql: queries.append((name, sql)) or []): + assert m.producer(scope, 'UNLOAD') is None +assert queries[0][0] == 'firstleg-test-lastleg' +assert "customer_id='0'" in queries[0][1] and "event_key='OverseasContainerUnloaded:66'" in queries[0][1] +assert "JSON_EXTRACT(payload_json,'$')" in queries[0][1] and "publishStatus',status" in queries[0][1] +row={'entryId':'123','eventId':'00000000-0000-4000-8000-000000000001','payloadHash':'sha256:'+'a'*64,'publishStatus':'DEAD','failedAttempts':12,'envelope':{'eventType':'OverseasContainerUnloaded'}} +retry_state={'direction':'UNLOAD','operationsRequestId':'00000000-0000-4000-8000-000000000002'} +retry_container={'Config':{'Env':['EVENT_OPERATIONS_TOKEN=offline']},'NetworkSettings':{'Networks':{'firstleg-test-network':{'IPAddress':'172.20.0.3'}}}} +with patch.object(m,'producer',return_value=row),patch.object(m,'control',return_value=row),patch.object(m,'container_facts',return_value={'version':'9'}),patch.object(m,'business_ids',side_effect=AssertionError('reverse must not query inventory or orders')),patch.object(m,'private_write'),patch.object(m,'inspect',return_value=retry_container),patch.object(m.urllib.request,'urlopen',return_value=io.BytesIO(b'{"code":"200"}')) as http: + assert m.recover(scope,pathlib.Path('/offline'),retry_state)['retrySubmitted'] + assert http.call_args.args[0].full_url == 'http://172.20.0.3:9303/internal/lastleg/event-operations/123/retry' + assert retry_state['beforeRetry']['container']=={'version':'9'} + assert retry_state['beforeRetry']['envelope']==row['envelope'] +row['publishStatus']='PUBLISHED' +queries=[] +with patch.object(m,'producer',return_value=row),patch.object(m,'control',return_value={'records':[]}),patch.object(m,'container_facts',return_value={'version':'9'}),patch.object(m,'business_ids',side_effect=AssertionError('reverse must not query inventory or orders')),patch.object(m,'inspect',side_effect=lambda name:name),patch.object(m,'read_sql',side_effect=lambda name,sql: queries.append((name,sql)) or []),patch.object(m,'private_write') as save: + proof=m.proof(scope,retry_state) + save.assert_called_once_with(m.scope_directory(scope,'UNLOAD')/'proof.json',proof) +assert proof['retryAuditCount']==0 and proof['retryRequestId']==retry_state['operationsRequestId'] +assert [item[0] for item in queries]==['firstleg-test-lastleg','firstleg-test-lastleg','firstleg-test-business'] +assert "WHERE entry_id='123' AND event_type='OverseasContainerUnloaded'" in queries[1][1] +assert 'fl_container_event_inbox' in queries[2][1] +container={'Name':'/firstleg-test-lastleg','Id':'container123456789','Image':'sha256:'+'a'*64, + 'Config':{'Image':'lastleg:test','Env':['FIRSTLEG_SERVICE_URL=http://firstleg:9202','A=1'],'Labels':{'com.docker.compose.project':'firstleg-test'}}, + 'HostConfig':{'Binds':['source:/data:rw'],'PortBindings':{'9303/tcp':[{'HostPort':'9303'}]}}, + 'Mounts':[{'Type':'bind','Source':'/source','Destination':'/data','RW':True}], + 'NetworkSettings':{'Networks':{'firstleg-test-network':{'IPAddress':'172.20.0.3','Aliases':['lastleg-service','container123']}}}, + 'State':{'Health':{'Status':'healthy'}}} +fingerprint=m.fingerprint(container) +multi=copy.deepcopy(container) +multi['NetworkSettings']['Networks']['firstleg-test-network']['MacAddress']='02:42:ac:15:00:04' +multi['NetworkSettings']['Networks']['second-test-network']={'IPAddress':'172.22.0.7','MacAddress':'02:42:ac:16:00:07','Aliases':['lastleg']} +legacy=copy.deepcopy(multi); legacy['Config']['MacAddress']='02:42:ac:16:00:07' +assert m.fingerprint(legacy)==m.fingerprint(multi) +changed=copy.deepcopy(legacy); changed['Config']['MacAddress']='02:42:ac:99:00:01' +assert m.fingerprint(changed)!=m.fingerprint(multi) +changed=copy.deepcopy(legacy); changed['NetworkSettings']['Networks']['second-test-network']['MacAddress']='02:42:ac:16:00:08' +assert m.fingerprint(changed)!=m.fingerprint(multi) +state={'direction':'UNLOAD','restored':False,'image':container['Config']['Image'],'originalImageId':container['Image'], + 'originalFingerprint':fingerprint,'originalBody':m.recreation(container,m.env_of(container)),'proxyPid':123,'controlToken':'offline'} +for mutate in [lambda c:c['HostConfig'].update(Binds=['other:/data:rw']),lambda c:c['NetworkSettings']['Networks']['firstleg-test-network'].update(IPAddress='172.20.0.4'),lambda c:c.update(Image='sha256:'+'b'*64),lambda c:c['State']['Health'].update(Status='unhealthy')]: + changed=copy.deepcopy(container); mutate(changed) + with patch.object(m,'inspect',return_value=changed), patch.object(m,'private_write') as write: + try: m.finish(pathlib.Path('/offline'),copy.deepcopy(state)); assert False + except RuntimeError as error: assert str(error).startswith('RESTORED_') + write.assert_not_called() +with patch.object(m,'inspect',return_value=container),patch.object(m.Path,'exists',return_value=False),patch.object(m,'private_write'): + restored=m.finish(pathlib.Path('/offline'),copy.deepcopy(state)) +assert restored['proxyStopped'] and restored['currentFingerprint']==fingerprint and restored['originalImageId']==restored['currentImageId'] +with patch.object(m,'inspect',return_value=container),patch.object(m.Path,'exists',return_value=True),patch.object(m.Path,'read_bytes',return_value=b'python3\x00'+str(pathlib.Path('/offline')/'proxy.py').encode()+b'\x00'),patch.object(m.os,'kill'),patch.object(m.time,'sleep'),patch.object(m,'private_write') as write: + try: m.finish(pathlib.Path('/offline'),copy.deepcopy(state)); assert False + except RuntimeError as error: assert str(error)=='PROXY_NOT_STOPPED' + write.assert_not_called() +`], { encoding: 'utf8', cwd: new URL('..', import.meta.url), timeout: 10000 }) + assert.equal(result.status, 0, result.stderr || result.error?.message) +}) +test('only original scoped event is recovered and restoration precedes a successful proof', async () => { + const calls = [], executor = createTestFaultExecutor(environment, transport(calls)) + await executor.prepare(scope) + await executor.prepare({ ...scope, loadingPlanId: '100' }) + const proof = await executor.readProof({ containerBookingId: '66', loadingPlanId: '99', customerId: '7', marker: 'TEST-ROOT' }) + assert.equal(proof.restored, true) + await executor.cleanup() + assert.deepEqual(calls, ['prepare', 'proxy-ready', 'activate', 'health', 'status', 'recover', + 'status', 'proof', 'restore', 'health', 'finish']) +}) +test('failed activation retains task ownership for explicit restoration, never retries activation', async () => { + const calls = [], executor = createTestFaultExecutor(environment, transport(calls, true)) + await assert.rejects(executor.prepare(scope), /DOCKER_409/) + await executor.cleanup() + assert.equal(calls.filter(name => name === 'activate').length, 1) + assert.deepEqual(calls.slice(-3), ['restore', 'health', 'finish']) +}) + +const deliveryScope={marker:'TEST-DELIVERY',customerId:'7',deliveryId:'90',sourceInboundId:'200',sourceOrderId:'100',rootOrderId:'100'} +function deliveryProof(mode='ROOT_PENDING') { + const scope={...deliveryScope,...(mode==='CHILD_FIRST'?{sourceOrderId:'101',sourceInboundId:'201'}:{})} + const eventId='00000000-0000-4000-8000-000000000001', entryId='123' + const payload={deliveryOrderId:'90',deliveryNo:'DL-OFFLINE',lastlegOrderId:'300',lastlegOrderNo:'LL-OFFLINE', + sourceInboundId:scope.sourceInboundId,inboundCode:'IN-OFFLINE',sourceOrderId:scope.sourceOrderId, + sourceOrderNo:'FL-OFFLINE',rootOrderId:'100',fbaWarehouseCode:'ABE4',completedAt:'2026-10-03T01:00:00Z'} + const payloadHash='sha256:'+createHash('sha256').update(JSON.stringify(payload,Object.keys(payload).sort())).digest('hex') + const envelope={eventId,eventType:'OverseasDeliveryCompleted',eventVersion:1,tenantId:'7',aggregateType:'INBOUND_ORDER',aggregateId:scope.sourceInboundId,aggregateVersion:5,payloadHash,payload} + const producer={entryId,eventId,eventType:envelope.eventType,customerId:'7',aggregateId:scope.sourceInboundId,aggregateType:'INBOUND_ORDER',eventKey:`OverseasDeliveryCompleted:DL-OFFLINE:${scope.sourceInboundId}`,envelope,payloadHash,publishStatus:'PUBLISHED'} + const ownRoot=mode!=='CHILD_FIRST', childComplete=mode!=='ROOT_PENDING', rootComplete=mode==='ALL_COMPLETE' + const orders=['100','101'].map(id=>({id,rootOrderId:'100',customerId:'7',orderRole:id==='100'?'MASTER':'CHILD', + orderNo:id===scope.sourceOrderId?'FL-OFFLINE':'OTHER',inboundCode:id===scope.sourceOrderId?'IN-OFFLINE':'OTHER', + status:(id==='100'?rootComplete:childComplete)?'COMPLETED':'APPROVED',version:3, + completedAt:(id==='100'?rootComplete:childComplete)?'2026-10-03 01:00:00':null})) + const consumer={eventId,eventType:envelope.eventType,aggregateId:scope.sourceInboundId,payloadHash,payload,status:'PROCESSED'} + const business={inbound:[{id:scope.sourceInboundId,customerId:'7',orderId:scope.sourceOrderId,orderNo:'FL-OFFLINE',inboundCode:'IN-OFFLINE',processStatus:'DELIVERED',orderStatusSnapshot:orders.find(o=>o.id===scope.sourceOrderId).status,version:2}], + inboundHistory:[{id:'41',inboundId:scope.sourceInboundId,action:'COMPLETE_DELIVERY',fromStatus:'LOADED',toStatus:'DELIVERED',occurredAt:'2026-10-03 01:00:00'}], + inbox:[{...consumer,processedAt:'2026-10-03 01:00:01'}],order:orders, + orderHistory:[...(ownRoot?[{id:'51',orderId:'100',action:'OVERSEAS_DELIVERED',occurredAt:'2026-10-03 01:00:00'}]:[]),...(childComplete?[{id:'52',orderId:'101',action:'OVERSEAS_DELIVERED',occurredAt:'2026-10-03 01:00:00'}]:[]),...(rootComplete?[{id:'53',orderId:'100',action:'FULFILLMENT_COMPLETED'}]:[])], + completionEvent:orders.filter(o=>o.status==='COMPLETED').map((o,i)=>({id:String(60+i),orderId:o.id,eventType:'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED',status:'PUBLISHED',payload:{orderId:o.id}}))} + const old=unloadProof() + return JSON.parse(JSON.stringify({...scope,readOnly:true,producer,consumer:{...consumer,count:1}, + beforeRetry:{...producer,publishStatus:'DEAD',failedAttempts:12,business},afterRetry:{...producer,business}, + attempts:old.attempts,proxyRecords:old.proxyRecords.map(p=>({...p,payloadHash})),retryAudit:{...old.retryAudit,payloadHash},retryAuditCount:1,retryRequestId:old.retryRequestId})) +} +function deliveryTransport(calls, proof=deliveryProof(), finish={}) { + let recovered=false + return async(_url,options)=>{ + const {action,direction,scope}=requestedAction(options) + assert.equal(direction,'DELIVERY');assert.deepEqual(scope,deliveryScope);calls.push(action) + if(action==='recover')recovered=true + const data=action==='prepare'?{originalImageId,originalFingerprint}:action==='proxy-ready'?{ready:true}:action==='health'?{healthy:true} + :action==='status'?{producer:{publishStatus:recovered?'PUBLISHED':'DEAD',failedAttempts:12}}:action==='proof'?proof + :action==='finish'?{restored:true,healthy:true,proxyStopped:true,producerName:'firstleg-test-lastleg',originalImageId,currentImageId:originalImageId,originalFingerprint,currentFingerprint:originalFingerprint,...finish}:{} + return {ok:true,text:async()=> 'TEST_EVENT_JSON='+JSON.stringify({ok:true,data})} + } +} +test('DELIVERY has a separate strict scope and restores before accepting original event proof',async()=>{ + const calls=[],executor=createTestDeliveryFaultExecutor(environment,deliveryTransport(calls)) + await assert.rejects(executor.prepare({...deliveryScope,eventType:'OverseasContainerUnloaded'}),/Invalid/) + await executor.prepare(deliveryScope) + await assert.rejects(executor.prepare({...deliveryScope,deliveryId:'91'}),/scope drift/) + const proof=await executor.readProof(deliveryScope) + assert.equal(proof.restored,true);assert.equal(proof.restoration.currentImageId,originalImageId) + assert.deepEqual(calls,['prepare','proxy-ready','activate','health','status','recover','status','proof','restore','health','finish']) +}) +test('DELIVERY proof distinguishes root pending, child first and all completed with immutable original evidence',()=>{ + for(const mode of ['ROOT_PENDING','CHILD_FIRST','ALL_COMPLETE']) { + const p=deliveryProof(mode),scope={...deliveryScope,sourceOrderId:p.sourceOrderId,sourceInboundId:p.sourceInboundId} + assertTestDeliveryFaultProof(p,scope) + } + for(const mutate of [p=>p.producer.envelope.payload.deliveryId='90',p=>delete p.producer.envelope.payload.deliveryOrderId, + p=>p.producer.customerId='0',p=>p.producer.eventKey='wrong',p=>p.beforeRetry.entryId='124', + p=>p.consumer.count=2,p=>p.attempts.pop(),p=>p.proxyRecords[0].outcome='DUPLICATE', + p=>p.retryAuditCount=2,p=>p.afterRetry.business.inbound[0].version++,p=>p.afterRetry.business.order[0].version++, + p=>p.beforeRetry.business.inbox[0].status='RECEIVED',p=>p.beforeRetry.business.inboundHistory.push({...p.beforeRetry.business.inboundHistory[0]}), + p=>{p.beforeRetry.business.order[0].status=p.afterRetry.business.order[0].status='COMPLETED'}, + p=>p.beforeRetry.business.completionEvent.push({orderId:'100',status:'PUBLISHED'})]) { + const proof=deliveryProof();mutate(proof);assert.throws(()=>assertTestDeliveryFaultProof(proof,deliveryScope),/Invalid delivery/) + } +}) +test('DELIVERY evidence failure still restores and restoration drift is a blocking failure',async()=>{ + const p=deliveryProof();p.consumer.count=0 + const calls=[],executor=createTestDeliveryFaultExecutor(environment,deliveryTransport(calls,p)) + await executor.prepare(deliveryScope);await assert.rejects(executor.readProof(deliveryScope),/INBOX/) + assert.deepEqual(calls.slice(-3),['restore','health','finish']) + const broken=createTestDeliveryFaultExecutor(environment,deliveryTransport([],deliveryProof(),{proxyStopped:false})) + await broken.prepare(deliveryScope);await assert.rejects(broken.readProof(deliveryScope),/Delivery restoration/) +}) +test('F40 hook faults only first signature, captures whole inventory and always cleans failed signing',async()=>{ + const calls=[],restoration={restored:true,healthy:true,proxyStopped:true} + const inventory={scope:{...deliveryScope,warehouseId:'9',coverage:'CUSTOMER_WAREHOUSE_COMPLETE'},balances:[],positions:[],ledger:[]} + const parentProof={} + const context={context:{f40RootFulfillmentProof:parentProof,f40DeliveryEventFaultExecutor:{prepare:async s=>{assert.deepEqual(s,deliveryScope);calls.push('prepare')}, + readProof:async()=>{calls.push('proof');return {...deliveryProof(),restored:true,restoration}},cleanup:async()=>{calls.push('cleanup');return restoration}}, + f40ReadOnlyInventoryEvidence:async s=>{assert.equal(s.warehouseId,'9');calls.push('inventory');return structuredClone(inventory)}},createdResources:{phase3OrderSplitting:{}}} + assert.equal(await withDeliveryEventRecovery(context,deliveryScope,'9',0,async()=>{calls.push('sign');return 'signed'}),'signed') + assert.deepEqual(calls,['inventory','prepare','sign','proof','inventory','cleanup']) + assert.equal(parentProof.deliveryEventRecovery.status,'VERIFIED') + assert.equal(context.createdResources.phase5RootFulfillment,undefined) + await withDeliveryEventRecovery(context,deliveryScope,'9',1,async()=>calls.push('second')) + assert.equal(calls.at(-1),'second') + context.context.f40RootFulfillmentProof={} + await assert.rejects(withDeliveryEventRecovery(context,deliveryScope,'9',0,async()=>{throw Error('sign failed')}),/sign failed/) + assert.equal(calls.at(-1),'cleanup') + assert.equal(context.context.f40RootFulfillmentProof.deliveryEventRecovery.restored,true) +}) +test('Python DELIVERY SQL uses actual payload, original Lastleg retry, and Order database without inventory queries',()=>{ + const result=spawnSync('python',['-c',String.raw` +import importlib.util,sys,ast,pathlib,copy +from unittest.mock import patch,MagicMock,mock_open +sys.dont_write_bytecode=True +p=pathlib.Path('scripts/test-event-fault-runtime.py');ast.parse(p.read_text(encoding='utf-8')) +spec=importlib.util.spec_from_file_location('runtime',p);m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) +s={'marker':'TEST-DELIVERY','customerId':'7','deliveryId':'90','sourceInboundId':'200','sourceOrderId':'100','rootOrderId':'100'} +assert m.scope_directory(s,'DELIVERY')!=m.scope_directory({'marker':s['marker'],'customerId':'7','containerBookingId':'200'},'UNLOAD') +assert m.producer_name({'direction':'DELIVERY'})=='firstleg-test-lastleg' +assert m.target_setting({'direction':'DELIVERY'})==('FIRSTLEG_SERVICE_URL','http://firstleg:9202') +queries=[] +with patch.object(m,'inspect',side_effect=lambda name:{'Name':'/'+name}),patch.object(m,'require_test'),patch.object(m,'read_sql',side_effect=lambda container,sql:queries.append((container['Name'],sql)) or []): + assert m.producer(s,'DELIVERY') is None + m.delivery_facts(s,'00000000-0000-4000-8000-000000000001') +assert [q[0] for q in queries]==['/firstleg-test-lastleg','/firstleg-test-business','/firstleg-test-order'] +assert "$.payload.deliveryOrderId" in queries[0][1] and "customer_id='7'" in queries[0][1] +assert 'fl_inbound_status_history' in queries[1][1] and 'fl_container_event_inbox' in queries[1][1] +assert 'ord_order_status_history' in queries[2][1] and 'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED' in queries[2][1] +assert all('inventory' not in q[1] for q in queries) +original={'Name':'/firstleg-test-lastleg','Id':'original-container','Image':'sha256:'+'a'*64, + 'Config':{'Image':'lastleg:test','Env':['FIRSTLEG_SERVICE_URL=http://firstleg:9202'],'Labels':{'com.docker.compose.project':'firstleg-test'}}, + 'HostConfig':{},'Mounts':[],'NetworkSettings':{'Networks':{'firstleg-test-network':{'IPAddress':'172.20.0.3','Aliases':['lastleg-service']}}}, + 'State':{'Running':True,'Health':{'Status':'healthy'}}} +consumer=copy.deepcopy(original);consumer['Name']='/firstleg-test-business' +with patch.object(m,'producer',return_value=None),patch.object(m,'inspect',side_effect=lambda name:original if name=='firstleg-test-lastleg' else consumer),patch.object(m.Path,'mkdir'),patch.object(m.Path,'write_bytes'),patch('builtins.open',mock_open()),patch.object(m.socket,'socket') as sock,patch.object(m.subprocess,'Popen') as process,patch.object(m,'private_write'): + sock.return_value.getsockname.return_value=('127.0.0.1',18001) + m.prepare(s,pathlib.Path('/offline'),{'direction':'DELIVERY','proxySource':''}) + args=process.call_args.args[0] + assert args[args.index('--delivery-id')+1]=='90' and args[args.index('--event-type')+1]=='OverseasDeliveryCompleted' + assert '--container-booking-id' not in args and args[args.index('--upstream')+1].endswith(':9202/internal/firstleg/v1/container-events') +state={'direction':'DELIVERY','restored':False,'prepared':True,'image':'lastleg:test','originalId':'original-container', + 'originalImageId':original['Image'],'originalFingerprint':m.fingerprint(original),'originalBody':m.recreation(original,m.env_of(original),full=True),'proxyPort':18001} +restored=copy.deepcopy(original);restored['Id']='restored-new-id' +with patch.object(m,'control',side_effect=OSError('proxy already stopped')),patch.object(m,'inspect',return_value=restored),patch.object(m,'private_write'),patch.object(m,'replace') as replace,patch.object(m,'docker') as docker: + assert m.restore(pathlib.Path('/offline'),state)=={'restoreStarted':True} + assert state['prepared'] is False + replace.assert_not_called();docker.assert_not_called() +state['prepared']=True +restored['Config']['Env']=['FIRSTLEG_SERVICE_URL=http://firstleg:9202','UNEXPECTED=1'] +with patch.object(m,'control'),patch.object(m,'inspect',return_value=restored),patch.object(m,'replace') as replace: + try: m.restore(pathlib.Path('/offline'),state);assert False + except RuntimeError as error: assert str(error)=='CONCURRENT_PRODUCER_CHANGE' + replace.assert_not_called() +`],{encoding:'utf8',cwd:new URL('..',import.meta.url),timeout:10000}) + assert.equal(result.status,0,result.stderr||result.error?.message) +}) diff --git a/scripts/test-event-fault-runtime.py b/scripts/test-event-fault-runtime.py new file mode 100644 index 000000000..e7be87886 --- /dev/null +++ b/scripts/test-event-fault-runtime.py @@ -0,0 +1,607 @@ +"""Internal TEST executor. Receives one bounded task action from Jenkins. + +Never edits business SQL. Docker configuration is captured before the scoped +fault and restored to the same image, mounts, ports and network identity. +Only the original DEAD event can be retried through its protected HTTP API. +""" +import base64 +import copy +import decimal +import hashlib +import http.client +import json +import os +import re +import secrets +import signal +import socket +import subprocess +import sys +import tempfile +import time +import urllib.parse +import urllib.request +import uuid +from pathlib import Path + + +class DockerConnection(http.client.HTTPConnection): + def connect(self): + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.settimeout(self.timeout) + self.sock.connect('/var/run/docker.sock') + + +def docker(method, path, body=None): + conn = DockerConnection('localhost', timeout=20) + data = None if body is None else json.dumps(body).encode() + conn.request(method, path, data, {'Content-Type': 'application/json'}) + response = conn.getresponse() + raw = response.read() + conn.close() + if response.status >= 300: + raise RuntimeError('DOCKER_' + str(response.status)) + return json.loads(raw) if raw else None + + +def inspect(name): + return docker('GET', '/containers/' + urllib.parse.quote(name, safe='') + '/json') + + +def env_of(container): + return dict(item.split('=', 1) for item in container['Config']['Env']) + + +def require_test(container, name): + if container['Name'] != '/' + name or container['Config']['Labels'].get('com.docker.compose.project') != 'firstleg-test': + raise RuntimeError('NOT_TEST_CONTAINER') + + +def recreation(container, environment, full=False): + body = copy.deepcopy(container['Config']) + body['Env'] = [key + '=' + value for key, value in environment.items()] + body['HostConfig'] = copy.deepcopy(container['HostConfig']) + if body['HostConfig'].get('AutoRemove') or any(mount['Type'] not in ('bind', 'volume') for mount in container['Mounts']): + raise RuntimeError('UNSUPPORTED_CONTAINER_STORAGE') + # Preserve named volumes explicitly, never create a replacement anonymous volume. + if any(mount['Type'] == 'volume' and not mount.get('Name') for mount in container['Mounts']): + raise RuntimeError('UNNAMED_VOLUME') + if body['HostConfig'].get('VolumesFrom'): + raise RuntimeError('UNSUPPORTED_VOLUME_REFERENCE') + endpoints = {} + for name, network in container['NetworkSettings']['Networks'].items(): + aliases = [alias for alias in network.get('Aliases') or [] if alias != container['Id'][:12]] + endpoints[name] = {'Aliases': aliases, 'IPAMConfig': {'IPv4Address': network['IPAddress']}} + if full: + ipam = copy.deepcopy(network.get('IPAMConfig') or {}) + ipam['IPv4Address'] = network['IPAddress'] + if network.get('GlobalIPv6Address'): + ipam['IPv6Address'] = network['GlobalIPv6Address'] + endpoints[name]['IPAMConfig'] = ipam + for key in ('Links', 'DriverOpts', 'GwPriority', 'MacAddress'): + if network.get(key) is not None: + endpoints[name][key] = copy.deepcopy(network[key]) + body['NetworkingConfig'] = {'EndpointsConfig': endpoints} + return body + + +def replace(container, body): + name = container['Name'].lstrip('/') + current = inspect(name) + if current['Id'] != container['Id']: + raise RuntimeError('CONCURRENT_CONTAINER_CHANGE') + docker('POST', '/containers/' + current['Id'] + '/stop?t=10') + docker('DELETE', '/containers/' + current['Id'] + '?v=false') + created = docker('POST', '/containers/create?name=' + name, body) + docker('POST', '/containers/' + created['Id'] + '/start') + return created['Id'] + + +def private_write(path, value): + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, 'w') as file: + json.dump(value, file) + + +def scope_directory(scope, direction='LOADING'): + if direction not in ('LOADING', 'UNLOAD', 'DELIVERY', 'FEE_VOID'): + raise RuntimeError('INVALID_DIRECTION') + marker = scope.get('marker', '') + if not re.fullmatch(r'[A-Za-z0-9_-]{1,96}', marker): + raise RuntimeError('INVALID_SCOPE') + keys = ('customerId', 'sourceFeeId', 'sourceVersion', 'sourceType') if direction == 'FEE_VOID' else ('customerId', 'deliveryId', 'sourceInboundId', 'sourceOrderId', 'rootOrderId') if direction == 'DELIVERY' else ('customerId', 'containerBookingId') if direction == 'UNLOAD' else ('customerId', 'loadingPlanId', 'containerBookingId') + if direction != 'LOADING' and set(scope) != {'marker', *keys}: + raise RuntimeError('INVALID_SCOPE') + if direction == 'FEE_VOID' and scope.get('sourceType') not in FEE_SOURCE_TYPES: + raise RuntimeError('INVALID_FEE_SOURCE_TYPE') + if direction == 'FEE_VOID' and (not str(scope.get('sourceVersion', '')).isdigit() or not 0 < int(scope['sourceVersion']) <= 2147483647): + raise RuntimeError('INVALID_SOURCE_VERSION') + for key in keys: + if key == 'sourceType': + continue + if not re.fullmatch(r'[1-9][0-9]{0,19}', scope.get(key, '')): + raise RuntimeError('INVALID_SCOPE') + identity = direction + ':' + marker if direction != 'LOADING' else marker + return Path(tempfile.gettempdir()) / ('chongchong-event-' + hashlib.sha256(identity.encode()).hexdigest()[:24]) + + +FEE_SOURCE_TYPES = ('OVERSEAS_INBOUND_FEE', 'OVERSEAS_PROCUREMENT_FEE', 'OVERSEAS_TRUCK_FEE', 'OVERSEAS_LOOSEBOARD_FEE', 'OVERSEAS_WAREHOUSE_FEE', 'OVERSEAS_CONTAINER_FEE', 'OVERSEAS_ORDER_FEE', 'OVERSEAS_DELIVERY_FEE', 'OVERSEAS_RELABEL_FEE') + + +def fee_void(state): + return state.get('direction') == 'FEE_VOID' + + +def unload(state): + return state.get('direction', 'LOADING') == 'UNLOAD' + + +def delivery(state): + return state.get('direction') == 'DELIVERY' + + +def reverse_delivery(state): + return state.get('direction') in ('UNLOAD', 'DELIVERY', 'FEE_VOID') + + +def producer_name(state): + return 'firstleg-test-lastleg' if reverse_delivery(state) else 'firstleg-test-business' + + +def target_setting(state): + if fee_void(state): + return ('FINANCE_SERVICE_URL', 'http://finance:9203') + return ('FIRSTLEG_SERVICE_URL', 'http://firstleg:9202') if reverse_delivery(state) else ('LASTLEG_SERVICE_URL', 'http://lastleg-service:9303') + + +def fingerprint(container): + body = recreation(container, env_of(container), full=True) + # Docker may add the legacy Config.MacAddress after explicit per-network MAC + # restoration. It is redundant only when it equals an actual preserved MAC; + # every network MAC remains in the fingerprint and unrelated values still fail. + network_macs = {endpoint.get('MacAddress') for endpoint in body['NetworkingConfig']['EndpointsConfig'].values()} + if body.get('MacAddress') and body['MacAddress'] in network_macs: + body.pop('MacAddress') + body['Env'].sort() + for endpoint in body['NetworkingConfig']['EndpointsConfig'].values(): + endpoint['Aliases'].sort() + body['ResolvedMounts'] = sorted(container['Mounts'], key=lambda mount: mount['Destination']) + body['NetworkIds'] = {name: network.get('NetworkID') for name, network in container['NetworkSettings']['Networks'].items()} + return hashlib.sha256(json.dumps(body, sort_keys=True, separators=(',', ':')).encode()).hexdigest() + + +def control(state, mode=None): + url = 'http://127.0.0.1:' + str(state['controlPort']) + ('/mode' if mode else '/status') + request = urllib.request.Request(url, None if mode is None else json.dumps({'mode': mode}).encode(), + {'X-Event-Fault-Control-Token': state['controlToken'], 'Content-Type': 'application/json'}) + with urllib.request.urlopen(request, timeout=5) as response: + return json.load(response) + + +def read_sql(container, sql): + # SQL is generated here from fixed templates and decimal/UUID/hash identities. + if re.search(r'\b(INSERT|UPDATE|DELETE|ALTER|DROP|TRUNCATE|CALL|INTO|OUTFILE)\b', sql, re.I): + raise RuntimeError('NON_READ_ONLY_SQL') + e = env_of(container) + prefix = {'/firstleg-test-business': 'FIRSTLEG', '/firstleg-test-lastleg': 'LASTLEG', '/firstleg-test-order': 'ORDER', '/firstleg-test-finance': 'FINANCE'}[container['Name']] + url = urllib.parse.urlsplit(e[prefix + '_DB_URL'].removeprefix('jdbc:')) + host = '127.0.0.1' if url.hostname == 'host.docker.internal' else url.hostname + transaction = 'SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;\n' + sql + '\nCOMMIT;' + result = subprocess.run(['mysql', '--host=' + host, '--port=' + str(url.port or 3306), + '--user=' + e[prefix + '_DB_USERNAME'], '--batch', '--raw', '--skip-column-names', url.path.lstrip('/')], + input=transaction, text=True, capture_output=True, timeout=20, + env=dict(os.environ, MYSQL_PWD=e[prefix + '_DB_PASSWORD'])) + if result.returncode: + code = re.search(r'ERROR (\d+)', result.stderr) + raise RuntimeError('MYSQL_' + (code.group(1) if code else 'UNKNOWN')) + return [json.loads(line) for line in result.stdout.splitlines() if line.strip()] + + +def producer(scope, direction='LOADING'): + if direction == 'FEE_VOID': + return fee_event(scope, 'VOIDED') + if direction == 'DELIVERY': + rows = read_sql(inspect('firstleg-test-lastleg'), + "SELECT JSON_OBJECT('entryId',CAST(id AS CHAR),'eventId',JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.eventId')),'payloadHash',JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payloadHash')),'eventType',event_type,'customerId',customer_id,'aggregateId',aggregate_id,'aggregateType',aggregate_type,'eventKey',event_key,'envelope',JSON_EXTRACT(payload_json,'$'),'publishStatus',status,'failedAttempts',retry_count) FROM ll_event_outbox WHERE customer_id='" + scope['customerId'] + "' AND event_type='OverseasDeliveryCompleted' AND JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payload.deliveryOrderId'))='" + scope['deliveryId'] + "';") + elif direction == 'UNLOAD': + rows = read_sql(inspect('firstleg-test-lastleg'), + "SELECT JSON_OBJECT('entryId',CAST(id AS CHAR),'eventId',JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.eventId')),'payloadHash',JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payloadHash')),'eventType',event_type,'customerId',customer_id,'aggregateId',aggregate_id,'aggregateType',aggregate_type,'eventKey',event_key,'envelope',JSON_EXTRACT(payload_json,'$'),'publishStatus',status,'failedAttempts',retry_count) FROM ll_event_outbox WHERE customer_id='0' AND aggregate_id='" + + scope['containerBookingId'] + "' AND event_type='OverseasContainerUnloaded' AND event_key='OverseasContainerUnloaded:" + scope['containerBookingId'] + "';") + else: + rows = read_sql(inspect('firstleg-test-business'), + "SELECT JSON_OBJECT('entryId',CAST(id AS CHAR),'eventId',JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.eventId')),'payloadHash',payload_hash,'eventType',event_type,'publishStatus',publish_status,'failedAttempts',retry_count) FROM fl_inbound_outbox WHERE tenant_id=" + scope['customerId'] + + " AND aggregate_id=" + scope['loadingPlanId'] + " AND event_type='FirstLegLoadingPlanCompleted';") + if len(rows) > 1: + raise RuntimeError('AMBIGUOUS_EVENT') + return rows[0] if rows else None + + +def container_facts(scope): + rows = read_sql(inspect('firstleg-test-business'), + "SELECT JSON_OBJECT('id',CAST(id AS CHAR),'containerNo',container_no,'version',CAST(version AS CHAR),'unloadingCompletedAt',CAST(unloading_completed_at AS CHAR),'unloadingPhotoUploadedAt',CAST(unloading_photo_uploaded_at AS CHAR),'estimatedEmptyReportedAt',CAST(estimated_empty_reported_at AS CHAR),'actualEmptyReportedAt',CAST(actual_empty_reported_at AS CHAR),'emptyReportSourceVersion',CAST(empty_report_source_version AS CHAR),'updatedAt',CAST(updated_at AS CHAR),'updatedBy',CAST(updated_by AS CHAR),'updatedByName',updated_by_name) FROM fl_container_booking WHERE id=" + scope['containerBookingId'] + ";") + if len(rows) != 1: + raise RuntimeError('CONTAINER_SCOPE_MISMATCH') + return rows[0] + + +def business_ids(scope): + rows = read_sql(inspect('firstleg-test-lastleg'), "SELECT JSON_OBJECT('id',CAST(id AS CHAR)) FROM ll_order WHERE customer_id='" + + scope['customerId'] + "' AND source_container_booking_id='" + scope['containerBookingId'] + "' ORDER BY id;") + return [row['id'] for row in rows] + + +def fee_canonical(value): + """Match EventPayloadCanonicalizer's sorted JSON and stripped BigDecimal.toString.""" + if isinstance(value, dict): + return '{' + ','.join(json.dumps(key, ensure_ascii=False) + ':' + fee_canonical(value[key]) for key in sorted(value)) + '}' + if isinstance(value, list): + return '[' + ','.join(fee_canonical(item) for item in value) + ']' + if isinstance(value, decimal.Decimal): + with decimal.localcontext() as context: + context.prec = max(28, len(value.as_tuple().digits)) + number = value.normalize() + if not number: + return '0' + return format(number, 'f') if number.as_tuple().exponent <= 0 and number.adjusted() >= -6 else str(number) + return json.dumps(value, ensure_ascii=False, separators=(',', ':')) + + +def fee_event(scope, action): + if action not in ('PUBLISHED', 'ADJUSTED', 'VOIDED'): + raise RuntimeError('INVALID_FEE_ACTION') + event_key = 'BusinessFeeSourceChanged:LASTLEG:' + scope['sourceType'] + ':' + scope['sourceFeeId'] + ':' + scope['sourceVersion'] + ':' + action + rows = read_sql(inspect('firstleg-test-lastleg'), + "SELECT JSON_OBJECT('entryId',CAST(id AS CHAR),'eventId',JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.eventId')),'payloadHash',JSON_UNQUOTE(JSON_EXTRACT(payload_json,'$.payloadHash')),'eventType',event_type,'customerId',customer_id,'aggregateId',aggregate_id,'aggregateType',aggregate_type,'eventKey',event_key,'envelope',JSON_EXTRACT(payload_json,'$'),'envelopeJson',payload_json,'publishStatus',status,'failedAttempts',retry_count) FROM ll_event_outbox WHERE event_type='BusinessFeeSourceChanged' AND event_key='" + event_key + "';") + if len(rows) > 1: + raise RuntimeError('AMBIGUOUS_FEE_EVENT') + if not rows: + return None + row = rows[0] + payload = json.loads(row['envelopeJson'], parse_float=decimal.Decimal, parse_int=decimal.Decimal)['payload'] + row['payloadCanonicalJson'] = fee_canonical(payload) + # The shared NON_NULL ObjectMapper omits these unsigned DTO fields. + unsigned = {key: value for key, value in payload.items() if key not in ('action', 'sourceHash')} + unsigned['sourceStatus'] = 'ACTIVE' + row['sourceCanonicalJson'] = fee_canonical(unsigned) + if hashlib.sha256(row['payloadCanonicalJson'].encode()).hexdigest() != row['payloadHash'].removeprefix('sha256:'): + raise RuntimeError('FEE_PAYLOAD_HASH_MISMATCH') + if payload.get('sourceHash') != 'sha256:' + hashlib.sha256(row['sourceCanonicalJson'].encode()).hexdigest(): + raise RuntimeError('FEE_SOURCE_HASH_MISMATCH') + if row['customerId'] != scope['customerId'] or row['aggregateId'] != scope['sourceFeeId'] or row['aggregateType'] != scope['sourceType']: + raise RuntimeError('FEE_EVENT_SCOPE_MISMATCH') + if payload.get('sourceSystem') != 'LASTLEG' or payload.get('customerId') != scope['customerId'] or payload.get('sourceFeeId') != scope['sourceFeeId'] or str(payload.get('sourceVersion')) != scope['sourceVersion'] or payload.get('sourceType') != scope['sourceType'] or payload.get('action') != action: + raise RuntimeError('FEE_PAYLOAD_SCOPE_MISMATCH') + return row + + +def fee_inbox(scope, event_id): + if not re.fullmatch(r'[0-9a-fA-F-]{36}|[0-9A-HJKMNP-TV-Z]{26}', event_id): + raise RuntimeError('INVALID_EVENT_IDENTITY') + return read_sql(inspect('firstleg-test-finance'), + "SELECT JSON_OBJECT('id',CAST(id AS CHAR),'eventId',event_id,'sourceSystem',source_system,'eventType',event_type,'envelope',JSON_EXTRACT(payload,'$'),'payloadHash',payload_hash,'resultResourceId',CAST(result_resource_id AS CHAR),'status',status,'errorCode',error_code,'receivedAt',CAST(received_at AS CHAR),'processedAt',CAST(processed_at AS CHAR)) FROM fin_inbox_event WHERE source_system='LASTLEG' AND event_id='" + event_id + "';") + + +def fee_facts(scope, event_id): + lastleg, finance = inspect('firstleg-test-lastleg'), inspect('firstleg-test-finance') + require_test(lastleg, 'firstleg-test-lastleg') + require_test(finance, 'firstleg-test-finance') + # Scalar decimals remain strings. Original event decimals are retained in envelopeJson. + def columns(names): + return ','.join("'" + name + "',CAST(" + re.sub(r'([A-Z])', lambda match: '_' + match[1].lower(), name) + " AS CHAR)" for name in names.split()) + local_names = 'id sourceFeeId sourceVersion customerId customerName sourceType sourceNo businessType businessId businessNo orderId inboundId inboundCode containerId containerBookingId containerNo warehouseId feeItemCode feeItemName direction supplierId supplierName supplierCategory settlementMode paidAt paymentMethod paymentReferenceNo businessDate currency amount originalCurrency originalAmount exchangeRate exchangeRateMonth chargeableQuantity quantityUnit unitPrice periodStart periodEnd reviewStatus publicationStatus sourceStatus reviewReason reviewerId reviewedAt createdBy updatedBy createdAt updatedAt' + local = read_sql(lastleg, "SELECT JSON_OBJECT(" + columns(local_names) + ",'paymentVoucherFileIds',payment_voucher_file_ids) FROM ll_finance_fee_source WHERE source_fee_id=" + scope['sourceFeeId'] + " ORDER BY source_version,id;") + where = "source_system='LASTLEG' AND source_type='" + scope['sourceType'] + "' AND source_fee_id=" + scope['sourceFeeId'] + finance_names = 'id sourceSystem sourceFeeId sourceFeeVersion customerId sourceType sourceNo businessType businessId businessNo inboundCode warehouseId direction orderId inboundId containerId containerBookingId containerNo feeItemCode feeItemName amount currencyCode settlementMode paidAt paymentMethod paymentReferenceNo sourceHash reviewStatus reviewReason operationVersion walletHoldId status createdAt updatedAt' + ids = 'SELECT id FROM fin_fee_source WHERE ' + where + rows = read_sql(finance, + "SELECT JSON_OBJECT('kind','source'," + columns(finance_names) + ",'sourceSnapshot',source_snapshot,'paymentVoucherFileIds',payment_voucher_file_ids) FROM fin_fee_source WHERE " + where + " ORDER BY source_fee_version,id;\n" + + "SELECT JSON_OBJECT('kind','bill'," + columns('id tenantId billId sourceId sourceVersion amount currencyCode createdAt') + ") FROM fin_bill_line WHERE source_id IN (" + ids + ") ORDER BY id;\n" + + "SELECT JSON_OBJECT('kind','settlement'," + columns('id tenantId settlementId feeSourceId amount currencyCode createdAt') + ") FROM fin_settlement_line WHERE fee_source_id IN (" + ids + ") ORDER BY id;\n" + + "SELECT JSON_OBJECT('kind','cost'," + columns('id tenantId sourceSystem sourceType sourceFeeId sourceFeeVersion sourceHash status version costAmount createdAt voidedAt') + ") FROM fin_cost WHERE " + where + " ORDER BY id;") + return {'lastleg': local, 'finance': {**{kind: [row for row in rows if row['kind'] == kind] for kind in ('source', 'bill', 'settlement', 'cost')}, 'inbox': fee_inbox(scope, event_id)}} + + +def fee_prerequisite(scope, wait=False): + if fee_event(scope, 'VOIDED') is not None: + raise RuntimeError('FEE_VOID_EVENT_ALREADY_EXISTS') + publication = fee_event(scope, 'PUBLISHED' if scope['sourceVersion'] == '1' else 'ADJUSTED') + if not publication or publication['publishStatus'] not in ('PENDING', 'PROCESSING', 'PUBLISHED'): + raise RuntimeError('FEE_PUBLICATION_NOT_READY') + facts = fee_facts(scope, publication['eventId']) + current = [row for row in facts['lastleg'] if row['sourceVersion'] == scope['sourceVersion']] + financial = [row for row in facts['finance']['source'] if row['sourceFeeVersion'] == scope['sourceVersion']] + if len(current) != 1 or len(financial) != 1 or any(row['customerId'] != scope['customerId'] or row['sourceType'] != scope['sourceType'] for row in facts['lastleg'] + facts['finance']['source']): + raise RuntimeError('FEE_SOURCE_SCOPE_MISMATCH') + if max(int(row['sourceVersion']) for row in facts['lastleg']) != int(scope['sourceVersion']) or current[0]['sourceStatus'] != 'ACTIVE' or current[0]['reviewStatus'] != 'APPROVED' or current[0]['publicationStatus'] != 'PUBLISHED' or current[0]['direction'] != 'RECEIVABLE': + raise RuntimeError('FEE_SOURCE_NOT_ACTIVE_RECEIVABLE') + if financial[0]['status'] != 'AVAILABLE' or financial[0]['walletHoldId'] is not None or any(facts['finance'][key] for key in ('bill', 'settlement', 'cost')): + raise RuntimeError('FEE_SOURCE_ALREADY_FINANCIALLY_LINKED') + inbox = facts['finance']['inbox'] + if len(inbox) != 1 or inbox[0]['status'] != 'PROCESSED' or inbox[0]['payloadHash'] != publication['payloadHash'] or inbox[0]['resultResourceId'] != financial[0]['id']: + raise RuntimeError('FEE_PUBLICATION_INBOX_MISMATCH') + attempts = read_sql(inspect('firstleg-test-lastleg'), "SELECT JSON_OBJECT('entryId',entry_id,'eventId',event_id,'outcome',outcome) FROM event_delivery_attempt WHERE entry_id='" + publication['entryId'] + "' AND event_type='BusinessFeeSourceChanged' ORDER BY started_at,attempt_id;") + ready = publication['publishStatus'] == 'PUBLISHED' and any(row['eventId'] == publication['eventId'] and row['outcome'] in ('APPLIED', 'DUPLICATE') for row in attempts) + if not ready and not wait: + raise RuntimeError('FEE_PUBLICATION_ACK_NOT_READY') + return {'ready': ready, 'publication': publication, 'business': facts, 'attempts': attempts} + + +def business_snapshot(scope, state, row): + if fee_void(state): + return {'fee': fee_facts(scope, row['eventId'])} + if delivery(state): + return {'business': delivery_facts(scope, row['eventId'])} + return {'container': container_facts(scope)} if unload(state) else {'businessOrderIds': business_ids(scope)} + + +def delivery_facts(scope, event_id): + if not re.fullmatch(r'[0-9a-fA-F-]{36}|[0-9A-HJKMNP-TV-Z]{26}', event_id): + raise RuntimeError('INVALID_EVENT_IDENTITY') + customer, source, root = scope['customerId'], scope['sourceOrderId'], scope['rootOrderId'] + firstleg = inspect('firstleg-test-business') + order = inspect('firstleg-test-order') + require_test(firstleg, 'firstleg-test-business') + require_test(order, 'firstleg-test-order') + # Each service is read in its own consistent read-only transaction; no cross-schema join. + inbound_sql = "tenant_id=" + customer + " AND deleted=0 AND order_id IN (" + source + "," + root + ")" + first_rows = read_sql(firstleg, + "SELECT JSON_OBJECT('kind','inbound','id',CAST(id AS CHAR),'customerId',CAST(tenant_id AS CHAR),'orderId',CAST(order_id AS CHAR),'orderNo',order_no,'inboundCode',inbound_code,'processStatus',process_status,'orderStatusSnapshot',order_status_snapshot,'version',version,'updatedAt',CAST(updated_at AS CHAR)) FROM fl_inbound_order WHERE " + inbound_sql + " ORDER BY id;\n" + + "SELECT JSON_OBJECT('kind','inboundHistory','id',CAST(id AS CHAR),'inboundId',CAST(inbound_id AS CHAR),'action',action,'fromStatus',from_status,'toStatus',to_status,'occurredAt',CAST(occurred_at AS CHAR)) FROM fl_inbound_status_history WHERE tenant_id=" + customer + " AND inbound_id=" + scope['sourceInboundId'] + " ORDER BY id;\n" + + "SELECT JSON_OBJECT('kind','inbox','eventId',event_id,'eventType',event_type,'aggregateId',aggregate_id,'aggregateVersion',aggregate_version,'payloadHash',payload_hash,'payload',JSON_EXTRACT(payload_json,'$'),'status',status,'receivedAt',CAST(received_at AS CHAR),'processedAt',CAST(processed_at AS CHAR)) FROM fl_container_event_inbox WHERE event_id='" + event_id + "';") + tree = "tenant_id=" + customer + " AND deleted=0 AND (id=" + root + " OR root_order_id=" + root + ")" + ids = "SELECT id FROM ord_order WHERE " + tree + order_rows = read_sql(order, + "SELECT JSON_OBJECT('kind','order','id',CAST(id AS CHAR),'customerId',CAST(tenant_id AS CHAR),'rootOrderId',CAST(root_order_id AS CHAR),'orderRole',order_role,'orderNo',order_no,'inboundCode',inbound_code,'status',status,'version',version,'completedAt',CAST(completed_at AS CHAR),'updatedAt',CAST(updated_at AS CHAR)) FROM ord_order WHERE " + tree + " ORDER BY id;\n" + + "SELECT JSON_OBJECT('kind','orderHistory','id',CAST(id AS CHAR),'orderId',CAST(order_id AS CHAR),'action',action,'fromStatus',from_status,'toStatus',to_status,'occurredAt',CAST(occurred_at AS CHAR)) FROM ord_order_status_history WHERE tenant_id=" + customer + " AND order_id IN (" + ids + ") ORDER BY id;\n" + + "SELECT JSON_OBJECT('kind','completionEvent','id',CAST(id AS CHAR),'orderId',CAST(aggregate_id AS CHAR),'eventType',event_type,'status',publish_status,'payload',JSON_EXTRACT(payload_json,'$')) FROM ord_outbox_event WHERE tenant_id=" + customer + " AND aggregate_id IN (" + ids + ") AND event_type='FIRST_LEG_ORDER_FULFILLMENT_COMPLETED' ORDER BY id;") + return {kind: [row for row in first_rows + order_rows if row['kind'] == kind] for kind in + ('inbound', 'inboundHistory', 'inbox', 'order', 'orderHistory', 'completionEvent')} + + +def prepare(scope, directory, config): + if delivery(config) and producer(scope, 'DELIVERY') is not None: + raise RuntimeError('DELIVERY_EVENT_ALREADY_EXISTS') + prerequisite = fee_prerequisite(scope) if fee_void(config) else None + directory.mkdir(mode=0o700) + original = inspect(producer_name(config)) + require_test(original, producer_name(config)) + consumer_name = 'firstleg-test-finance' if fee_void(config) else 'firstleg-test-business' if reverse_delivery(config) else 'firstleg-test-lastleg' + consumer = inspect(consumer_name) + require_test(consumer, consumer_name) + old_env = env_of(original) + setting, direct = target_setting(config) + if old_env.get(setting) != direct: + raise RuntimeError('PRODUCER_NOT_DIRECT') + original_body = recreation(original, old_env, full=reverse_delivery(config)) + token = secrets.token_urlsafe(32) + # Reserve task-specific ports and let the existing proxy select exactly this plan. + sockets = [socket.socket() for _ in range(2)] + for sock in sockets: + sock.bind(('127.0.0.1', 0)) + ports = [sock.getsockname()[1] for sock in sockets] + for sock in sockets: + sock.close() + proxy = directory / 'proxy.py' + proxy.write_bytes(base64.b64decode(config['proxySource'])) + ip = consumer['NetworkSettings']['Networks']['firstleg-test-network']['IPAddress'] + log = open(directory / 'proxy-output.log', 'wb') + upstream = ':9203/internal/finance/v1/business-fee-source-events' if fee_void(config) else ':9202/internal/firstleg/v1/container-events' if reverse_delivery(config) else ':9303/internal/lastleg/events' + selector = [] if (delivery(config) or fee_void(config)) else ['--event-type', 'OverseasContainerUnloaded', '--container-booking-id', scope['containerBookingId']] if unload(config) else ['--event-type', 'FirstLegLoadingPlanCompleted', '--loading-plan-id', scope['loadingPlanId']] + if delivery(config): + selector = ['--event-type', 'OverseasDeliveryCompleted', '--delivery-id', scope['deliveryId']] + if fee_void(config): + selector = ['--event-type', 'BusinessFeeSourceChanged', '--fee-source-id', scope['sourceFeeId'], '--source-version', scope['sourceVersion']] + process = subprocess.Popen(['python3', str(proxy), '--upstream', 'http://' + ip + upstream, *selector, + '--bind', '0.0.0.0', '--port', str(ports[0]), '--control-port', str(ports[1]), + '--log', str(directory / 'proxy-evidence.jsonl')], + env=dict(os.environ, EVENT_FAULT_CONTROL_TOKEN=token), stdout=log, stderr=log, start_new_session=True) + state = {'scope': scope, 'originalBody': original_body, 'originalId': original['Id'], 'image': original['Config']['Image'], + 'direction': config.get('direction', 'LOADING'), 'originalImageId': original['Image'], 'originalFingerprint': fingerprint(original), + 'proxyPid': process.pid, 'proxyPort': ports[0], 'controlPort': ports[1], 'controlToken': token, + 'prerequisite': prerequisite, 'operationsRequestId': str(uuid.uuid4()), 'prepared': False, 'restored': False} + private_write(directory / 'state.json', state) + return {'created': True, 'originalImageId': state['originalImageId'], 'originalFingerprint': state['originalFingerprint'], **({'prerequisite': prerequisite} if fee_void(config) else {})} + + +def activate(scope, directory, state): + snapshot = control(state, 'FAULT') + if snapshot.get('eventId'): + raise RuntimeError('EVENT_ALREADY_PINNED') + original = inspect(producer_name(state)) + require_test(original, producer_name(state)) + setting, direct = target_setting(state) + if original['Id'] != state['originalId'] or original['Config']['Image'] != state['image'] or env_of(original).get(setting) != direct: + raise RuntimeError('CONCURRENT_PRODUCER_CHANGE') + if reverse_delivery(state) and (original['Image'] != state['originalImageId'] or fingerprint(original) != state['originalFingerprint']): + raise RuntimeError('CONCURRENT_PRODUCER_CHANGE') + body = copy.deepcopy(state['originalBody']) + e = env_of(original) + e[setting] = 'http://host.docker.internal:' + str(state['proxyPort']) + e.update(EVENT_MAX_ATTEMPTS='12', EVENT_BASE_DELAY_SECONDS='5', EVENT_MAX_DELAY_SECONDS='5', EVENT_JITTER='0') + if len(e.get('EVENT_OPERATIONS_TOKEN', '')) < 32: + e['EVENT_OPERATIONS_TOKEN'] = secrets.token_urlsafe(32) + body['Env'] = [key + '=' + value for key, value in e.items()] + state['prepared'] = True + private_write(directory / 'state.json', state) + replace(original, body) + return {'activated': True} + + +def recover(scope, directory, state): + row = producer(scope, state.get('direction', 'LOADING')) + if not row or row['publishStatus'] != 'DEAD' or row['failedAttempts'] != 12: + raise RuntimeError('ORIGINAL_EVENT_NOT_DEAD') + pinned = control(state) + if pinned['eventId'] != row['eventId'] or pinned['payloadHash'] != row['payloadHash']: + raise RuntimeError('PINNED_EVENT_MISMATCH') + state['beforeRetry'] = {**row, **business_snapshot(scope, state, row)} + if fee_void(state): + facts = state['beforeRetry']['fee'] + local = [value for value in facts['lastleg'] if value['sourceVersion'] == scope['sourceVersion']] + financial = [value for value in facts['finance']['source'] if value['sourceFeeVersion'] == scope['sourceVersion']] + if len(local) != 1 or local[0]['sourceStatus'] != 'ACTIVE' or len(financial) != 1 or financial[0]['status'] != 'VOIDED': + raise RuntimeError('FEE_VOID_DEAD_PHASE_MISMATCH') + private_write(directory / 'state.json', state) + control(state, 'PASS') + container = inspect(producer_name(state)) + e = env_of(container) + ip = container['NetworkSettings']['Networks']['firstleg-test-network']['IPAddress'] + body = {'requestId': state['operationsRequestId'], 'operator': 'admin-full-test', + 'reason': scope['marker'] + ': scoped ACK-loss recovery', 'expectedPayloadHash': row['payloadHash']} + endpoint = ':9303/internal/lastleg/event-operations/' if reverse_delivery(state) else ':9202/internal/firstleg/event-operations/' + url = 'http://' + ip + endpoint + row['entryId'] + '/retry' + request = urllib.request.Request(url, json.dumps(body).encode(), + {'X-Event-Operations-Token': e['EVENT_OPERATIONS_TOKEN'], 'Content-Type': 'application/json'}) + with urllib.request.urlopen(request, timeout=15) as response: + result = json.load(response) + if result.get('code') != '200': + raise RuntimeError('OPERATIONS_RETRY_REJECTED') + return {'retrySubmitted': True} + + +def restore(directory, state): + if state['restored']: + return {'restored': True} + try: + control(state, 'PASS') + except Exception: + if not reverse_delivery(state): + raise + # A dead proxy must not prevent restoring the producer's direct target. + if state['prepared']: + try: + current = inspect(producer_name(state)) + except RuntimeError as error: + if str(error) != 'DOCKER_404': + raise + created = docker('POST', '/containers/create?name=' + producer_name(state), state['originalBody']) + docker('POST', '/containers/' + created['Id'] + '/start') + state['prepared'] = False + private_write(directory / 'state.json', state) + return {'restoreStarted': True} + if current['Id'] == state['originalId'] or (reverse_delivery(state) and current['Image'] == state['originalImageId'] and fingerprint(current) == state['originalFingerprint']): + if reverse_delivery(state) and not current['State'].get('Running'): + docker('POST', '/containers/' + current['Id'] + '/start') + state['prepared'] = False + private_write(directory / 'state.json', state) + return {'restoreStarted': True} + if current['Config']['Image'] != state['image'] or env_of(current).get(target_setting(state)[0]) != 'http://host.docker.internal:' + str(state['proxyPort']): + raise RuntimeError('CONCURRENT_PRODUCER_CHANGE') + replace(current, state['originalBody']) + state['prepared'] = False + private_write(directory / 'state.json', state) + return {'restoreStarted': True} + + +def proof(scope, state): + row = producer(scope, state.get('direction', 'LOADING')) + if not row or row['publishStatus'] != 'PUBLISHED' or not state.get('beforeRetry'): + raise RuntimeError('RECOVERY_NOT_PUBLISHED') + event_id, entry_id = row['eventId'], row['entryId'] + if not re.fullmatch(r'[0-9a-fA-F-]{36}|[0-9A-HJKMNP-TV-Z]{26}', event_id) or not re.fullmatch(r'[1-9][0-9]{0,19}', entry_id): + raise RuntimeError('INVALID_EVENT_IDENTITY') + event_type = 'BusinessFeeSourceChanged' if fee_void(state) else 'OverseasDeliveryCompleted' if delivery(state) else 'OverseasContainerUnloaded' if unload(state) else 'FirstLegLoadingPlanCompleted' + attempts = read_sql(inspect(producer_name(state)), + "SELECT JSON_OBJECT('attemptId',attempt_id,'serviceName',service_name,'entryId',entry_id,'eventId',event_id,'targetName',target_name,'outcome',outcome) FROM event_delivery_attempt WHERE entry_id='" + + entry_id + "' AND event_type='" + event_type + "' ORDER BY started_at,attempt_id;") + audit = read_sql(inspect(producer_name(state)), + "SELECT JSON_OBJECT('requestId',request_id,'entryId',entry_id,'eventId',event_id,'payloadHash',payload_hash,'previousFailures',previous_failures) FROM event_delivery_retry_audit WHERE request_id='" + + state['operationsRequestId'] + "';" if not reverse_delivery(state) else + "SELECT JSON_OBJECT('requestId',request_id,'entryId',entry_id,'eventId',event_id,'payloadHash',payload_hash,'previousFailures',previous_failures) FROM event_delivery_retry_audit WHERE entry_id='" + entry_id + "' AND event_type='" + event_type + "';") + if fee_void(state): + consumers = fee_inbox(scope, row['eventId']) + elif reverse_delivery(state): + consumers = read_sql(inspect('firstleg-test-business'), + "SELECT JSON_OBJECT('eventId',event_id,'payloadHash',payload_hash,'status',status,'eventType',event_type,'aggregateId',aggregate_id,'payload',JSON_EXTRACT(payload_json,'$')) FROM fl_container_event_inbox WHERE event_id='" + event_id + "';") + else: + consumers = read_sql(inspect('firstleg-test-lastleg'), + "SELECT JSON_OBJECT('eventId',event_id,'payloadHash',payload_hash,'status',status) FROM ll_loading_event_inbox WHERE event_id='" + + event_id + "' AND customer_id='" + scope['customerId'] + "';") + consumer = {**(consumers[0] if len(consumers) == 1 else {}), 'count': len(consumers)} + result = {**scope, 'readOnly': True, 'producer': row, 'consumer': consumer, 'attempts': attempts, + 'proxyRecords': control(state)['records'], 'beforeRetry': state['beforeRetry'], + 'afterRetry': {**row, **business_snapshot(scope, state, row)}, + 'retryAudit': audit[0] if len(audit) == 1 else None, 'retryAuditCount': len(audit), 'retryRequestId': state['operationsRequestId'], 'restored': False} + if fee_void(state): + result['prerequisite'] = state['prerequisite'] + result['publicationNow'] = fee_event(scope, 'PUBLISHED' if scope['sourceVersion'] == '1' else 'ADJUSTED') + private_write(scope_directory(scope, state.get('direction', 'LOADING')) / 'proof.json', result) + return result + + +def finish(directory, state): + if state['restored']: + return state.get('restoration', {'restored': True}) + current = inspect(producer_name(state)) + require_test(current, producer_name(state)) + if current['State'].get('Health', {}).get('Status') != 'healthy' or current['Config']['Image'] != state['image']: + raise RuntimeError('RESTORED_SERVICE_NOT_HEALTHY') + if sorted(current['Config']['Env']) != sorted(state['originalBody']['Env']): + raise RuntimeError('RESTORED_CONFIGURATION_MISMATCH') + if reverse_delivery(state) and (current['Image'] != state['originalImageId'] or fingerprint(current) != state['originalFingerprint']): + raise RuntimeError('RESTORED_CONFIGURATION_MISMATCH') + cmdline = Path('/proc/' + str(state['proxyPid']) + '/cmdline') + if cmdline.exists(): + if str(directory / 'proxy.py').encode() not in cmdline.read_bytes().split(b'\x00'): + raise RuntimeError('PROXY_PROCESS_OWNERSHIP_MISMATCH') + os.kill(state['proxyPid'], signal.SIGTERM) + if reverse_delivery(state): + for _ in range(50): + if not cmdline.exists() or not cmdline.read_bytes(): + break + time.sleep(0.1) + else: + raise RuntimeError('PROXY_NOT_STOPPED') + result = {'restored': True} + if reverse_delivery(state): + result.update(healthy=True, proxyStopped=True, producerName=producer_name(state), + originalImageId=state['originalImageId'], currentImageId=current['Image'], + originalFingerprint=state['originalFingerprint'], currentFingerprint=fingerprint(current)) + state['restoration'] = result + state['restored'] = True + # Proof is already captured. Remove credentials from the retained task metadata. + state.pop('originalBody', None) + state.pop('controlToken', None) + private_write(directory / 'state.json', state) + return result + + +def main(config): + scope, action = config['scope'], config['action'] + direction = config.get('direction', 'LOADING') + directory = scope_directory(scope, direction) + if action == 'preflight' and direction == 'FEE_VOID': + return fee_prerequisite(scope, wait=True) + if action == 'prepare': + return prepare(scope, directory, config) + state = json.loads((directory / 'state.json').read_text()) + if state['scope'] != scope or state.get('direction', 'LOADING') != direction: + raise RuntimeError('TASK_SCOPE_MISMATCH') + if action == 'activate': + return activate(scope, directory, state) + if action == 'health': + current = inspect(producer_name(state)) + return {'healthy': current['State'].get('Health', {}).get('Status') == 'healthy'} + if action == 'proxy-ready': + return {'ready': control(state)['mode'] == 'PASS'} + if action == 'status': + return {'producer': producer(scope, direction), 'proxy': control(state)} + if action == 'recover': + return recover(scope, directory, state) + if action == 'proof': + return proof(scope, state) + if action == 'restore': + return restore(directory, state) + if action == 'finish': + return finish(directory, state) + raise RuntimeError('INVALID_ACTION') + + +if __name__ == '__main__': + try: + value = main(json.loads(base64.b64decode(sys.argv[1]))) + print('TEST_EVENT_JSON=' + json.dumps({'ok': True, 'data': value})) + except Exception as error: + message = str(error) + safe = message if re.fullmatch(r'[A-Z_0-9]{1,80}', message) else 'TEST_EVENT_ACTION_FAILED' + print('TEST_EVENT_JSON=' + json.dumps({'ok': False, 'error': safe})) diff --git a/scripts/test-fee-void-fault-proof.mjs b/scripts/test-fee-void-fault-proof.mjs new file mode 100644 index 000000000..78c96a4bc --- /dev/null +++ b/scripts/test-fee-void-fault-proof.mjs @@ -0,0 +1,92 @@ +import { createHash } from 'node:crypto' +import { isDeepStrictEqual as equal } from 'node:util' + +export const feeVoidScopeKeys=['marker','customerId','sourceFeeId','sourceVersion','sourceType'] +export const feeSourceTypes=['OVERSEAS_INBOUND_FEE','OVERSEAS_PROCUREMENT_FEE','OVERSEAS_TRUCK_FEE','OVERSEAS_LOOSEBOARD_FEE', + 'OVERSEAS_WAREHOUSE_FEE','OVERSEAS_CONTAINER_FEE','OVERSEAS_ORDER_FEE','OVERSEAS_DELIVERY_FEE','OVERSEAS_RELABEL_FEE'] +const hash=value=>'sha256:'+createHash('sha256').update(value,'utf8').digest('hex') +const without=(row,keys)=>Object.fromEntries(Object.entries(row).filter(([key])=>!keys.includes(key))) +const date=value=>Date.parse(String(value).replace(' ','T').replace(/Z$/,'')+'Z') + +export function assertTestFeeVoidFaultProof(proof,scope) { + const need=(passed,code)=>{if(!passed)throw Error('Invalid fee void fault proof: '+code)} + need(proof?.readOnly===true && feeVoidScopeKeys.every(key=>typeof scope[key]==='string'&&proof[key]===scope[key]) + && Object.keys(scope).length===feeVoidScopeKeys.length && feeSourceTypes.includes(scope.sourceType) + && /^[A-Za-z0-9_-]{1,96}$/.test(scope.marker) && ['customerId','sourceFeeId','sourceVersion'].every(key=>/^[1-9][0-9]{0,19}$/.test(scope[key])) + && Number(scope.sourceVersion)<=2147483647, 'SCOPE') + const event=(row,action)=>{ + need(row && /^[1-9][0-9]{0,19}$/.test(row.entryId) && /^[0-9a-fA-F-]{36}$/.test(row.eventId), 'EVENT_IDENTITY') + const env=row.envelope,p=env?.payload + need(row.eventType==='BusinessFeeSourceChanged' && row.customerId===scope.customerId && row.aggregateId===scope.sourceFeeId + && row.aggregateType===scope.sourceType && row.eventKey===`BusinessFeeSourceChanged:LASTLEG:${scope.sourceType}:${scope.sourceFeeId}:${scope.sourceVersion}:${action}` + && env?.eventId===row.eventId && env.eventType===row.eventType && env.tenantId===scope.customerId && Number(env.eventVersion)===1 + && env.aggregateType===scope.sourceType && env.aggregateId===scope.sourceFeeId && String(env.aggregateVersion)===scope.sourceVersion + && p?.action===action && p.sourceSystem==='LASTLEG' && p.customerId===scope.customerId && p.sourceFeeId===scope.sourceFeeId + && String(p.sourceVersion)===scope.sourceVersion && p.sourceType===scope.sourceType && p.direction==='RECEIVABLE' + && p.currency==='CNY' && p.reviewStatus==='APPROVED' && p.publicationStatus==='PUBLISHED' + && p.sourceStatus===(action==='VOIDED'?'VOIDED':'ACTIVE'), 'EVENT_SCOPE') + // Runtime preserves exact decimal lexemes and applies Java BigDecimal canonicalization. + let parsed,canonical,unsigned + try {parsed=JSON.parse(row.envelopeJson);canonical=JSON.parse(row.payloadCanonicalJson);unsigned=JSON.parse(row.sourceCanonicalJson)} + catch {need(false,'CANONICAL_JSON')} + need(equal(parsed,env) && equal(canonical,p) + && hash(row.payloadCanonicalJson)===row.payloadHash && env.payloadHash===row.payloadHash, 'PAYLOAD_HASH') + need(equal(unsigned,{...without(p,['action','sourceHash']),sourceStatus:'ACTIVE'}) + && hash(row.sourceCanonicalJson)===p.sourceHash, 'SOURCE_HASH') + return p + } + const row=proof.producer,payload=event(row,'VOIDED'),prior=proof.prerequisite,publication=prior?.publication + const original=event(publication,scope.sourceVersion==='1'?'PUBLISHED':'ADJUSTED') + need(prior.ready===true && publication.publishStatus==='PUBLISHED' && equal(publication,proof.publicationNow) + && publication.eventId!==row.eventId && publication.entryId!==row.entryId + && publication.sourceCanonicalJson===row.sourceCanonicalJson && original.sourceHash===payload.sourceHash + && prior.attempts?.some(a=>a.entryId===publication.entryId&&a.eventId===publication.eventId&&['APPLIED','DUPLICATE'].includes(a.outcome)), 'PUBLICATION_PREREQUISITE') + const before=proof.beforeRetry,after=proof.afterRetry + need(before?.publishStatus==='DEAD' && before.failedAttempts===12 && after?.publishStatus==='PUBLISHED' && row.publishStatus==='PUBLISHED' + && [before,after].every(value=>value.entryId===row.entryId && value.eventId===row.eventId && value.payloadHash===row.payloadHash + && equal(value.envelope,row.envelope) && value.envelopeJson===row.envelopeJson + && value.payloadCanonicalJson===row.payloadCanonicalJson && value.sourceCanonicalJson===row.sourceCanonicalJson), 'ORIGINAL_RETRY') + const attempts=proof.attempts,records=proof.proxyRecords?.filter(value=>value.matched===true) + need(attempts?.length===13 && new Set(attempts.map(a=>a.attemptId)).size===13 && attempts.every((a,i)=>a.entryId===row.entryId + && a.eventId===row.eventId && a.serviceName==='lastleg' && a.outcome===(i<11?'RETRY':i===11?'DEAD':'DUPLICATE')), 'ATTEMPTS') + need(records?.length===13 && records.every((a,i)=>a.eventId===row.eventId && a.payloadHash===row.payloadHash && a.attempt===i+1 + && a.outcome===(i===0?'APPLIED':'DUPLICATE') && (i<12?a.errorCode==='ACK_LOSS_INJECTED':a.errorCode===null)), 'REAL_ACK_LOSS') + const audit=proof.retryAudit + need(proof.retryAuditCount===1 && audit?.requestId===proof.retryRequestId && audit.entryId===row.entryId && audit.eventId===row.eventId + && audit.payloadHash===row.payloadHash && audit.previousFailures===12, 'RETRY_AUDIT') + const initial=prior.business,dead=before.fee,done=after.fee + need(initial && dead && done && equal(dead.finance,done.finance), 'FINANCE_CHANGED_AFTER_DUPLICATE') + const selected=(facts,local)=>{ + const rows=local?facts.lastleg:facts.finance?.source + need(Array.isArray(rows)&&rows.length>0&&new Set(rows.map(a=>a.id)).size===rows.length, 'SOURCE_ROWS') + need(rows.every(a=>a.customerId===scope.customerId && a.sourceType===scope.sourceType && a.sourceFeeId===scope.sourceFeeId), 'SOURCE_SCOPE') + const result=rows.filter(a=>String(local?a.sourceVersion:a.sourceFeeVersion)===scope.sourceVersion) + need(result.length===1, 'UNIQUE_SOURCE_VERSION') + need(Math.max(...rows.map(a=>Number(local?a.sourceVersion:a.sourceFeeVersion)))===Number(scope.sourceVersion), 'LATEST_VERSION') + return result[0] + } + const l0=selected(initial,true),ld=selected(dead,true),la=selected(done,true) + const f0=selected(initial,false),fd=selected(dead,false) + need(l0.sourceStatus==='ACTIVE' && ld.sourceStatus==='ACTIVE' && la.sourceStatus==='VOIDED' + && [l0,ld,la].every(a=>a.reviewStatus==='APPROVED'&&a.publicationStatus==='PUBLISHED'&&a.direction==='RECEIVABLE'), 'LASTLEG_ACK_BOUNDARY') + need(equal(without(l0,['reviewReason','updatedBy','updatedAt']),without(ld,['reviewReason','updatedBy','updatedAt'])) + && equal(without(ld,['sourceStatus','updatedAt']),without(la,['sourceStatus','updatedAt'])) + && typeof ld.reviewReason==='string' && ld.reviewReason.trim() && date(la.updatedAt)>=date(ld.updatedAt), 'LASTLEG_UNEXPECTED_CHANGE') + need(equal(initial.lastleg.filter(a=>a.id!==l0.id),dead.lastleg.filter(a=>a.id!==ld.id)) + && equal(dead.lastleg.filter(a=>a.id!==ld.id),done.lastleg.filter(a=>a.id!==la.id)), 'OTHER_LOCAL_VERSIONS_CHANGED') + need(f0.status==='AVAILABLE' && fd.status==='VOIDED' && Number(fd.operationVersion)===Number(f0.operationVersion)+1 + && equal(without(f0,['status','operationVersion','updatedAt']),without(fd,['status','operationVersion','updatedAt'])) + && f0.sourceHash===payload.sourceHash && f0.direction==='RECEIVABLE' && f0.currencyCode==='CNY' + && f0.amount===l0.amount && f0.walletHoldId===null && equal(f0.sourceSnapshot,original), 'FINANCE_VOID_FACT') + need(equal(initial.finance.source.filter(a=>a.id!==f0.id),dead.finance.source.filter(a=>a.id!==fd.id)), 'OTHER_FINANCE_VERSIONS_CHANGED') + for(const facts of [initial,dead,done]) for(const key of ['bill','settlement','cost']) need(Array.isArray(facts.finance[key])&&facts.finance[key].length===0,'FINANCIAL_LINKS') + const checkInbox=(rows,producer,resource)=>{ + need(rows?.length===1 && rows[0].sourceSystem==='LASTLEG' && rows[0].eventType==='BusinessFeeSourceChanged' + && rows[0].eventId===producer.eventId && rows[0].payloadHash===producer.payloadHash && rows[0].status==='PROCESSED' + && rows[0].resultResourceId===resource.id && rows[0].processedAt && rows[0].errorCode===null + && equal(rows[0].envelope,producer.envelope), 'FINANCE_INBOX') + } + checkInbox(initial.finance.inbox,publication,f0); checkInbox(dead.finance.inbox,row,fd) + need(proof.consumer?.count===1 && equal(without(proof.consumer,['count']),dead.finance.inbox[0]), 'CONSUMER_IDENTITY') + return proof +} diff --git a/scripts/test-fee-void-fault-proof.test.mjs b/scripts/test-fee-void-fault-proof.test.mjs new file mode 100644 index 000000000..2a3a9e51c --- /dev/null +++ b/scripts/test-fee-void-fault-proof.test.mjs @@ -0,0 +1,229 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createHash } from 'node:crypto' +import { spawnSync } from 'node:child_process' +import { createTestFeeVoidFaultExecutor } from './test-event-fault-executor.mjs' +import { assertTestFeeVoidFaultProof } from './test-fee-void-fault-proof.mjs' + +const scope={marker:'FEE-VOID-OFFLINE',customerId:'7',sourceFeeId:'123',sourceVersion:'1',sourceType:'OVERSEAS_WAREHOUSE_FEE'} +const env={CHONGCHONG_ENV:'test',JENKINS_URL:'https://jenkins.example.invalid/',JENKINS_USER:'offline',JENKINS_API_TOKEN:'offline',FBA_CORE_JENKINS_AGENT:'java-test-agent'} +const sort=value=>Array.isArray(value)?value.map(sort):value&&typeof value==='object'?Object.fromEntries(Object.keys(value).sort().map(key=>[key,sort(value[key])])):value +const text=value=>JSON.stringify(sort(value)), hash=value=>'sha256:'+createHash('sha256').update(value).digest('hex') +const clone=value=>JSON.parse(JSON.stringify(value)) +const imageId='sha256:'+'a'.repeat(64),fingerprint='b'.repeat(64) +const restoration={restored:true,healthy:true,proxyStopped:true,producerName:'firstleg-test-lastleg',originalImageId:imageId,currentImageId:imageId,originalFingerprint:fingerprint,currentFingerprint:fingerprint} +function fixture() { + const base=Object.fromEntries(Object.entries({action:null,sourceSystem:'LASTLEG',sourceType:scope.sourceType,sourceFeeId:'123',sourceVersion:1,customerId:'7',customerName:'Offline', + sourceNo:'WH-OFFLINE',businessType:'WAREHOUSE_SERVICE',businessId:'200',businessNo:'WH-OFFLINE',orderId:null,inboundId:null,inboundCode:null, + containerId:null,containerBookingId:null,containerNo:null,warehouseId:'9',feeItemCode:'TEST',feeItemName:'Offline fee',direction:'RECEIVABLE', + supplierId:null,supplierName:null,supplierCategory:null,settlementMode:null,paidAt:null,paymentMethod:null,paymentReferenceNo:null,paymentVoucherFileIds:[], + businessDate:'2026-10-03',currency:'CNY',totalAmount:7.25,originalCurrency:'CNY',originalAmount:7.25,exchangeRate:1,exchangeRateMonth:'2026-10', + chargeableQuantity:null,quantityUnit:null,unitPrice:null,periodStart:null,periodEnd:null,reviewStatus:'APPROVED',publicationStatus:'PUBLISHED',sourceStatus:'ACTIVE',sourceHash:null}).filter(([,value])=>value!==null)) + const sourceCanonicalJson=text(base),sourceHash=hash(sourceCanonicalJson) + const event=(action,index)=>{ + const payload={...base,action,sourceStatus:action==='VOIDED'?'VOIDED':'ACTIVE',sourceHash} + const payloadCanonicalJson=text(payload),payloadHash=hash(payloadCanonicalJson) + const eventId=`00000000-0000-4000-8000-00000000000${index}` + const envelope={eventId,eventType:'BusinessFeeSourceChanged',eventVersion:1,tenantId:'7',aggregateType:scope.sourceType,aggregateId:'123',aggregateVersion:1,payload,payloadHash} + return {entryId:String(index),eventId,payloadHash,eventType:envelope.eventType,customerId:'7',aggregateId:'123',aggregateType:scope.sourceType, + eventKey:`BusinessFeeSourceChanged:LASTLEG:${scope.sourceType}:123:1:${action}`,envelope,envelopeJson:JSON.stringify(envelope),payloadCanonicalJson,sourceCanonicalJson,publishStatus:'PUBLISHED',failedAttempts:0} + } + const publication=event('PUBLISHED',1),producer=event('VOIDED',2) + const local={id:'500',sourceFeeId:'123',sourceVersion:'1',customerId:'7',sourceType:scope.sourceType,sourceStatus:'ACTIVE',reviewStatus:'APPROVED',publicationStatus:'PUBLISHED',direction:'RECEIVABLE',amount:'7.250000',businessType:'WAREHOUSE_SERVICE',businessId:'200',reviewReason:'approved',updatedBy:'7',updatedAt:'2026-10-03 01:00:00'} + const finance={id:'600',sourceSystem:'LASTLEG',sourceFeeId:'123',sourceFeeVersion:'1',customerId:'7',sourceType:scope.sourceType,direction:'RECEIVABLE',currencyCode:'CNY',amount:'7.250000',walletHoldId:null,operationVersion:'0',status:'AVAILABLE',sourceHash,sourceSnapshot:publication.envelope.payload,updatedAt:'2026-10-03 01:00:01'} + const inbox=(row,id)=>({id,eventId:row.eventId,sourceSystem:'LASTLEG',eventType:'BusinessFeeSourceChanged',envelope:row.envelope,payloadHash:row.payloadHash,resultResourceId:'600',status:'PROCESSED',errorCode:null,receivedAt:'2026-10-03 01:00:01',processedAt:'2026-10-03 01:00:01'}) + const initial={lastleg:[local],finance:{source:[finance],bill:[],settlement:[],cost:[],inbox:[inbox(publication,'700')]}} + const dead=clone(initial);dead.lastleg[0].reviewReason='void reason';dead.lastleg[0].updatedAt='2026-10-03 01:01:00' + Object.assign(dead.finance.source[0],{status:'VOIDED',operationVersion:'1',updatedAt:'2026-10-03 01:01:01'});dead.finance.inbox=[inbox(producer,'701')] + const after=clone(dead);Object.assign(after.lastleg[0],{sourceStatus:'VOIDED',updatedAt:'2026-10-03 01:02:00'}) + const prior={ready:true,publication,business:initial,attempts:[{entryId:'1',eventId:publication.eventId,outcome:'APPLIED'}]} + const requestId='00000000-0000-4000-8000-000000000003' + return clone({...scope,readOnly:true,producer,prerequisite:prior,publicationNow:publication, + beforeRetry:{...producer,publishStatus:'DEAD',failedAttempts:12,fee:dead},afterRetry:{...producer,fee:after}, + consumer:{...dead.finance.inbox[0],count:1}, + attempts:Array.from({length:13},(_,i)=>({attemptId:`a${i}`,entryId:'2',eventId:producer.eventId,serviceName:'lastleg',outcome:i<11?'RETRY':i===11?'DEAD':'DUPLICATE'})), + proxyRecords:Array.from({length:13},(_,i)=>({matched:true,attempt:i+1,eventId:producer.eventId,payloadHash:producer.payloadHash,outcome:i===0?'APPLIED':'DUPLICATE',errorCode:i<12?'ACK_LOSS_INJECTED':null})), + retryAuditCount:1,retryRequestId:requestId,retryAudit:{requestId,entryId:'2',eventId:producer.eventId,payloadHash:producer.payloadHash,previousFailures:12}}) +} +function config(options) { + const chunks=[...options.body.get('script').matchAll(/'([A-Za-z0-9+/=]+)'/g)].map(m=>m[1]).join('') + const groovy=Buffer.from(chunks,'base64').toString('utf8'),encoded=groovy.match(/'UTF-8'\),'([A-Za-z0-9+/=]+)'\]\)/)?.[1] + return JSON.parse(Buffer.from(encoded,'base64').toString('utf8')) +} +function transport(calls,{pending=false,preflightFailure=false,proof=fixture(),finish={}}={}) { + let recovered=false,preflightCount=0 + return async(_url,options)=>{ + const request=config(options);assert.equal(request.direction,'FEE_VOID');assert.deepEqual(request.scope,scope) + const action=request.action;calls.push(action) + if(action==='recover')recovered=true + if(action==='preflight')preflightCount++ + const data=action==='preflight'?{ready:!pending||preflightCount>1}:action==='prepare'?{originalImageId:imageId,originalFingerprint:fingerprint,prerequisite:proof.prerequisite} + :action==='proxy-ready'?{ready:true}:action==='health'?{healthy:true}:action==='status'?{producer:{publishStatus:recovered?'PUBLISHED':'DEAD',failedAttempts:12}} + :action==='proof'?proof:action==='finish'?{...restoration,...finish}:{} + const result=preflightFailure&&action==='preflight'?{ok:false,error:'FEE_PUBLICATION_NOT_READY'}:{ok:true,data} + return {ok:true,text:async()=> 'TEST_EVENT_JSON='+JSON.stringify(result)} + } +} +test('VOID proof accepts Finance VOIDED at DEAD while Lastleg remains ACTIVE until matched retry ACK',()=>{ + const proof=fixture();assertTestFeeVoidFaultProof(proof,scope) + assert.equal(proof.beforeRetry.fee.lastleg[0].sourceStatus,'ACTIVE') + assert.equal(proof.beforeRetry.fee.finance.source[0].status,'VOIDED') + assert.equal(proof.afterRetry.fee.lastleg[0].sourceStatus,'VOIDED') +}) +test('VOID proof rejects premature local confirmation, extra financial writes, altered source and lost original event evidence',()=>{ + const mutations=[p=>p.beforeRetry.fee.lastleg[0].sourceStatus='VOIDED',p=>p.afterRetry.fee.lastleg[0].sourceStatus='ACTIVE', + p=>p.afterRetry.fee.lastleg[0].amount='8.250000',p=>p.afterRetry.fee.lastleg[0].sourceVersion='2', + p=>p.afterRetry.fee.finance.source[0].operationVersion='2',p=>p.beforeRetry.fee.finance.source[0].status='AVAILABLE', + p=>p.prerequisite.publication.publishStatus='PENDING',p=>p.prerequisite.business.finance.source[0].walletHoldId='99', + p=>p.afterRetry.fee.finance.bill.push({id:'123'}),p=>p.consumer.resultResourceId='601', + p=>p.producer.envelope.payload.sourceFeeId='124',p=>p.beforeRetry.entryId='9',p=>p.retryAuditCount=2, + p=>p.proxyRecords[0].outcome='DUPLICATE',p=>p.attempts.pop(),p=>p.producer.sourceCanonicalJson='{}', + p=>p.prerequisite.business.lastleg.push({...p.prerequisite.business.lastleg[0],id:'501',sourceVersion:'2'})] + for(const mutate of mutations){const proof=fixture();mutate(proof);assert.throws(()=>assertTestFeeVoidFaultProof(proof,scope),/Invalid fee void/)} +}) +test('preflight waits for original publish ACK before a single prepare; restoration precedes successful proof',async()=>{ + const calls=[],executor=createTestFeeVoidFaultExecutor(env,transport(calls,{pending:true})) + for(const invalid of [{...scope,sourceVersion:1},{...scope,sourceType:'UNKNOWN'},{...scope,sourceVersion:'2147483648'},{...scope,action:'VOIDED'}]) + await assert.rejects(executor.prepare(invalid),/Invalid/) + await executor.prepare(scope) + const proof=await executor.readProof(scope);assert.equal(proof.restored,true) + assert.deepEqual(calls,['preflight','preflight','prepare','proxy-ready','activate','health','status','recover','status','proof','restore','health','finish']) + await executor.cleanup();assert.equal(calls.filter(a=>a==='prepare').length,1) +}) +test('preflight failure never mutates environment and cannot silently reuse an unprepared executor',async()=>{ + const calls=[],executor=createTestFeeVoidFaultExecutor(env,transport(calls,{preflightFailure:true})) + await assert.rejects(executor.prepare(scope),/FEE_PUBLICATION_NOT_READY/) + assert.deepEqual(await executor.cleanup(),{attempted:false});await assert.rejects(executor.prepare(scope),/did not complete/) + assert.deepEqual(calls,['preflight']) +}) +test('proof failure restores; missing original configuration or proxy stop remains a blocker',async()=>{ + const proof=fixture();proof.afterRetry.fee.finance.source[0].operationVersion='2' + const calls=[],executor=createTestFeeVoidFaultExecutor(env,transport(calls,{proof})) + await executor.prepare(scope);await assert.rejects(executor.readProof(scope),/FINANCE_CHANGED/) + assert.deepEqual(calls.slice(-3),['restore','health','finish']) + for(const finish of [{proxyStopped:false},{currentFingerprint:'c'.repeat(64)}]) { + const failed=createTestFeeVoidFaultExecutor(env,transport([],{finish})) + await failed.prepare(scope);await assert.rejects(failed.readProof(scope),/Fee void restoration/) + } +}) + +test('unknown prepare result is never reissued and cleanup still attempts original restoration',async()=>{ + const calls=[],base=transport(calls) + const executor=createTestFeeVoidFaultExecutor(env,async(url,options)=>{ + if(config(options).action==='prepare'){calls.push('prepare');throw Error('response lost')} + return base(url,options) + }) + await assert.rejects(executor.prepare(scope),/response lost/) + await assert.rejects(executor.prepare(scope),/did not complete/) + await assert.rejects(executor.cleanup(),/Fee void restoration evidence mismatch/) + assert.deepEqual(calls,['preflight','prepare','restore','health','finish']) +}) + +test('NON_NULL source hashes agree across runtime and proof and reject explicit null unsigned fields',()=>{ + const proof=fixture(),publication=proof.prerequisite.publication + const unsigned=JSON.parse(publication.sourceCanonicalJson) + assert.equal(Object.hasOwn(unsigned,'action'),false) + assert.equal(Object.hasOwn(unsigned,'sourceHash'),false) + assert.equal(Object.hasOwn(unsigned,'orderId'),false) + assert.deepEqual(unsigned.paymentVoucherFileIds,[]) + const invalid=clone(proof) + invalid.producer.sourceCanonicalJson=text({...unsigned,action:null,sourceHash:null}) + invalid.producer.envelope.payload.sourceHash=hash(invalid.producer.sourceCanonicalJson) + invalid.producer.payloadCanonicalJson=text(invalid.producer.envelope.payload) + invalid.producer.payloadHash=hash(invalid.producer.payloadCanonicalJson) + invalid.producer.envelope.payloadHash=invalid.producer.payloadHash + invalid.producer.envelopeJson=JSON.stringify(invalid.producer.envelope) + assert.throws(()=>assertTestFeeVoidFaultProof(invalid,scope),/SOURCE_HASH/) + const result=spawnSync('python',['-c',String.raw` +import sys,importlib.util,json,copy,hashlib +from unittest.mock import patch +sys.dont_write_bytecode=True +spec=importlib.util.spec_from_file_location('fault','scripts/test-event-fault-runtime.py');m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) +data=json.load(sys.stdin);scope=data['scope'];row=data['row'] +with patch.object(m,'inspect'),patch.object(m,'read_sql',side_effect=lambda *args:[copy.deepcopy(row)]): + result=m.fee_event(scope,'PUBLISHED') + assert result['sourceCanonicalJson']==row['sourceCanonicalJson'] + assert result['payloadCanonicalJson']==row['payloadCanonicalJson'] + payload=json.loads(row['envelopeJson'])['payload'] + unsigned={**payload,'action':None,'sourceHash':None,'sourceStatus':'ACTIVE'} + payload['sourceHash']='sha256:'+hashlib.sha256(m.fee_canonical(unsigned).encode()).hexdigest() + envelope=json.loads(row['envelopeJson']);envelope['payload']=payload + row['envelopeJson']=json.dumps(envelope) + row['payloadHash']='sha256:'+hashlib.sha256(m.fee_canonical(payload).encode()).hexdigest() + try:m.fee_event(scope,'PUBLISHED');assert False + except RuntimeError as e:assert str(e)=='FEE_SOURCE_HASH_MISMATCH' + row['payloadHash']='sha256:'+'0'*64 + try:m.fee_event(scope,'PUBLISHED');assert False + except RuntimeError as e:assert str(e)=='FEE_PAYLOAD_HASH_MISMATCH' +`],{cwd:new URL('..',import.meta.url),input:JSON.stringify({scope,row:publication}),encoding:'utf8',timeout:10000}) + assert.equal(result.status,0,result.stderr||result.error?.message) +}) + +test('Python FEE_VOID routing, exact decimal hashes, preflight gating and restore reentry are offline',()=>{ + const result=spawnSync('python',['-c',String.raw` +import sys,importlib.util,pathlib,copy,decimal,ast,io +from unittest.mock import patch,mock_open +sys.dont_write_bytecode=True +p=pathlib.Path('scripts/test-event-fault-runtime.py');ast.parse(p.read_text(encoding='utf-8')) +spec=importlib.util.spec_from_file_location('fault',p);m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) +s={'marker':'FEE-VOID-OFFLINE','customerId':'7','sourceFeeId':'123','sourceVersion':'1','sourceType':'OVERSEAS_WAREHOUSE_FEE'} +assert m.target_setting({'direction':'FEE_VOID'})==('FINANCE_SERVICE_URL','http://finance:9203') +assert m.target_setting({'direction':'DELIVERY'})==('FIRSTLEG_SERVICE_URL','http://firstleg:9202') +assert m.producer_name({'direction':'FEE_VOID'})=='firstleg-test-lastleg' +assert m.fee_canonical(decimal.Decimal('100.000000'))=='1E+2' +assert m.fee_canonical(decimal.Decimal('0.00000010'))=='1E-7' +assert m.fee_canonical(decimal.Decimal('12345678901234.123456'))=='12345678901234.123456' +assert m.fee_canonical(decimal.Decimal('-0.000'))=='0' +queries=[] +with patch.object(m,'inspect',side_effect=lambda name:{'Name':'/'+name}),patch.object(m,'require_test'),patch.object(m,'read_sql',side_effect=lambda c,sql:queries.append((c['Name'],sql)) or []): + assert m.producer(s,'FEE_VOID') is None + m.fee_facts(s,'00000000-0000-4000-8000-000000000002') +assert [r[0] for r in queries]==['/firstleg-test-lastleg','/firstleg-test-lastleg','/firstleg-test-finance','/firstleg-test-finance'] +assert 'BusinessFeeSourceChanged:LASTLEG:OVERSEAS_WAREHOUSE_FEE:123:1:VOIDED' in queries[0][1] +assert 'void_status' not in queries[1][1] and 'source_status' in queries[1][1] +assert all(t in queries[2][1] for t in ['fin_fee_source','fin_bill_line','fin_settlement_line','fin_cost','operation_version']) +assert 'fin_inbox_event' in queries[3][1] and 'result_resource_id' in queries[3][1] +publication={'entryId':'1','eventId':'00000000-0000-4000-8000-000000000001','publishStatus':'PENDING','payloadHash':'sha256:'+'a'*64} +facts={'lastleg':[{'sourceVersion':'1','sourceType':s['sourceType'],'customerId':'7','sourceStatus':'ACTIVE','reviewStatus':'APPROVED','publicationStatus':'PUBLISHED','direction':'RECEIVABLE'}], + 'finance':{'source':[{'id':'600','sourceFeeVersion':'1','sourceType':s['sourceType'],'customerId':'7','status':'AVAILABLE','walletHoldId':None}], + 'bill':[],'settlement':[],'cost':[],'inbox':[{'status':'PROCESSED','payloadHash':publication['payloadHash'],'resultResourceId':'600'}]}} +with patch.object(m,'fee_event',side_effect=lambda scope,action:None if action=='VOIDED' else publication),patch.object(m,'fee_facts',return_value=facts),patch.object(m,'inspect'),patch.object(m,'read_sql',return_value=[{'eventId':publication['eventId'],'outcome':'APPLIED'}]): + assert m.fee_prerequisite(s,wait=True)['ready'] is False + publication['publishStatus']='PUBLISHED';assert m.fee_prerequisite(s,wait=True)['ready'] is True + publication['publishStatus']='DEAD' + try:m.fee_prerequisite(s,wait=True);assert False + except RuntimeError as e:assert str(e)=='FEE_PUBLICATION_NOT_READY' +original={'Name':'/firstleg-test-lastleg','Id':'original','Image':'sha256:'+'a'*64,'Config':{'Image':'lastleg:test','Env':['FINANCE_SERVICE_URL=http://finance:9203','FIRSTLEG_SERVICE_URL=http://firstleg:9202'],'Labels':{'com.docker.compose.project':'firstleg-test'}},'HostConfig':{},'Mounts':[], + 'NetworkSettings':{'Networks':{'firstleg-test-network':{'IPAddress':'172.20.0.3','Aliases':['lastleg-service']}}},'State':{'Running':True,'Health':{'Status':'healthy'}}} +finance=copy.deepcopy(original);finance['Name']='/firstleg-test-finance';finance['NetworkSettings']['Networks']['firstleg-test-network']['IPAddress']='172.20.0.9' +with patch.object(m,'fee_prerequisite',return_value={'ready':True}),patch.object(m,'inspect',side_effect=lambda name:finance if name=='firstleg-test-finance' else original),patch.object(m.Path,'mkdir'),patch.object(m.Path,'write_bytes'),patch('builtins.open',mock_open()),patch.object(m.socket,'socket') as sock,patch.object(m.subprocess,'Popen') as process,patch.object(m,'private_write'): + sock.return_value.getsockname.return_value=('127.0.0.1',18001) + m.prepare(s,pathlib.Path('/offline'),{'direction':'FEE_VOID','proxySource':''}) + args=process.call_args.args[0] + assert args[args.index('--upstream')+1]=='http://172.20.0.9:9203/internal/finance/v1/business-fee-source-events' + assert args[args.index('--fee-source-id')+1]=='123' and args[args.index('--source-version')+1]=='1' + assert '--delivery-id' not in args and '--container-booking-id' not in args +state={'direction':'FEE_VOID','restored':False,'prepared':True,'originalId':'original','image':'lastleg:test','originalImageId':original['Image'],'originalFingerprint':m.fingerprint(original),'originalBody':m.recreation(original,m.env_of(original),full=True),'proxyPort':18001} +with patch.object(m,'control',return_value={}),patch.object(m,'inspect',return_value=original),patch.object(m,'private_write'),patch.object(m,'replace') as replace: + m.activate(s,pathlib.Path('/offline'),copy.deepcopy(state)) + changed=dict(item.split('=',1) for item in replace.call_args.args[1]['Env']) + assert changed['FINANCE_SERVICE_URL']=='http://host.docker.internal:18001' + assert changed['FIRSTLEG_SERVICE_URL']=='http://firstleg:9202' and changed['EVENT_MAX_ATTEMPTS']=='12' +row={'entryId':'2','eventId':'00000000-0000-4000-8000-000000000002','payloadHash':'sha256:'+'b'*64,'publishStatus':'DEAD','failedAttempts':12} +retry_state={**state,'operationsRequestId':'00000000-0000-4000-8000-000000000003'} +runtime=copy.deepcopy(original);runtime['Config']['Env'].append('EVENT_OPERATIONS_TOKEN=offline') +dead={'lastleg':[{'sourceVersion':'1','sourceStatus':'ACTIVE'}],'finance':{'source':[{'sourceFeeVersion':'1','status':'VOIDED'}]}} +with patch.object(m,'producer',return_value=row),patch.object(m,'control',return_value=row),patch.object(m,'fee_facts',return_value=dead),patch.object(m,'private_write'),patch.object(m,'inspect',return_value=runtime),patch.object(m.urllib.request,'urlopen',return_value=io.BytesIO(b'{"code":"200"}')) as http: + assert m.recover(s,pathlib.Path('/offline'),retry_state)['retrySubmitted'] + assert http.call_args.args[0].full_url=='http://172.20.0.3:9303/internal/lastleg/event-operations/2/retry' + assert retry_state['beforeRetry']['fee']['lastleg'][0]['sourceStatus']=='ACTIVE' +restored=copy.deepcopy(original);restored['Id']='restored' +with patch.object(m,'control',side_effect=OSError('stopped')),patch.object(m,'inspect',return_value=restored),patch.object(m,'private_write'),patch.object(m,'replace') as replace: + m.restore(pathlib.Path('/offline'),state);assert not state['prepared'];replace.assert_not_called() +state['prepared']=True;restored['Config']['Env'].append('UNEXPECTED=1') +with patch.object(m,'control'),patch.object(m,'inspect',return_value=restored): + try:m.restore(pathlib.Path('/offline'),state);assert False + except RuntimeError as e:assert str(e)=='CONCURRENT_PRODUCER_CHANGE' +`],{cwd:new URL('..',import.meta.url),encoding:'utf8',timeout:10000}) + assert.equal(result.status,0,result.stderr||result.error?.message) +}) diff --git a/scripts/use-nacos-environment.ps1 b/scripts/use-nacos-environment.ps1 index ee5f64734..0e4cdb9d4 100644 --- a/scripts/use-nacos-environment.ps1 +++ b/scripts/use-nacos-environment.ps1 @@ -1,74 +1,17 @@ param( - [ValidateSet('local', 'dev', 'development', 'test')] - [string]$Environment, - [ValidateSet('cn', 'overseas')] - [string]$Region = 'cn', - [string]$NacosServerAddr, - [string]$NacosNamespace, - [string]$NacosUsername, - [string]$NacosPassword + [ValidateSet('test')][string]$Environment = 'test', + [Parameter(Mandatory)][string]$NacosServerAddr, + [Parameter(Mandatory)][string]$NacosNamespace, + [string]$NacosUsername = $env:NACOS_USERNAME, + [string]$NacosPassword = $env:NACOS_PASSWORD ) - $ErrorActionPreference = 'Stop' -$target = if ($Environment -eq 'development') { 'dev' } else { $Environment } - -$defaults = @{ - local = @{ Server = '127.0.0.1:8848'; CnNamespace = 'public' } - dev = @{ Server = '8.163.49.32:8848'; CnNamespace = $env:DEV_NACOS_NAMESPACE } - test = @{ Server = '8.163.30.128:8848'; CnNamespace = 'd99d7998-5bb0-4ffe-a2a0-1a564ce14463' } -} -$selected = $defaults[$target] -$serverAddr = if ($NacosServerAddr) { $NacosServerAddr } else { $selected.Server } -$namespace = if ($NacosNamespace) { - $NacosNamespace -} elseif ($target -eq 'local') { - 'public' -} elseif ($Region -eq 'overseas') { - $regionNamespace = [Environment]::GetEnvironmentVariable("$($target.ToUpperInvariant())_OVERSEAS_NACOS_NAMESPACE") - if ($regionNamespace) { - $regionNamespace - } else { - [Environment]::GetEnvironmentVariable("$($target.ToUpperInvariant())_NACOS_NAMESPACE") - } -} else { - $selected.CnNamespace -} -$username = if ($NacosUsername) { - $NacosUsername -} elseif ([Environment]::GetEnvironmentVariable("$($target.ToUpperInvariant())_NACOS_USERNAME")) { - [Environment]::GetEnvironmentVariable("$($target.ToUpperInvariant())_NACOS_USERNAME") -} else { - 'nacos' -} -$password = if ($NacosPassword) { - $NacosPassword -} elseif ([Environment]::GetEnvironmentVariable("$($target.ToUpperInvariant())_NACOS_PASSWORD")) { - [Environment]::GetEnvironmentVariable("$($target.ToUpperInvariant())_NACOS_PASSWORD") -} elseif ($target -eq 'local') { - 'nacos' -} else { - $null -} - -if (-not $namespace) { - throw "$target/$Region 必须通过 -NacosNamespace 或对应的 *_NACOS_NAMESPACE 环境变量指定 namespace" -} -if (-not $password) { - throw "$target 必须通过 -NacosPassword 或 $($target.ToUpperInvariant())_NACOS_PASSWORD 提供 Nacos 密码" -} - -$env:NACOS_SERVER_ADDR = $serverAddr -$env:NACOS_NAMESPACE = $namespace -$env:NACOS_GROUP = 'BAIYUE' -$env:NACOS_USERNAME = $username -$env:NACOS_PASSWORD = $password -$env:SPRING_CLOUD_NACOS_CONFIG_NAMESPACE = $namespace -$env:SPRING_CLOUD_NACOS_DISCOVERY_NAMESPACE = $namespace -$env:SPRING_PROFILES_ACTIVE = if ($Region -eq 'overseas') { 'overseas' } elseif ($target -eq 'local') { 'dev' } else { 'cn' } -$env:BAIYUE_RUNTIME_ENVIRONMENT = $target -$env:BAIYUE_RUNTIME_REGION = $Region - -Write-Host "Runtime environment: $target / $Region" -Write-Host "Nacos: $serverAddr; namespace: $namespace; group: BAIYUE" -Write-Host "Spring profiles: $env:SPRING_PROFILES_ACTIVE" -Write-Host 'Nacos password is loaded into the current process and is not printed.' +if (-not $NacosNamespace -or $NacosNamespace -eq 'public') { throw 'Actual TEST Namespace ID required' } +if (-not $NacosUsername -or -not $NacosPassword) { throw 'Nacos credentials required' } +$env:NACOS_SERVER_ADDR=$NacosServerAddr +$env:NACOS_NAMESPACE=$NacosNamespace +$env:NACOS_GROUP='BAIYUE' +$env:NACOS_USERNAME=$NacosUsername +$env:NACOS_PASSWORD=$NacosPassword +$env:SPRING_PROFILES_ACTIVE='test' +Write-Host 'TEST Nacos configuration loaded into this process; no configuration published.' diff --git a/warehouse-overseas-service/AGENTS.md b/warehouse-overseas-service/AGENTS.md index 55adcf0ae..5b5ae5124 100644 --- a/warehouse-overseas-service/AGENTS.md +++ b/warehouse-overseas-service/AGENTS.md @@ -17,7 +17,7 @@ | 托盘解析与业务绑定 | `controller/InternalWarehousePalletController.java`、`dto/WarehousePalletResolveResponse.java`、`entity/WarehousePalletBindingEntity.java` | | 柜级入库计划批量托盘 | `controller/InternalWarehousePalletBatchController.java`、`service/WarehousePalletBatchService.java`、`warehouse-pallet-batch-internal-v1.yaml`;以稳定批次键幂等执行 `ensure-reserved → confirm`,权威托盘码已存在且资源可预留时必须复用,未提供或不存在时才创建;Lastleg 本地计划事务失败时仅补偿仍为 `RESERVED` 的未确认资源,已 `CONFIRMED` 批次不得回退,同柜重放不得产生第二批资源 | | 仓储资源服务 | `service/WarehouseResourceService.java`、`WarehouseResourceLockService.java` | -| 单张托盘标签 PDF | `controller/PlatformWarehouseResourceController.java` 的 `/pallets/{id}/label`、`service/PalletLabelPrintService.java`、`client/PrintPdfClient.java`;契约 `src/main/resources/openapi/pallet-label-v1.yaml`。有 FBA 仓码的托盘与国内统一调用 Print `/internal/print/v1/pdfs/fba-pallet-labels`、`FBA_PALLET_A4_LANDSCAPE_V1`;其他托盘调用 `/internal/print/v1/pdfs/pallet-a4-labels`、`PALLET_A4_LANDSCAPE_V1`。两者均为 297×210 mm A4 横向,托盘完整编码作为 Code 128 与底部文字,托盘标不得回退到 100×100 mm 箱唛模板。模板只在 Print 维护;沿用托盘列表权限与海外 Gateway 的 `warehouse-admin/**` 路由。`baiyue.clients.print.url` 对应 `PRINT_SERVICE_URL`,海外 Compose 必须与 BOL 一样显式注入国内 Print 地址,不能依赖海外 Nacos 发现国内服务;Feign 及熔断包装异常统一返回 `S0503` | +| 单张托盘标签 PDF | `controller/PlatformWarehouseResourceController.java` 的 `/pallets/{id}/label`、`service/PalletLabelPrintService.java`、`client/PrintPdfClient.java`;契约 `src/main/resources/openapi/pallet-label-v1.yaml`。有 FBA 仓码的托盘与国内统一调用 Print `/internal/print/v1/pdfs/fba-pallet-labels`、`FBA_PALLET_A4_LANDSCAPE_V1`;其他托盘调用 `/internal/print/v1/pdfs/pallet-a4-labels`、`PALLET_A4_LANDSCAPE_V1`。两者均为 297×210 mm A4 横向,托盘完整编码作为 Code 128 与底部文字,托盘标不得回退到 100×100 mm 箱唛模板。模板只在 Print 维护;沿用托盘列表权限与统一 Gateway 的 `overseas-warehouse-resources/**` 路由。`baiyue.clients.print.url` 对应 `PRINT_SERVICE_URL`,单机 Compose 使用内部 Print 地址,所有服务位于同一测试 Nacos Namespace;Feign 及熔断包装异常统一返回 `S0503` | | 内部服务令牌 | `security/InternalServiceTokenGuard.java` | | 数据库基线与中文注释契约 | `src/main/resources/db/warehouse.sql`、`src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java` | | 测试 | `src/test/java/com/baiyue/warehouse/` | diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseDirectoryController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseDirectoryController.java index b930b438e..e2efe6836 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseDirectoryController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseDirectoryController.java @@ -9,30 +9,23 @@ import com.baiyue.warehouse.security.OverseasWarehousePermissions; import com.baiyue.warehouse.dto.PalletRequests; import com.baiyue.warehouse.entity.WarehousePalletEntity; import com.baiyue.warehouse.service.WarehouseResourceService; -import com.baiyue.warehouse.service.DomesticWarehouseDirectory; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import jakarta.validation.Valid; -import java.util.List; - -/** Exposes the domestic authoritative directory as read-only overseas resource options. */ +/** Cross-warehouse queries for overseas resources; warehouse masters belong to Warehouse. */ @RestController -@RequestMapping("/api/platform/v1/warehouse-admin") +@RequestMapping("/api/platform/v1/overseas-warehouse-resources") @Tag(name = "Platform overseas warehouse directory") public class PlatformWarehouseDirectoryController { - private final DomesticWarehouseDirectory directory; private final WarehouseResourceService resources; - public PlatformWarehouseDirectoryController(DomesticWarehouseDirectory directory, - WarehouseResourceService resources) { - this.directory = directory; + public PlatformWarehouseDirectoryController(WarehouseResourceService resources) { this.resources = resources; } @@ -56,29 +49,6 @@ public class PlatformWarehouseDirectoryController { return ApiResponse.success(resources.publicPalletSummary(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, query)); } - @GetMapping - @PreAuthorize("hasAuthority('" + OverseasWarehousePermissions.WAREHOUSE_LIST + "')") - @Operation(summary = "Page shared overseas receiving warehouses", - description = "Required authority: " + OverseasWarehousePermissions.WAREHOUSE_LIST) - public ApiResponse> warehouses( - @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(defaultValue = "1") long page, - @RequestParam(defaultValue = "200") long size) { - requirePlatform(user); - if (page < 1 || size < 1 || size > 200) { - throw new BizException(ResultCode.BAD_REQUEST, "分页参数无效"); - } - List all = directory.options().stream() - .map(value -> new WarehouseOption(value.warehouseId(), 0L, value.warehouseCode(), - value.warehouseName(), "OVERSEAS", value.status())) - .toList(); - long from = Math.min((page - 1) * size, all.size()); - long to = Math.min(from + size, all.size()); - long pages = all.isEmpty() ? 0 : (all.size() + size - 1) / size; - return ApiResponse.success(new PageResult<>(all.subList((int) from, (int) to), - page, size, all.size(), pages)); - } - private static void requirePlatform(AuthenticatedUser user) { if (user == null || !"PLATFORM".equals(user.appCode()) || user.tenantId() == null || user.tenantId() != 0L) { @@ -86,6 +56,4 @@ public class PlatformWarehouseDirectoryController { } } - public record WarehouseOption(long id, long customerId, String warehouseCode, - String warehouseName, String warehouseType, String status) { } } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java index f2864e1d9..e081ca4f5 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/PlatformWarehouseResourceController.java @@ -33,7 +33,7 @@ import org.springframework.web.bind.annotation.RestController; @Validated @RestController -@RequestMapping("/api/platform/v1/warehouse-admin/{warehouseId}") +@RequestMapping("/api/platform/v1/overseas-warehouse-resources/{warehouseId}") @Tag(name = "Platform overseas warehouse resources") public class PlatformWarehouseResourceController { @Resource private WarehouseResourceService service; diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java deleted file mode 100644 index 795623301..000000000 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/WarehouseHealthController.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.baiyue.warehouse.controller; - -import com.baiyue.common.core.api.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import java.util.Map; - -/** - * 仓库微服务可用性接口,向平台端暴露服务逻辑状态。 - */ -@Tag(name = "Warehouse service status / 仓储服务状态", description = "Warehouse service availability API. / 仓储服务可用性接口。") -@RestController -@RequestMapping("/api/platform/v1/warehouses") -public class WarehouseHealthController { - @Operation(summary = "Get service status / 查询服务状态", description = "Returns the logical status of warehouse-overseas-service. / 返回 warehouse-overseas-service 的逻辑运行状态。") - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Service is available / 服务可用") - @GetMapping("/status") - public ApiResponse> status() { - return ApiResponse.success(Map.of("service", "warehouse-overseas-service", "status", "UP")); - } -} - diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java index f4cbe17c2..7659ae918 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletTransitionService.java @@ -18,6 +18,7 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.time.LocalDateTime; import java.util.HexFormat; +import java.util.Objects; import java.util.Set; @Service @@ -102,15 +103,15 @@ public class WarehousePalletTransitionService { WarehouseLocationEntity target; if (sourceLocationId <= targetLocationId) { source = requireLocation(warehouseId, sourceLocationId); - target = sourceLocationId == targetLocationId ? source : requireLocation(warehouseId, targetLocationId); + target = Objects.equals(sourceLocationId, targetLocationId) ? source : requireLocation(warehouseId, targetLocationId); } else { target = requireLocation(warehouseId, targetLocationId); source = requireLocation(warehouseId, sourceLocationId); } requireLocationMatchesPallet(source, pallet.getStatus()); - if (sourceLocationId != targetLocationId) requireAvailableTarget(target, targetStatus); + if (!Objects.equals(sourceLocationId, targetLocationId)) requireAvailableTarget(target, targetStatus); - if (sourceLocationId == targetLocationId) { + if (Objects.equals(sourceLocationId, targetLocationId)) { transitionSameLocation(warehouseId, source, pallet.getStatus(), targetStatus); } else { releaseSourceLocation(warehouseId, source, pallet.getStatus()); @@ -156,7 +157,7 @@ public class WarehousePalletTransitionService { } private void requireAvailableTarget(WarehouseLocationEntity location, String targetStatus) { - String expected = isVirtual(location) ? "ACTIVE" : "IDLE"; + String expected = isNonExclusive(location) ? "ACTIVE" : "IDLE"; if (!expected.equals(location.getStatus())) { throw new BizException(ResultCode.CONFLICT, "目标库位不可用"); } @@ -167,18 +168,18 @@ public class WarehousePalletTransitionService { private void transitionSameLocation(long warehouseId, WarehouseLocationEntity location, String beforeStatus, String afterStatus) { - if (beforeStatus.equals(afterStatus) || isVirtual(location)) return; + if (beforeStatus.equals(afterStatus) || isNonExclusive(location)) return; changeLocationStatus(warehouseId, location.getId(), expectedLocationStatus(location, beforeStatus), expectedLocationStatus(location, afterStatus)); } private void releaseSourceLocation(long warehouseId, WarehouseLocationEntity source, String palletStatus) { - if (isVirtual(source) || "IDLE".equals(palletStatus)) return; + if (isNonExclusive(source) || "IDLE".equals(palletStatus)) return; changeLocationStatus(warehouseId, source.getId(), "OCCUPIED", "IDLE"); } private void occupyTargetLocation(long warehouseId, WarehouseLocationEntity target, String palletStatus) { - if (isVirtual(target) || "IDLE".equals(palletStatus)) return; + if (isNonExclusive(target) || "IDLE".equals(palletStatus)) return; changeLocationStatus(warehouseId, target.getId(), "IDLE", "OCCUPIED"); } @@ -190,11 +191,11 @@ public class WarehousePalletTransitionService { } private static String expectedLocationStatus(WarehouseLocationEntity location, String palletStatus) { - return isVirtual(location) ? "ACTIVE" : palletStatus; + return isNonExclusive(location) ? "ACTIVE" : palletStatus; } - private static boolean isVirtual(WarehouseLocationEntity location) { - return "VIRTUAL".equals(location.getLocationType()); + private static boolean isNonExclusive(WarehouseLocationEntity location) { + return "VIRTUAL".equals(location.getLocationType()) || WarehouseResourceService.isSharedOverflow(location); } private PalletTransitionResponse replay(WarehousePalletOperationEntity operation, String hash) { diff --git a/warehouse-overseas-service/src/main/resources/application.yml b/warehouse-overseas-service/src/main/resources/application.yml index 5cf4f5762..fe31d6f99 100644 --- a/warehouse-overseas-service/src/main/resources/application.yml +++ b/warehouse-overseas-service/src/main/resources/application.yml @@ -4,38 +4,38 @@ server: spring: sql: init: - mode: always + mode: never encoding: UTF-8 schema-locations: classpath:db/warehouse.sql data: redis: - host: ${REDIS_HOST:127.0.0.1} - port: ${REDIS_PORT:6380} + host: ${REDIS_HOST} + port: ${REDIS_PORT:6379} application: name: warehouse-overseas-service config: import: - - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + - nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true + - nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} + server-addr: ${NACOS_SERVER_ADDR} + username: ${NACOS_USERNAME} + password: ${NACOS_PASSWORD} discovery: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} + server-addr: ${NACOS_SERVER_ADDR} enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} config: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - namespace: ${NACOS_NAMESPACE:public} + server-addr: ${NACOS_SERVER_ADDR} + namespace: ${NACOS_NAMESPACE} datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url: ${WAREHOUSE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_lastlag_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${WAREHOUSE_DB_USERNAME:root} - password: ${WAREHOUSE_DB_PASSWORD:123456} + url: ${WAREHOUSE_DB_URL} + username: ${WAREHOUSE_DB_USERNAME} + password: ${WAREHOUSE_DB_PASSWORD} management.endpoints.web.exposure.include: health,info,prometheus baiyue: @@ -48,10 +48,10 @@ baiyue: url: ${DOMESTIC_WAREHOUSE_URL:} cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m} internal-service: - token: ${INTERNAL_SERVICE_TOKEN:phase1-local-internal-token-at-least-32-bytes} + token: ${INTERNAL_SERVICE_TOKEN} security: jwt-mode: jwks - session-mode: gateway + session-mode: redis issuer: ${JWT_ISSUER:auth-service} audience: ${JWT_AUDIENCE:lastleg-api} - jwk-set-uri: ${JWT_JWK_SET_URI:http://127.0.0.1:9100/api/auth/.well-known/jwks.json} + jwk-set-uri: ${JWT_JWKS_URI:http://auth:9100/api/auth/.well-known/jwks.json} diff --git a/warehouse-overseas-service/src/main/resources/openapi/pallet-label-v1.yaml b/warehouse-overseas-service/src/main/resources/openapi/pallet-label-v1.yaml index 8152ade7a..f373d8197 100644 --- a/warehouse-overseas-service/src/main/resources/openapi/pallet-label-v1.yaml +++ b/warehouse-overseas-service/src/main/resources/openapi/pallet-label-v1.yaml @@ -3,7 +3,7 @@ info: title: Overseas pallet label version: 1.0.0 paths: - /api/platform/v1/warehouse-admin/{warehouseId}/pallets/{id}/label: + /api/platform/v1/overseas-warehouse-resources/{warehouseId}/pallets/{id}/label: get: operationId: printOverseasPalletLabel summary: 下载指定托盘的一页标签 PDF diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfigurationTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfigurationTest.java index 2f2ef9751..87d7baf34 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfigurationTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/config/WarehouseDatabaseGuardConfigurationTest.java @@ -11,14 +11,14 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; class WarehouseDatabaseGuardConfigurationTest { @Test - void applicationDefaultsTargetDedicatedOverseasWarehouseDatabase() throws Exception { - var properties = new YamlPropertySourceLoader() - .load("application", new ClassPathResource("application.yml")) - .get(0); - - assertThat(properties.getProperty("spring.datasource.url")) - .asString() - .contains("baiyue_lastlag_warehouse") + void testConfigurationTargetsDedicatedWarehouseDatabaseViaRequiredExternalSettings() throws Exception { + var root = java.nio.file.Path.of(".."); + String nacos = java.nio.file.Files.readString(root.resolve("config/nacos/test/warehouse-overseas-service.yml")); + String compose = java.nio.file.Files.readString(root.resolve("deploy/docker-compose.yml")); + String example = java.nio.file.Files.readString(root.resolve("deploy/env/.env.test.example")); + assertThat(nacos).contains("url: ${WAREHOUSE_DB_URL}"); + assertThat(compose).contains("WAREHOUSE_DB_URL: ${WAREHOUSE_OVERSEAS_DB_URL}"); + assertThat(example).contains("WAREHOUSE_OVERSEAS_DB_URL=jdbc:mysql://host.docker.internal:3306/baiyue_lastlag_warehouse") .doesNotContain("baiyue_warehouse_overseas"); } diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseDirectoryControllerTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseDirectoryControllerTest.java deleted file mode 100644 index ae3f2b09e..000000000 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/PlatformWarehouseDirectoryControllerTest.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.baiyue.warehouse.controller; - -import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.warehouse.dto.SharedWarehouseReference; -import com.baiyue.warehouse.service.DomesticWarehouseDirectory; -import com.baiyue.warehouse.service.WarehouseResourceService; -import org.junit.jupiter.api.Test; - -import java.util.List; -import java.util.Set; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -class PlatformWarehouseDirectoryControllerTest { - @Test - void pagesPublicSharedWarehouseOptions() { - DomesticWarehouseDirectory directory = mock(DomesticWarehouseDirectory.class); - when(directory.options()).thenReturn(List.of( - new SharedWarehouseReference(21L, "US-LAX", "洛杉矶仓", "ACTIVE"))); - var controller = new PlatformWarehouseDirectoryController(directory, mock(WarehouseResourceService.class)); - var user = new AuthenticatedUser(1L, 0L, 0L, 2L, - "tester", "PLATFORM", "PLATFORM", Set.of()); - - var page = controller.warehouses(user, 1, 200).data(); - - assertEquals(1, page.total()); - assertEquals(21L, page.records().get(0).id()); - assertEquals(0L, page.records().get(0).customerId()); - assertEquals("OVERSEAS", page.records().get(0).warehouseType()); - } -} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java index 3448dfe3f..a7ecbcb66 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/controller/WarehouseResourcePermissionContractTest.java @@ -34,10 +34,10 @@ class WarehouseResourcePermissionContractTest { } @Test - void warehouseDirectoryUsesCanonicalListAuthority() throws Exception { + void crossWarehousePalletDirectoryUsesCanonicalListAuthority() throws Exception { assertPermission(PlatformWarehouseDirectoryController.class.getDeclaredMethod( - "warehouses", com.baiyue.common.security.AuthenticatedUser.class, long.class, long.class), - PREFIX + "warehouse:list"); + "pallets", com.baiyue.common.security.AuthenticatedUser.class, com.baiyue.warehouse.dto.PalletRequests.QueryRequest.class), + PREFIX + "pallet:list"); } private static void assertPermission(Method method, String authority) { diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java index 5d37cc44c..4607692d2 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletTransitionServiceTest.java @@ -48,6 +48,43 @@ class WarehousePalletTransitionServiceTest { assertEquals(4, captor.getValue().getBeforeVersion()); } + @Test void releasesOccupiedPalletAtSameLargeLocationId() { + long locationId = 2104611550538616833L; + when(mapper.lockPublicPallet(88L, 10L)).thenReturn(pallet("P-LARGE", "OCCUPIED", 4, locationId)); + when(mapper.lockPublicLocation(88L, locationId)).thenReturn(location(locationId, "STORAGE", "OCCUPIED")); + when(mapper.transitionLocation(88L, locationId, "OCCUPIED", "IDLE")).thenReturn(1); + when(mapper.transition(88L, 10L, "IDLE", locationId, 4)).thenReturn(1); + + var response = service.apply(10L, new PalletTransitionRequests.Apply( + 88L, 4, "IDLE", locationId, "large-location-release", "swap")); + + assertEquals("IDLE", response.afterStatus()); + assertEquals(locationId, response.afterLocationId()); + verify(mapper, times(1)).lockPublicLocation(88L, locationId); + verify(mapper).transitionLocation(88L, locationId, "OCCUPIED", "IDLE"); + } + + @Test void movingIntoAndOutOfSharedOverflowKeepsItActive() { + WarehouseLocationEntity overflow = location(20L, "SHARED_OVERFLOW", "ACTIVE"); + overflow.setLocationCode("A0"); overflow.setSharedOverflow(true); + WarehousePalletEntity pallet = pallet("P-1", "OCCUPIED", 4); + when(mapper.lockPublicPallet(88L, 10L)).thenReturn(pallet); + when(mapper.lockPublicLocation(88L, 11L)).thenReturn(location(11L, "STORAGE", "OCCUPIED")); + when(mapper.lockPublicLocation(88L, 20L)).thenReturn(overflow); + when(mapper.transitionLocation(88L, 11L, "OCCUPIED", "IDLE")).thenReturn(1); + when(mapper.transition(88L, 10L, "OCCUPIED", 20L, 4)).thenReturn(1); + service.apply(10L, new PalletTransitionRequests.Apply(88L, 4, "OCCUPIED", 20L, "into-overflow", "move")); + + pallet.setLocationId(20L); pallet.setVersion(5); + when(mapper.lockPublicLocation(88L, 11L)).thenReturn(location(11L, "STORAGE", "IDLE")); + when(mapper.transitionLocation(88L, 11L, "IDLE", "OCCUPIED")).thenReturn(1); + when(mapper.transition(88L, 10L, "OCCUPIED", 11L, 5)).thenReturn(1); + service.apply(10L, new PalletTransitionRequests.Apply(88L, 5, "OCCUPIED", 11L, "out-of-overflow", "restore")); + + verify(mapper, never()).transitionLocation(eq(88L), eq(20L), anyString(), anyString()); + assertEquals("ACTIVE", overflow.getStatus()); + } + @Test void exactReplayDoesNotMutatePhysicalPalletAgain() { WarehousePalletOperationEntity existing = operation(); when(mapper.findOperation("operation-1")).thenReturn(existing); diff --git a/warehouse/AGENTS.md b/warehouse/AGENTS.md index 185d89f5b..98dd5d0fd 100644 --- a/warehouse/AGENTS.md +++ b/warehouse/AGENTS.md @@ -16,6 +16,7 @@ | FBA 目的仓公共主数据与 Admin 候选 | 权威表 `wh_warehouse` 的 `tenant_id=0 + warehouse_type=FBA` 记录;Admin 直接调用 `GET /api/platform/v1/warehouse-admin?warehouseType=FBA&status=ACTIVE&page=1&size=200`,权限 `platform:warehouse:list`,DTO `WarehouseSummaryResponse` 的 `id/warehouseCode/warehouseName/status/countryCode/primaryAddress.stateProvince` 分别作为 ID/编码/名称/状态/国家/区域 | | FBA 目的仓内部校验 | `InternalWarehouseController.java` 的 `GET /internal/warehouses/0/{warehouseId}/active`,使用内部服务令牌返回 `WarehouseDetailResponse`;调用方仍须校验 `warehouseType=FBA` 及 ID/编码/名称一致,不得把 `OVERSEAS` 实际收货仓或 `DOMESTIC` 中转仓当作 FBA 目的仓 | | 公共海外收货仓内部目录 | `controller/InternalWarehouseController.java` 的 `/internal/warehouses/shared-overseas`、`/shared-overseas/{warehouseId}`,响应 `dto/SharedOverseasWarehouseResponse.java`;只返回平台公共 `OVERSEAS + ACTIVE + RECEIVING` 仓,供海外资源服务按 warehouseId 引用 | +| 公共海外收货仓平台候选 | `controller/PlatformOverseasReceivingOptionsController.java`;统一 Gateway 的 `/api/platform/v1/warehouse-admin/overseas-receiving-options`,权限 `platform:overseas-warehouse:warehouse:list`,平台主体且公共作用域,固定 `OVERSEAS + ACTIVE + RECEIVING`;分页上限200,契约见 `src/main/resources/openapi/warehouse-v1.yaml` | | 海外仓合作仓类型 | 沿用仓库/地址/能力入口;同步核对仓库 DTO、OpenAPI、`src/main/resources/db/`、统一 SQL 及 Admin `modules/warehouses/`,并与普通海外仓语义明确区分 | | 往来单位/快递 | `controller/PlatformPartnerController.java`、`PlatformExpressCompanyController.java`、`service/PlatformPartnerQueryService.java`、`PartnerImportService.java` | | 托盘 | `controller/PlatformPalletController.java`、`service/PalletApplicationService.java` | diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformOverseasReceivingOptionsController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformOverseasReceivingOptionsController.java new file mode 100644 index 000000000..cd6b55f62 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformOverseasReceivingOptionsController.java @@ -0,0 +1,51 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.warehouse.service.WarehouseApplicationService; +import io.swagger.v3.oas.annotations.Operation; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** Read-only resource options, without granting warehouse master maintenance permissions. */ +@RestController +public class PlatformOverseasReceivingOptionsController { + private final WarehouseApplicationService service; + + public PlatformOverseasReceivingOptionsController(WarehouseApplicationService service) { + this.service = service; + } + + @GetMapping("/api/platform/v1/warehouse-admin/overseas-receiving-options") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:warehouse:list')") + @Operation(summary = "Page active shared overseas receiving warehouse options", + description = "Requires platform:overseas-warehouse:warehouse:list; only public OVERSEAS/ACTIVE/RECEIVING warehouses.") + public ApiResponse> warehouses( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(defaultValue = "1") long page, + @RequestParam(defaultValue = "200") long size) { + if (user == null || !"PLATFORM".equals(user.appCode()) + || user.tenantId() == null || user.tenantId() != 0L) { + throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问"); + } + if (page < 1 || size < 1 || size > 200) throw new BizException(ResultCode.BAD_REQUEST, "分页参数无效"); + List all = service.activeSharedOverseasWarehouses().stream() + .map(value -> new WarehouseOption(value.warehouseId(), 0L, value.warehouseCode(), + value.warehouseName(), "OVERSEAS", value.status())).toList(); + long pages = (all.size() + size - 1) / size; + long from = page > pages ? all.size() : (page - 1) * size; + long to = Math.min(from + size, all.size()); + return ApiResponse.success(new PageResult<>(all.subList((int) from, (int) to), page, size, all.size(), pages)); + } + + public record WarehouseOption(long id, long customerId, String warehouseCode, + String warehouseName, String warehouseType, String status) { } +} diff --git a/warehouse/src/main/resources/application-dev.yml b/warehouse/src/main/resources/application-dev.yml deleted file mode 100644 index 73de396cf..000000000 --- a/warehouse/src/main/resources/application-dev.yml +++ /dev/null @@ -1,6 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${WAREHOUSE_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${WAREHOUSE_DB_USERNAME:root} - password: ${WAREHOUSE_DB_PASSWORD:123456} diff --git a/warehouse/src/main/resources/application-test.yml b/warehouse/src/main/resources/application-test.yml deleted file mode 100644 index 5fb37b7f4..000000000 --- a/warehouse/src/main/resources/application-test.yml +++ /dev/null @@ -1,15 +0,0 @@ -spring: - cloud: - nacos: - config: - namespace: ${NACOS_NAMESPACE:test} - group: ${NACOS_GROUP:BAIYUE} - encode: UTF-8 - discovery: - namespace: ${NACOS_NAMESPACE:test} - group: ${NACOS_GROUP:BAIYUE} - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${WAREHOUSE_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_warehouse?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true} - username: ${WAREHOUSE_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}} - password: ${WAREHOUSE_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}} diff --git a/warehouse/src/main/resources/application.yml b/warehouse/src/main/resources/application.yml index 55de853bf..dd13b0081 100644 --- a/warehouse/src/main/resources/application.yml +++ b/warehouse/src/main/resources/application.yml @@ -2,28 +2,27 @@ server: port: ${WAREHOUSE_PORT:9205} spring: profiles: - active: ${SPRING_PROFILES_ACTIVE:dev} + active: ${SPRING_PROFILES_ACTIVE:test} application: name: warehouse-service config: import: - - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true - - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + - nacos:baiyue-common.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true + - nacos:${spring.application.name}.yml?group=${NACOS_GROUP:BAIYUE}&refreshEnabled=true cloud: nacos: - server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} - username: ${NACOS_USERNAME:nacos} - password: ${NACOS_PASSWORD:nacos} + server-addr: ${NACOS_SERVER_ADDR} + username: ${NACOS_USERNAME} + password: ${NACOS_PASSWORD} discovery: enabled: ${NACOS_DISCOVERY_ENABLED:true} register-enabled: ${NACOS_REGISTER_ENABLED:true} group: ${NACOS_GROUP:BAIYUE} - namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} config: - namespace: ${NACOS_NAMESPACE:public} + namespace: ${NACOS_NAMESPACE} encode: UTF-8 baiyue: security: - jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} - internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} + internal-service-token: ${INTERNAL_SERVICE_TOKEN} management.endpoints.web.exposure.include: health,info,prometheus diff --git a/warehouse/src/main/resources/openapi/warehouse-v1.yaml b/warehouse/src/main/resources/openapi/warehouse-v1.yaml index 909774b5e..75d263d1b 100644 --- a/warehouse/src/main/resources/openapi/warehouse-v1.yaml +++ b/warehouse/src/main/resources/openapi/warehouse-v1.yaml @@ -1,8 +1,46 @@ openapi: 3.0.3 info: - title: Warehouse internal order catalog + title: Warehouse catalog and receiving options version: 1.0.0 paths: + /api/platform/v1/warehouse-admin/overseas-receiving-options: + get: + summary: 分页查询启用的公共海外收货仓 + description: 仅允许平台主体(appCode=PLATFORM、tenantId=0)且具有 platform:overseas-warehouse:warehouse:list 权限。固定筛选公共 OVERSEAS、ACTIVE、RECEIVING 仓,不授予仓库主档维护权限。统一 Gateway 入口,统一 Access Token。 + security: + - bearerAuth: [] + parameters: + - name: page + in: query + schema: {type: integer, format: int64, minimum: 1, default: 1} + - name: size + in: query + schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 200} + responses: + '200': + description: 统一响应;code字符串200为成功,业务拒绝返回非200字符串code。Long字段序列化为十进制字符串。 + content: + application/json: + schema: + type: object + required: [code, message, data] + properties: + code: {type: string, example: '200'} + message: {type: string} + data: + type: object + nullable: true + properties: + page: {type: string, example: '1'} + size: {type: string, example: '200'} + total: {type: string, example: '2'} + pages: {type: string, example: '1'} + records: + type: array + items: + $ref: '#/components/schemas/ReceivingWarehouseOption' + '401': {description: 无有效统一会话} + '403': {description: 无访问权限} /internal/warehouses/order-catalog: get: summary: 查询订单可引用的启用公共仓库目录 @@ -12,7 +50,22 @@ paths: responses: '200': {description: 返回仓库 ID、编码、名称、类型和国家编码} components: + schemas: + ReceivingWarehouseOption: + type: object + required: [id, customerId, warehouseCode, warehouseName, warehouseType, status] + properties: + id: {type: string, description: 权威仓库ID} + customerId: {type: string, enum: ['0'], description: 平台公共资源作用域} + warehouseCode: {type: string} + warehouseName: {type: string} + warehouseType: {type: string, enum: [OVERSEAS]} + status: {type: string, enum: [ACTIVE]} securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT internalServiceToken: type: apiKey in: header diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformOverseasReceivingOptionsControllerTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformOverseasReceivingOptionsControllerTest.java new file mode 100644 index 000000000..e9a7ffc03 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformOverseasReceivingOptionsControllerTest.java @@ -0,0 +1,46 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.SharedOverseasWarehouseResponse; +import com.baiyue.warehouse.service.WarehouseApplicationService; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class PlatformOverseasReceivingOptionsControllerTest { + private final WarehouseApplicationService service = mock(WarehouseApplicationService.class); + private final PlatformOverseasReceivingOptionsController controller = new PlatformOverseasReceivingOptionsController(service); + private final AuthenticatedUser platform = new AuthenticatedUser(1L, 0L, 0L, 2L, + "tester", "PLATFORM", "PLATFORM", Set.of()); + + @Test + void pagesAuthoritativeSharedOptionsWithoutExposingMasterWrites() throws Exception { + when(service.activeSharedOverseasWarehouses()).thenReturn(List.of( + new SharedOverseasWarehouseResponse(21L, "US-LAX", "洛杉矶仓", "US", "ACTIVE"))); + var page = controller.warehouses(platform, 1, 200).data(); + assertEquals(1, page.total()); + assertEquals(21L, page.records().get(0).id()); + assertEquals(0L, page.records().get(0).customerId()); + assertEquals("OVERSEAS", page.records().get(0).warehouseType()); + assertEquals("hasAuthority('platform:overseas-warehouse:warehouse:list')", + controller.getClass().getMethod("warehouses", AuthenticatedUser.class, long.class, long.class) + .getAnnotation(PreAuthorize.class).value()); + assertTrue(controller.warehouses(platform, Long.MAX_VALUE, 200).data().records().isEmpty()); + } + + @Test + void rejectsInvalidScopeAndPaginationBeforeReadingDirectory() { + var merchant = new AuthenticatedUser(2L, 31L, 31L, 3L, "client", "MERCHANT", "MERCHANT", Set.of()); + assertThrows(BizException.class, () -> controller.warehouses(merchant, 1, 200)); + assertThrows(BizException.class, () -> controller.warehouses(null, 1, 200)); + assertThrows(BizException.class, () -> controller.warehouses(platform, 0, 200)); + assertThrows(BizException.class, () -> controller.warehouses(platform, 1, 201)); + verifyNoInteractions(service); + } +}